From 3c0a9ef721e8d3d82d20e421d773082c5602a789 Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 9 Nov 2026 13:06:22 +0000 Subject: [PATCH] fix(security): harden Contact Us sender confirmations --- actions/services/notifyDirectService.js | 21 + lib/security/contactus-email-links.js | 81 ++++ pages/api/email/contactus-confirmation.js | 246 ++++++++++++ pages/contactus.js | 11 +- pages/myportal/contactus.js | 11 +- tests/phase21/email-handler-contract.test.cjs | 380 ++++++++++++++++++ tests/phase7/service-behaviour.test.cjs | 78 +++- 7 files changed, 811 insertions(+), 17 deletions(-) create mode 100644 lib/security/contactus-email-links.js create mode 100644 pages/api/email/contactus-confirmation.js diff --git a/actions/services/notifyDirectService.js b/actions/services/notifyDirectService.js index b36b64de..491b4d63 100644 --- a/actions/services/notifyDirectService.js +++ b/actions/services/notifyDirectService.js @@ -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; + } +}; diff --git a/lib/security/contactus-email-links.js b/lib/security/contactus-email-links.js new file mode 100644 index 00000000..cc49475a --- /dev/null +++ b/lib/security/contactus-email-links.js @@ -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( + /]*>([\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; +} diff --git a/pages/api/email/contactus-confirmation.js b/pages/api/email/contactus-confirmation.js new file mode 100644 index 00000000..3ebe8393 --- /dev/null +++ b/pages/api/email/contactus-confirmation.js @@ -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" + }); + } +} diff --git a/pages/contactus.js b/pages/contactus.js index e877ceb9..9266efb3 100644 --- a/pages/contactus.js +++ b/pages/contactus.js @@ -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."); diff --git a/pages/myportal/contactus.js b/pages/myportal/contactus.js index 05ea94b7..fd4251e8 100644 --- a/pages/myportal/contactus.js +++ b/pages/myportal/contactus.js @@ -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."); diff --git a/tests/phase21/email-handler-contract.test.cjs b/tests/phase21/email-handler-contract.test.cjs index c648325c..a861a135 100644 --- a/tests/phase21/email-handler-contract.test.cjs +++ b/tests/phase21/email-handler-contract.test.cjs @@ -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 review this.', + 'Tag 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) { diff --git a/tests/phase7/service-behaviour.test.cjs b/tests/phase7/service-behaviour.test.cjs index e53a3071..270292a1 100644 --- a/tests/phase7/service-behaviour.test.cjs +++ b/tests/phase7/service-behaviour.test.cjs @@ -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();