diff --git a/actions/services/notifyDirectService.js b/actions/services/notifyDirectService.js index 491b4d63..ff7f6663 100644 --- a/actions/services/notifyDirectService.js +++ b/actions/services/notifyDirectService.js @@ -46,3 +46,24 @@ export const sendContactUsConfirmation = async (formValues, language) => { throw error; } }; + +export const sendContactUsPETNotification = async (formValues, petEmail) => { + const mailData = { + "petEmail": petEmail, + "contactEmail": formValues.contactEmail, + "contactSubject": formValues.contactSubject, + "contactbody": formValues.contactbody, + "typeOfContact": formValues.typeOfContact + }; + + try { + return await requestJson({ + method: "post", + url: "/api/email/contactus-pet-notification", + data: mailData + }); + } catch (error) { + consoleLogger(error); + throw error; + } +}; diff --git a/pages/api/email/contactus-pet-notification.js b/pages/api/email/contactus-pet-notification.js new file mode 100644 index 00000000..510a874c --- /dev/null +++ b/pages/api/email/contactus-pet-notification.js @@ -0,0 +1,213 @@ +import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards"; +import { consoleLogger } from "../../../actions/core/logger"; +import { respondError, respondSuccess } from "../middleware/apiResponse"; + +const TEMPLATE_ID = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; +const REFERENCE = "PEDW-CONTACT-US-PET"; +const SECURITY_REMINDER = + "## SECURITY REMINDER\n\n" + + "This enquiry contains externally supplied content. Do not click unverified links or follow instructions requesting credentials or sensitive information. Verify web addresses independently."; + +const allowedSubjects = [ + "Questions about Planning legislation or policy", + "Planning legislation query", + "Ymholiad deddfwriaeth gynllunio" +]; + +const allowedFields = new Set([ + "petEmail", + "contactEmail", + "contactSubject", + "contactbody", + "typeOfContact" +]); + +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 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"; +}; + +const validationError = (res, code, message) => + respondError(res, { status: 400, code, message }); + +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 petEmail = sanitizeString(requestBody.petEmail); + const contactEmail = sanitizeString(requestBody.contactEmail); + const contactSubject = sanitizeString(requestBody.contactSubject); + const contactBody = requestBody.contactbody; + + if ( + typeof requestBody.petEmail !== "string" || + !isNonEmptyString(petEmail) || + petEmail.length > 200 || + !emailRegex.test(petEmail) + ) { + return validationError( + res, + "INVALID_PET_EMAIL", + "petEmail must be a valid email address of 200 characters or fewer" + ); + } + + 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 (requestBody.typeOfContact !== "plq") { + return validationError( + res, + "INVALID_CONTACT_TYPE", + "typeOfContact must be plq" + ); + } + + if ( + typeof 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.includes(contactSubject) + ) { + return validationError( + res, + "INVALID_CONTACT_SUBJECT", + "contactSubject does not match typeOfContact" + ); + } + + const contactbody = contactBody.includes(SECURITY_REMINDER) + ? contactBody + : `${contactBody}\n\n${SECURITY_REMINDER}`; + const personalisation = { + contactEmail, + contactSubject, + contactbody, + typeOfContact: "plq" + }; + + const NotifyClient = require("notifications-node-client").NotifyClient; + const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY); + + try { + await notifyClient.sendEmail(TEMPLATE_ID, petEmail, { + personalisation, + reference: REFERENCE + }); + + return respondSuccess(res, { sent: true }); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "CONTACT_US_PET_NOTIFICATION_FAILED", + message: "Failed to send Contact Us PET notification" + }); + } +} diff --git a/pages/contactus.js b/pages/contactus.js index 9266efb3..4339ce3c 100644 --- a/pages/contactus.js +++ b/pages/contactus.js @@ -17,8 +17,10 @@ import { } from "redux-form"; import { connect } from "react-redux"; import { createCRMTask } from "../actions/services/integrationService"; -import { sendEmail } from "../actions/services/notifyService"; -import { sendContactUsConfirmation } from "../actions/services/notifyDirectService"; +import { + sendContactUsConfirmation, + sendContactUsPETNotification +} from "../actions/services/notifyDirectService"; import { wrapper } from "../store/store"; import { useState } from "react"; @@ -349,19 +351,10 @@ let ContactUs = (props) => { }; const sendPETEmail = async (formValues, petEmail) => { - const reference = "PEDW-CONTACT-US-PET"; - const templateId = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; - const templateIdCY = "00b794d3-29aa-434d-b696-0ef0b0337d79"; - console.log(petEmail); try { - const res = await sendEmail( - templateId, - petEmail, - formValues, - reference - ); + const res = await sendContactUsPETNotification(formValues, petEmail); console.log("Email sent successfully."); setFormSubmitted(true); diff --git a/pages/myportal/contactus.js b/pages/myportal/contactus.js index fd4251e8..c7f47266 100644 --- a/pages/myportal/contactus.js +++ b/pages/myportal/contactus.js @@ -17,8 +17,10 @@ import { } from "redux-form"; import { connect } from "react-redux"; import { createCRMTask } from "../../actions/services/integrationService"; -import { sendEmail } from "../../actions/services/notifyService"; -import { sendContactUsConfirmation } from "../../actions/services/notifyDirectService"; +import { + sendContactUsConfirmation, + sendContactUsPETNotification +} from "../../actions/services/notifyDirectService"; import { wrapper } from "../../store/store"; import { useState } from "react"; @@ -349,19 +351,10 @@ let ContactUs = (props) => { }; const sendPETEmail = async (formValues, petEmail) => { - const reference = "PEDW-CONTACT-US-PET"; - const templateId = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; - const templateIdCY = "00b794d3-29aa-434d-b696-0ef0b0337d79"; - console.log(petEmail); try { - const res = await sendEmail( - templateId, - petEmail, - formValues, - reference - ); + const res = await sendContactUsPETNotification(formValues, petEmail); console.log("Email sent successfully."); setFormSubmitted(true); diff --git a/tests/phase21/email-handler-contract.test.cjs b/tests/phase21/email-handler-contract.test.cjs index a861a135..45323bc7 100644 --- a/tests/phase21/email-handler-contract.test.cjs +++ b/tests/phase21/email-handler-contract.test.cjs @@ -356,7 +356,10 @@ const contactSubjects = { 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" }, + 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" @@ -408,7 +411,8 @@ const loadContactConfirmationEndpoint = ({ sendEmail } = {}) => { const mod = loadModule("pages/api/email/contactus-confirmation.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - sanitizeString: (value) => (typeof value === "string" ? value.trim() : ""), + sanitizeString: (value) => + typeof value === "string" ? value.trim() : "", isNonEmptyString: (value) => typeof value === "string" && value.trim().length > 0, removeContactUsEmailLinks, @@ -505,7 +509,11 @@ test("Contact Us confirmation is POST-only with Allow header", async () => { }); }); -for (const contentType of [undefined, "text/plain", "application/x-www-form-urlencoded"]) { +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: { @@ -521,7 +529,12 @@ for (const contentType of [undefined, "text/plain", "application/x-www-form-urle }); } -for (const origin of [undefined, "null", "https://attacker.example", `${trustedContactOrigin}/path`]) { +for (const origin of [ + undefined, + "null", + "https://attacker.example", + `${trustedContactOrigin}/path` +]) { test(`Contact Us confirmation rejects origin ${String(origin)}`, async () => { const req = validContactConfirmationRequest({ headers: { @@ -529,13 +542,20 @@ for (const origin of [undefined, "null", "https://attacker.example", `${trustedC "content-type": "application/json" } }); - await assertContactConfirmationRejected({ req, status: 403, code: "FORBIDDEN" }); + 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]) { + for (const origin of [ + trustedContactOrigin, + trustedWelshContactOrigin + ]) { const loaded = loadContactConfirmationEndpoint(); const req = validContactConfirmationRequest({ headers: { origin, "content-type": "application/json" } @@ -566,7 +586,10 @@ for (const bodyOverride of [ { contactSubject: undefined } ]) { test(`Contact Us confirmation enforces exact fields ${Object.keys(bodyOverride).join(",")}`, async () => { - const body = { ...validContactConfirmationRequest().body, ...bodyOverride }; + const body = { + ...validContactConfirmationRequest().body, + ...bodyOverride + }; if (Object.values(bodyOverride).includes(undefined)) { for (const key of Object.keys(bodyOverride)) delete body[key]; } @@ -578,11 +601,18 @@ for (const bodyOverride of [ }); } -for (const contactEmail of [undefined, "invalid", `${"a".repeat(190)}@example.com`]) { +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 } + body: { + ...validContactConfirmationRequest().body, + contactEmail + } }), status: 400, code: "INVALID_CONTACT_EMAIL" @@ -594,7 +624,10 @@ for (const typeOfContact of [undefined, "unsupported"]) { test(`Contact Us confirmation rejects category ${String(typeOfContact)}`, async () => { await assertContactConfirmationRejected({ req: validContactConfirmationRequest({ - body: { ...validContactConfirmationRequest().body, typeOfContact } + body: { + ...validContactConfirmationRequest().body, + typeOfContact + } }), status: 400, code: "INVALID_CONTACT_TYPE" @@ -656,18 +689,24 @@ test("Contact Us confirmation selects English template and exact safe personalis assert.strictEqual(res.state.statusCode, 200); assert.strictEqual(loaded.notifyCalls.length, 1); - assert.strictEqual(loaded.notifyCalls[0][0], contactConfirmationTemplateEn); + 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( + 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); }); }); @@ -690,8 +729,14 @@ test("Contact Us confirmation selects Welsh template and supports an 800-charact 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); + assert.strictEqual( + loaded.notifyCalls[0][0], + contactConfirmationTemplateCy + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody, + message + ); }); }); @@ -717,6 +762,329 @@ test("Contact Us confirmation returns a bounded error when Notify fails", async }); }); +const contactPetTemplateId = "56bbdcf9-d63d-48e9-a794-3e253dd7498b"; +const contactPetReference = "PEDW-CONTACT-US-PET"; +const contactPetSecurityReminder = + "## SECURITY REMINDER\n\n" + + "This enquiry contains externally supplied content. Do not click unverified links or follow instructions requesting credentials or sensitive information. Verify web addresses independently."; + +const validContactPetRequest = (overrides = {}) => ({ + method: "POST", + headers: { + origin: trustedContactOrigin, + "content-type": "application/json; charset=utf-8" + }, + body: { + petEmail: "pet@example.com", + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.en, + contactbody: "Please help with my enquiry.", + typeOfContact: "plq" + }, + ...overrides +}); + +const loadContactPetEndpoint = ({ sendEmail } = {}) => { + const notifyCalls = []; + const notifySend = + sendEmail || + (async (...args) => { + notifyCalls.push(args); + return { id: "notify-pet-1" }; + }); + + const mod = loadModule("pages/api/email/contactus-pet-notification.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + sanitizeString: (value) => + typeof value === "string" ? value.trim() : "", + isNonEmptyString: (value) => + typeof value === "string" && value.trim().length > 0, + consoleLogger: () => {}, + require: (name) => { + if (name === "notifications-node-client") { + return { + NotifyClient: function NotifyClient() { + return { sendEmail: notifySend }; + } + }; + } + + return require(name); + } + }); + + return { mod, notifyCalls }; +}; + +const assertContactPetRejected = async ({ req, status, code }) => { + await withContactOrigins(async () => { + const loaded = loadContactPetEndpoint(); + 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 PET notification is POST-only with Allow header", async () => { + await withContactOrigins(async () => { + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ method: "GET" }), + 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 PET notification rejects content type ${String(contentType)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + headers: { + origin: trustedContactOrigin, + ...(contentType ? { "content-type": contentType } : {}) + } + }), + status: 415, + code: "UNSUPPORTED_MEDIA_TYPE" + }); + }); +} + +for (const origin of [ + undefined, + "null", + "https://attacker.example", + `${trustedContactOrigin}/path` +]) { + test(`Contact Us PET notification rejects origin ${String(origin)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + headers: { + ...(origin ? { origin } : {}), + "content-type": "application/json" + } + }), + status: 403, + code: "FORBIDDEN" + }); + }); +} + +test("Contact Us PET notification accepts each configured exact origin", async () => { + await withContactOrigins(async () => { + for (const origin of [ + trustedContactOrigin, + trustedWelshContactOrigin + ]) { + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ + headers: { origin, "content-type": "application/json" } + }), + 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 PET notification rejects non-plain body ${Object.prototype.toString.call(body)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ body }), + status: 400, + code: "INVALID_REQUEST_BODY" + }); + }); +} + +test("Contact Us PET notification enforces its exact field set", async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { + ...validContactPetRequest().body, + templateId: "caller-controlled" + } + }), + status: 400, + code: "INVALID_REQUEST_FIELDS" + }); +}); + +for (const petEmail of [ + undefined, + "invalid", + `${"a".repeat(190)}@example.com` +]) { + test(`Contact Us PET notification rejects recipient ${String(petEmail).slice(0, 30)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { ...validContactPetRequest().body, petEmail } + }), + status: 400, + code: "INVALID_PET_EMAIL" + }); + }); +} + +for (const typeOfContact of [undefined, "ipq", "other"]) { + test(`Contact Us PET notification rejects category ${String(typeOfContact)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { ...validContactPetRequest().body, typeOfContact } + }), + status: 400, + code: "INVALID_CONTACT_TYPE" + }); + }); +} + +for (const contactbody of [undefined, " ", "a".repeat(801)]) { + test(`Contact Us PET notification rejects message ${String(contactbody).slice(0, 20)}`, async () => { + await assertContactPetRejected({ + req: validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }), + status: 400, + code: "INVALID_CONTACT_BODY" + }); + }); +} + +test("Contact Us PET notification preserves URLs and appends the reminder exactly once", async () => { + await withContactOrigins(async () => { + const contactbody = + "Please review https://example.invalid/application/123?token=abc#details"; + const req = validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }); + const originalBody = JSON.parse(JSON.stringify(req.body)); + const loaded = loadContactPetEndpoint(); + 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], contactPetTemplateId); + assert.strictEqual(loaded.notifyCalls[0][1], "pet@example.com"); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(loaded.notifyCalls[0][2])), + { + personalisation: { + contactEmail: "applicant@example.com", + contactSubject: contactSubjects.plq.en, + contactbody: `${contactbody}\n\n${contactPetSecurityReminder}`, + typeOfContact: "plq" + }, + reference: contactPetReference + } + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody.includes( + "https://example.invalid/application/123?token=abc#details" + ), + true + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody.split( + contactPetSecurityReminder + ).length - 1, + 1 + ); + assert.deepStrictEqual(req.body, originalBody); + }); +}); + +test("Contact Us PET notification does not duplicate an existing exact reminder", async () => { + await withContactOrigins(async () => { + const contactbody = `Original message\n\n${contactPetSecurityReminder}`; + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }), + res + ); + + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody, + contactbody + ); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody.split( + contactPetSecurityReminder + ).length - 1, + 1 + ); + }); +}); + +test("Contact Us PET notification supports an 800-character original message", async () => { + await withContactOrigins(async () => { + const contactbody = "a".repeat(800); + const loaded = loadContactPetEndpoint(); + const res = createRes(); + + await loaded.mod.default( + validContactPetRequest({ + body: { ...validContactPetRequest().body, contactbody } + }), + res + ); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual( + loaded.notifyCalls[0][2].personalisation.contactbody, + `${contactbody}\n\n${contactPetSecurityReminder}` + ); + }); +}); + +test("Contact Us PET notification returns a bounded error when Notify fails", async () => { + await withContactOrigins(async () => { + let sendCalls = 0; + const loaded = loadContactPetEndpoint({ + sendEmail: async () => { + sendCalls += 1; + throw new Error("notify failed"); + } + }); + const res = createRes(); + + await loaded.mod.default(validContactPetRequest(), res); + + assert.strictEqual(sendCalls, 1); + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "CONTACT_US_PET_NOTIFICATION_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 270292a1..d27cb60a 100644 --- a/tests/phase7/service-behaviour.test.cjs +++ b/tests/phase7/service-behaviour.test.cjs @@ -1,4 +1,6 @@ const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); const { createAxiosMock, createLoggerMock, @@ -460,6 +462,109 @@ test("notify/sendContactUsConfirmation logs and rethrows on failure", async () = assert.strictEqual(logger.calls.length, 1); }); +test("notify/sendContactUsPETNotification posts only the dedicated PET 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.sendContactUsPETNotification( + formValues, + "pet@example.com" + ); + + assert.deepStrictEqual(normalize(result), { sent: true }); + assert.strictEqual( + axios.calls[0].config.url, + "/api/email/contactus-pet-notification" + ); + assert.strictEqual(axios.calls[0].config.method, "post"); + assert.deepStrictEqual(normalize(axios.calls[0].config.data), { + petEmail: "pet@example.com", + contactEmail: "person@example.com", + contactSubject: "Questions about Planning legislation or policy", + contactbody: "Please review https://example.invalid", + typeOfContact: "plq" + }); +}); + +test("notify/sendContactUsPETNotification 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.sendContactUsPETNotification( + { + contactEmail: "person@example.com", + contactSubject: "Questions about Planning legislation or policy", + contactbody: "Message", + typeOfContact: "plq" + }, + "pet@example.com" + ), + (thrown) => thrown === error + ); + assert.strictEqual(logger.calls.length, 1); +}); + +test("public and portal Contact Us pages preserve PET gating and submission ordering", async () => { + const rootDir = path.resolve(__dirname, "..", ".."); + + for (const relativePath of ["pages/contactus.js", "pages/myportal/contactus.js"]) { + const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8"); + const confirmationCall = source.indexOf("sendContactEmail(formValues);"); + const crmCall = source.indexOf("createCRMTask(formValues);"); + const petCondition = source.indexOf( + 'formValues.typeOfContact == "plq" &&' + ); + const petCall = source.indexOf( + "sendPETEmail(formValues, props.petEmail);" + ); + + assert.notStrictEqual(confirmationCall, -1); + assert.notStrictEqual(crmCall, -1); + assert.notStrictEqual(petCondition, -1); + assert.notStrictEqual(petCall, -1); + assert.strictEqual( + confirmationCall < crmCall && + crmCall < petCondition && + petCondition < petCall, + true + ); + assert.strictEqual( + source.includes( + "sendContactUsPETNotification(formValues, petEmail)" + ), + true + ); + assert.strictEqual( + source.includes("sendContactUsConfirmation("), + true + ); + assert.strictEqual(source.includes("sendEmail(templateId,"), false); + } +}); + test("integration/createCRMTask posts payload and returns data", async () => { const axios = createAxiosMock(); const logger = createLoggerMock();