fix(security): harden Contact Us sender confirmations

This commit is contained in:
2026-11-09 13:06:22 +00:00
parent b821dc2092
commit 3c0a9ef721
7 changed files with 811 additions and 17 deletions
+21
View File
@@ -25,3 +25,24 @@ export const sendEmail = async (
throw error;
}
};
export const sendContactUsConfirmation = async (formValues, language) => {
const mailData = {
"contactEmail": formValues.contactEmail,
"contactSubject": formValues.contactSubject,
"contactbody": formValues.contactbody,
"typeOfContact": formValues.typeOfContact,
"language": language
};
try {
return await requestJson({
method: "post",
url: "/api/email/contactus-confirmation",
data: mailData
});
} catch (error) {
consoleLogger(error);
throw error;
}
};
+81
View File
@@ -0,0 +1,81 @@
const LINK_REMOVED = "[web link removed]";
const OBFUSCATING_SPACE = "[\\s\\u0000-\\u001f\\u007f\\u200b-\\u200d\\ufeff]*";
const SCHEME = `h${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}p(?:${OBFUSCATING_SPACE}s)?${OBFUSCATING_SPACE}:${OBFUSCATING_SPACE}\\/${OBFUSCATING_SPACE}\\/`;
const DOMAIN = "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}";
const URL_TAIL = "[^\\s<>\\\"']*";
const explicitUrlPattern = () => new RegExp(`${SCHEME}${DOMAIN}${URL_TAIL}`, "gi");
const wwwUrlPattern = () => new RegExp(`\\bwww\\.${DOMAIN}${URL_TAIL}`, "gi");
const bareDomainWithPathPattern = () =>
new RegExp(`(^|[^a-z0-9@._-])(${DOMAIN}\\/[^\\s<>\\\"']*)`, "gi");
const stripTrailingPunctuation = (value) => {
let destination = value;
let punctuation = "";
while (/[.,!;:]$/.test(destination)) {
punctuation = destination.slice(-1) + punctuation;
destination = destination.slice(0, -1);
}
while (/[)\]}]$/.test(destination)) {
punctuation = destination.slice(-1) + punctuation;
destination = destination.slice(0, -1);
}
return { destination, punctuation };
};
const replaceUrlMatch = (value) => {
const { destination, punctuation } = stripTrailingPunctuation(value);
return destination ? LINK_REMOVED + punctuation : value;
};
const containsWebDestination = (value) => {
explicitUrlPattern().lastIndex = 0;
wwwUrlPattern().lastIndex = 0;
bareDomainWithPathPattern().lastIndex = 0;
return (
explicitUrlPattern().test(value) ||
wwwUrlPattern().test(value) ||
bareDomainWithPathPattern().test(value)
);
};
export default function removeContactUsEmailLinks(message) {
if (typeof message !== "string") {
return message;
}
let transformed = message;
transformed = transformed.replace(
/<a\b[^>]*>([\s\S]*?)<\/a\s*>/gi,
(match, linkText) =>
containsWebDestination(match)
? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}`
: match
);
transformed = transformed.replace(/<[^>]+>/gi, (tag) =>
containsWebDestination(tag) ? LINK_REMOVED : tag
);
transformed = transformed.replace(
/\[([^\]]*)\]\(([^)]*)\)/gi,
(match, linkText, destination) =>
containsWebDestination(destination)
? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}`
: match
);
transformed = transformed.replace(explicitUrlPattern(), replaceUrlMatch);
transformed = transformed.replace(wwwUrlPattern(), replaceUrlMatch);
transformed = transformed.replace(
bareDomainWithPathPattern(),
(match, prefix, destination) => prefix + replaceUrlMatch(destination)
);
return transformed;
}
+246
View File
@@ -0,0 +1,246 @@
import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards";
import { consoleLogger } from "../../../actions/core/logger";
import removeContactUsEmailLinks from "../../../lib/security/contactus-email-links";
import { respondError, respondSuccess } from "../middleware/apiResponse";
const TEMPLATE_IDS = {
en: "69228c5f-8500-40ff-a6c5-95a9434c3ec8",
cy: "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3"
};
const REFERENCE = "PEDW-CONTACT-US";
const allowedSubjects = {
plq: [
"Questions about Planning legislation or policy",
"Planning legislation query",
"Ymholiad deddfwriaeth gynllunio"
],
ipq: [
"Questions or comments about applications for major infrastructure projects",
"Infrastructure procedure query",
"Ymholiad gweithdrefn seilwaith"
],
pcpq: [
"Questions or comments about planning and environmental appeals",
"Planning casework procedure query",
"Ymholiad gweithdrefn gwaith achos cynllunio"
],
pi: ["Query about using the Casework Portal", "Portal Issues", "Materion Porth"],
appadvice: [
"Advice How to submit an appeal",
"Appeal submission advice",
"Cyngor cyflwyno apêl"
],
repadvice: [
"Advice How to comment on an appeal or application",
"Representation submission advice",
"Cyngor cyflwyno sylwadau"
],
ceq: [
"Queries about forthcoming Hearing or Inquiry event",
"Casework Events Query",
"Ymholiad Digwyddiadau Gwaith Achos"
],
other: ["Other enquiry for PEDW", "Any Other Enquiries", "Unrhyw Ymholiadau Eraill"],
postdec: [
"Issues or questions about a case after the decision is issued",
"Post Decision",
"Ar ôl Penderfyniad"
],
comp: [
"General Complaints of Compliments",
"Complaints/Complements",
"Cwynion/Canmoliaethau"
]
};
const allowedFields = new Set([
"contactEmail",
"contactSubject",
"contactbody",
"typeOfContact",
"language"
]);
const emailRegex =
/^(?!.*(?:.+\.)?(?:burpcollaborator\.net|interactsh\.com|mailinator\.com|10minutemail\.com|yopmail\.com|trashmail\.com)$)(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:.+)])$/;
// Origin checking is browser defence in depth only. It is not authentication and
// does not prevent a direct HTTP client from attempting abuse.
const hasTrustedOrigin = (req) => {
const requestOrigin = req?.headers?.origin;
if (typeof requestOrigin !== "string" || requestOrigin === "null") {
return false;
}
try {
const requestUrl = new URL(requestOrigin);
if (requestOrigin !== requestUrl.origin) {
return false;
}
const trustedOrigins = [process.env.NEXTAUTH_URL, process.env.CY_API_ROOT]
.filter(
(configuredUrl) =>
typeof configuredUrl === "string" && configuredUrl.length > 0
)
.map((configuredUrl) => new URL(configuredUrl).origin);
return trustedOrigins.includes(requestUrl.origin);
} catch (_error) {
return false;
}
};
const validationError = (res, code, message) =>
respondError(res, { status: 400, code, message });
const isPlainObject = (value) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype?.constructor?.name === "Object";
};
export default async function ApiProxy(req, res) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return respondError(res, {
status: 405,
code: "METHOD_NOT_ALLOWED",
message: "Method not allowed"
});
}
const contentType = req.headers?.["content-type"];
if (
typeof contentType !== "string" ||
contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json"
) {
return respondError(res, {
status: 415,
code: "UNSUPPORTED_MEDIA_TYPE",
message: "Content-Type must be application/json"
});
}
if (!hasTrustedOrigin(req)) {
return respondError(res, {
status: 403,
code: "FORBIDDEN",
message: "Request not permitted"
});
}
const requestBody = req.body;
if (!isPlainObject(requestBody)) {
return validationError(
res,
"INVALID_REQUEST_BODY",
"Request body must be a JSON object"
);
}
const requestFields = Object.keys(requestBody);
if (
requestFields.length !== allowedFields.size ||
requestFields.some((field) => !allowedFields.has(field))
) {
return validationError(
res,
"INVALID_REQUEST_FIELDS",
"Request must contain exactly the supported fields"
);
}
const contactEmail = sanitizeString(requestBody.contactEmail);
const contactSubject = sanitizeString(requestBody.contactSubject);
const contactBody = sanitizeString(requestBody.contactbody);
const typeOfContact = requestBody.typeOfContact;
const language = requestBody.language;
if (
typeof requestBody.contactEmail !== "string" ||
!isNonEmptyString(contactEmail) ||
contactEmail.length > 200 ||
!emailRegex.test(contactEmail)
) {
return validationError(
res,
"INVALID_CONTACT_EMAIL",
"contactEmail must be a valid email address of 200 characters or fewer"
);
}
if (
typeof typeOfContact !== "string" ||
!Object.prototype.hasOwnProperty.call(allowedSubjects, typeOfContact)
) {
return validationError(
res,
"INVALID_CONTACT_TYPE",
"typeOfContact is not supported"
);
}
if (
typeof requestBody.contactbody !== "string" ||
!isNonEmptyString(contactBody) ||
contactBody.length > 800
) {
return validationError(
res,
"INVALID_CONTACT_BODY",
"contactbody must contain between 1 and 800 characters"
);
}
if (
typeof requestBody.contactSubject !== "string" ||
!allowedSubjects[typeOfContact].includes(contactSubject)
) {
return validationError(
res,
"INVALID_CONTACT_SUBJECT",
"contactSubject does not match typeOfContact"
);
}
if (language !== "en" && language !== "cy") {
return validationError(
res,
"INVALID_LANGUAGE",
"language must be either en or cy"
);
}
const personalisation = {
contactEmail,
contactSubject,
contactbody: removeContactUsEmailLinks(contactBody),
typeOfContact
};
const NotifyClient = require("notifications-node-client").NotifyClient;
const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY);
try {
await notifyClient.sendEmail(TEMPLATE_IDS[language], contactEmail, {
personalisation,
reference: REFERENCE
});
return respondSuccess(res, { sent: true });
} catch (error) {
consoleLogger(error);
return respondError(res, {
status: 400,
code: "CONTACT_US_CONFIRMATION_FAILED",
message: "Failed to send Contact Us confirmation email"
});
}
}
+3 -8
View File
@@ -18,6 +18,7 @@ import {
import { connect } from "react-redux";
import { createCRMTask } from "../actions/services/integrationService";
import { sendEmail } from "../actions/services/notifyService";
import { sendContactUsConfirmation } from "../actions/services/notifyDirectService";
import { wrapper } from "../store/store";
import { useState } from "react";
@@ -329,16 +330,10 @@ let ContactUs = (props) => {
.children
});
const reference = "PEDW-CONTACT-US";
const templateId = "69228c5f-8500-40ff-a6c5-95a9434c3ec8";
const templateIdCY = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3";
try {
const res = await sendEmail(
router.locale !== "en" ? templateIdCY : templateId,
formValues.contactEmail,
const res = await sendContactUsConfirmation(
formValues,
reference
router.locale !== "en" ? "cy" : "en"
);
console.log("Email sent successfully.");
+3 -8
View File
@@ -18,6 +18,7 @@ import {
import { connect } from "react-redux";
import { createCRMTask } from "../../actions/services/integrationService";
import { sendEmail } from "../../actions/services/notifyService";
import { sendContactUsConfirmation } from "../../actions/services/notifyDirectService";
import { wrapper } from "../../store/store";
import { useState } from "react";
@@ -329,16 +330,10 @@ let ContactUs = (props) => {
.children
});
const reference = "PEDW-CONTACT-US";
const templateId = "69228c5f-8500-40ff-a6c5-95a9434c3ec8";
const templateIdCY = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3";
try {
const res = await sendEmail(
router.locale !== "en" ? templateIdCY : templateId,
formValues.contactEmail,
const res = await sendContactUsConfirmation(
formValues,
reference
router.locale !== "en" ? "cy" : "en"
);
console.log("Email sent successfully.");
@@ -337,6 +337,386 @@ test("notify send failure returns EMAIL_NOTIFY_FAILED", async () => {
assert.strictEqual(res.state.jsonBody.error.code, "EMAIL_NOTIFY_FAILED");
});
const trustedContactOrigin = "https://portal.example.test";
const trustedWelshContactOrigin = "https://porth.example.test";
const contactConfirmationTemplateEn = "69228c5f-8500-40ff-a6c5-95a9434c3ec8";
const contactConfirmationTemplateCy = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3";
const contactSubjects = {
plq: {
en: "Questions about Planning legislation or policy",
cy: "Ymholiad deddfwriaeth gynllunio"
},
ipq: {
en: "Questions or comments about applications for major infrastructure projects",
cy: "Ymholiad gweithdrefn seilwaith"
},
pcpq: {
en: "Questions or comments about planning and environmental appeals",
cy: "Ymholiad gweithdrefn gwaith achos cynllunio"
},
pi: { en: "Query about using the Casework Portal", cy: "Materion Porth" },
appadvice: { en: "Advice How to submit an appeal", cy: "Cyngor cyflwyno apêl" },
repadvice: {
en: "Advice How to comment on an appeal or application",
cy: "Cyngor cyflwyno sylwadau"
},
ceq: {
en: "Queries about forthcoming Hearing or Inquiry event",
cy: "Ymholiad Digwyddiadau Gwaith Achos"
},
other: { en: "Other enquiry for PEDW", cy: "Unrhyw Ymholiadau Eraill" },
postdec: {
en: "Issues or questions about a case after the decision is issued",
cy: "Ar ôl Penderfyniad"
},
comp: {
en: "General Complaints of Compliments",
cy: "Cwynion/Canmoliaethau"
}
};
const loadContactLinkUtility = () =>
loadModule("lib/security/contactus-email-links.js").default;
const validContactConfirmationRequest = (overrides = {}) => ({
method: "POST",
headers: {
origin: trustedContactOrigin,
"content-type": "application/json; charset=utf-8"
},
body: {
contactEmail: "applicant@example.com",
contactSubject: contactSubjects.ipq.en,
contactbody: "Please help with my enquiry.",
typeOfContact: "ipq",
language: "en"
},
...overrides
});
const loadContactConfirmationEndpoint = ({ sendEmail } = {}) => {
const notifyCalls = [];
const removeContactUsEmailLinks = loadContactLinkUtility();
const notifySend =
sendEmail ||
(async (...args) => {
notifyCalls.push(args);
return { id: "notify-1" };
});
const mod = loadModule("pages/api/email/contactus-confirmation.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
sanitizeString: (value) => (typeof value === "string" ? value.trim() : ""),
isNonEmptyString: (value) =>
typeof value === "string" && value.trim().length > 0,
removeContactUsEmailLinks,
consoleLogger: () => {},
require: (name) => {
if (name === "notifications-node-client") {
return {
NotifyClient: function NotifyClient() {
return { sendEmail: notifySend };
}
};
}
return require(name);
}
});
return { mod, notifyCalls };
};
const withContactOrigins = async (fn) => {
const previousNextAuthUrl = process.env.NEXTAUTH_URL;
const previousCyApiRoot = process.env.CY_API_ROOT;
process.env.NEXTAUTH_URL = trustedContactOrigin;
process.env.CY_API_ROOT = trustedWelshContactOrigin;
try {
await fn();
} finally {
if (previousNextAuthUrl === undefined) delete process.env.NEXTAUTH_URL;
else process.env.NEXTAUTH_URL = previousNextAuthUrl;
if (previousCyApiRoot === undefined) delete process.env.CY_API_ROOT;
else process.env.CY_API_ROOT = previousCyApiRoot;
}
};
const assertContactConfirmationRejected = async ({ req, status, code }) => {
await withContactOrigins(async () => {
const loaded = loadContactConfirmationEndpoint();
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, status);
assert.strictEqual(res.state.jsonBody.error.code, code);
assert.strictEqual(loaded.notifyCalls.length, 0);
});
};
test("Contact Us link removal preserves text while removing recognised destinations", async () => {
const removeLinks = loadContactLinkUtility();
const input = [
"Please review https://example.invalid/path?token=abc#details.",
"Also HTTP://OTHER.INVALID/login and www.third.invalid/help.",
"Bare planning.example.invalid/application/123 remains represented safely.",
"Email applicant@example.com and reference APP/123456 must remain.",
"Markdown [review this](https://markdown.invalid/login).",
'HTML <a href="https://html.invalid/login">review this</a>.',
'Tag <img src="https://images.invalid/pixel"> remains non-actionable.',
"Obfuscated h\u200btt\u200bps://obfuscated.invalid/login is removed."
].join("\n");
const output = removeLinks(input);
assert.strictEqual(
output,
[
"Please review [web link removed].",
"Also [web link removed] and [web link removed].",
"Bare [web link removed] remains represented safely.",
"Email applicant@example.com and reference APP/123456 must remain.",
"Markdown review this [web link removed].",
"HTML review this [web link removed].",
"Tag [web link removed] remains non-actionable.",
"Obfuscated [web link removed] is removed."
].join("\n")
);
assert.strictEqual(input.includes("https://example.invalid"), true);
});
test("Contact Us confirmation is POST-only with Allow header", async () => {
const req = validContactConfirmationRequest({ method: "GET" });
await withContactOrigins(async () => {
const loaded = loadContactConfirmationEndpoint();
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 405);
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
assert.strictEqual(res.state.headers.allow, "POST");
assert.strictEqual(loaded.notifyCalls.length, 0);
});
});
for (const contentType of [undefined, "text/plain", "application/x-www-form-urlencoded"]) {
test(`Contact Us confirmation rejects content type ${String(contentType)}`, async () => {
const req = validContactConfirmationRequest({
headers: {
origin: trustedContactOrigin,
...(contentType ? { "content-type": contentType } : {})
}
});
await assertContactConfirmationRejected({
req,
status: 415,
code: "UNSUPPORTED_MEDIA_TYPE"
});
});
}
for (const origin of [undefined, "null", "https://attacker.example", `${trustedContactOrigin}/path`]) {
test(`Contact Us confirmation rejects origin ${String(origin)}`, async () => {
const req = validContactConfirmationRequest({
headers: {
...(origin ? { origin } : {}),
"content-type": "application/json"
}
});
await assertContactConfirmationRejected({ req, status: 403, code: "FORBIDDEN" });
});
}
test("Contact Us confirmation accepts each configured exact origin", async () => {
await withContactOrigins(async () => {
for (const origin of [trustedContactOrigin, trustedWelshContactOrigin]) {
const loaded = loadContactConfirmationEndpoint();
const req = validContactConfirmationRequest({
headers: { origin, "content-type": "application/json" }
});
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.notifyCalls.length, 1);
}
});
});
for (const body of [null, [], "not-an-object", new Date()]) {
test(`Contact Us confirmation rejects non-plain body ${Object.prototype.toString.call(body)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({ body }),
status: 400,
code: "INVALID_REQUEST_BODY"
});
});
}
for (const bodyOverride of [
{ extra: "field" },
{ language: undefined },
{ contactSubject: undefined }
]) {
test(`Contact Us confirmation enforces exact fields ${Object.keys(bodyOverride).join(",")}`, async () => {
const body = { ...validContactConfirmationRequest().body, ...bodyOverride };
if (Object.values(bodyOverride).includes(undefined)) {
for (const key of Object.keys(bodyOverride)) delete body[key];
}
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({ body }),
status: 400,
code: "INVALID_REQUEST_FIELDS"
});
});
}
for (const contactEmail of [undefined, "invalid", `${"a".repeat(190)}@example.com`]) {
test(`Contact Us confirmation rejects invalid email ${String(contactEmail).slice(0, 30)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: { ...validContactConfirmationRequest().body, contactEmail }
}),
status: 400,
code: "INVALID_CONTACT_EMAIL"
});
});
}
for (const typeOfContact of [undefined, "unsupported"]) {
test(`Contact Us confirmation rejects category ${String(typeOfContact)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: { ...validContactConfirmationRequest().body, typeOfContact }
}),
status: 400,
code: "INVALID_CONTACT_TYPE"
});
});
}
for (const contactbody of [undefined, " ", "a".repeat(801)]) {
test(`Contact Us confirmation rejects message ${String(contactbody).slice(0, 20)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: { ...validContactConfirmationRequest().body, contactbody }
}),
status: 400,
code: "INVALID_CONTACT_BODY"
});
});
}
test("Contact Us confirmation rejects a subject not matching its category", async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: {
...validContactConfirmationRequest().body,
contactSubject: contactSubjects.plq.en
}
}),
status: 400,
code: "INVALID_CONTACT_SUBJECT"
});
});
for (const language of [undefined, "CY", "fr", ""]) {
test(`Contact Us confirmation rejects language ${String(language)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: { ...validContactConfirmationRequest().body, language }
}),
status: 400,
code: "INVALID_LANGUAGE"
});
});
}
test("Contact Us confirmation selects English template and exact safe personalisation", async () => {
await withContactOrigins(async () => {
const loaded = loadContactConfirmationEndpoint();
const req = validContactConfirmationRequest({
body: {
...validContactConfirmationRequest().body,
contactbody:
"Please review (https://example.invalid/path?q=1#part), then email applicant@example.com about APP/123456."
}
});
const originalBody = JSON.parse(JSON.stringify(req.body));
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.notifyCalls.length, 1);
assert.strictEqual(loaded.notifyCalls[0][0], contactConfirmationTemplateEn);
assert.strictEqual(loaded.notifyCalls[0][1], "applicant@example.com");
assert.deepStrictEqual(JSON.parse(JSON.stringify(loaded.notifyCalls[0][2])), {
personalisation: {
contactEmail: "applicant@example.com",
contactSubject: contactSubjects.ipq.en,
contactbody:
"Please review ([web link removed]), then email applicant@example.com about APP/123456.",
typeOfContact: "ipq"
},
reference: "PEDW-CONTACT-US"
});
assert.deepStrictEqual(req.body, originalBody);
});
});
test("Contact Us confirmation selects Welsh template and supports an 800-character message", async () => {
await withContactOrigins(async () => {
const loaded = loadContactConfirmationEndpoint();
const message = "a".repeat(800);
const req = validContactConfirmationRequest({
body: {
contactEmail: "applicant@example.com",
contactSubject: contactSubjects.plq.cy,
contactbody: message,
typeOfContact: "plq",
language: "cy"
}
});
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.notifyCalls[0][0], contactConfirmationTemplateCy);
assert.strictEqual(loaded.notifyCalls[0][2].personalisation.contactbody, message);
});
});
test("Contact Us confirmation returns a bounded error when Notify fails", async () => {
await withContactOrigins(async () => {
let sendCalls = 0;
const loaded = loadContactConfirmationEndpoint({
sendEmail: async () => {
sendCalls += 1;
throw new Error("notify failed");
}
});
const res = createRes();
await loaded.mod.default(validContactConfirmationRequest(), res);
assert.strictEqual(sendCalls, 1);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
"CONTACT_US_CONFIRMATION_FAILED"
);
});
});
const run = async () => {
let passed = 0;
for (const currentTest of tests) {
+77 -1
View File
@@ -208,7 +208,21 @@ test("document delete blob flows use signer hash", async () => {
axios,
BASE_URL: "",
consoleLogger: logger.consoleLogger,
hashAPIPath: () => "&hash=fallback"
hashAPIPath: () => "&hash=fallback",
postSignedFileJson: async (queryUrl, data, config = {}) => {
const hashResponse = await axios.get(
"/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl)
);
const response = await axios({
method: "post",
url: queryUrl + hashResponse.data.hash,
data,
...config
});
return response.data;
}
});
const one = await document.deleteAwaitingSubmissionsFromBlob(
@@ -384,6 +398,68 @@ test("notify/sendEmail logs and rethrows on failure", async () => {
assert.strictEqual(logger.calls.length, 1);
});
test("notify/sendContactUsConfirmation posts only the dedicated payload", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
axios.requestHandler = async () => ({ data: { sent: true } });
const notify = loadServiceModule("notifyDirectService.js", {
axios,
consoleLogger: logger.consoleLogger
});
const formValues = {
contactEmail: "person@example.com",
contactSubject: "Questions about Planning legislation or policy",
contactbody: "Please review https://example.invalid",
typeOfContact: "plq",
ignored: "not sent"
};
const result = await notify.sendContactUsConfirmation(formValues, "cy");
assert.deepStrictEqual(normalize(result), { sent: true });
assert.strictEqual(
axios.calls[0].config.url,
"/api/email/contactus-confirmation"
);
assert.strictEqual(axios.calls[0].config.method, "post");
assert.deepStrictEqual(normalize(axios.calls[0].config.data), {
contactEmail: "person@example.com",
contactSubject: "Questions about Planning legislation or policy",
contactbody: "Please review https://example.invalid",
typeOfContact: "plq",
language: "cy"
});
});
test("notify/sendContactUsConfirmation logs and rethrows on failure", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
const error = createAxiosError(400, "Bad Request");
axios.requestHandler = async () => Promise.reject(error);
const notify = loadServiceModule("notifyDirectService.js", {
axios,
consoleLogger: logger.consoleLogger
});
await assert.rejects(
notify.sendContactUsConfirmation(
{
contactEmail: "person@example.com",
contactSubject: "Other enquiry for PEDW",
contactbody: "Message",
typeOfContact: "other"
},
"en"
),
(thrown) => thrown === error
);
assert.strictEqual(logger.calls.length, 1);
});
test("integration/createCRMTask posts payload and returns data", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();