fix(security): harden Contact Us PET notifications
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
-12
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user