diff --git a/actions/azurestorage.js b/actions/azurestorage.js
index c4fe4db9..cc10eed4 100644
--- a/actions/azurestorage.js
+++ b/actions/azurestorage.js
@@ -525,6 +525,31 @@ export const deleteBlob = async (containerName, blobName) => {
return { "deleted": blobName };
};
+const isSafeDeletionPathSegment = (value) =>
+ typeof value === "string" &&
+ value.length > 0 &&
+ value.length <= 255 &&
+ value === value.trim() &&
+ value !== "." &&
+ value !== ".." &&
+ !/[\\/\u0000-\u001f\u007f]/.test(value);
+
+const buildDeletionFolderPrefix = (resourcePath, expectedSegmentCount) => {
+ if (typeof resourcePath !== "string") {
+ throw new Error("Invalid deletion resource path");
+ }
+
+ const segments = resourcePath.split("/");
+ if (
+ segments.length !== expectedSegmentCount ||
+ !segments.every(isSafeDeletionPathSegment)
+ ) {
+ throw new Error("Invalid deletion resource path");
+ }
+
+ return segments.join("/") + "/";
+};
+
export const deleteBlobCase = async (containerName, blobName) => {
const creds = new DefaultAzureCredential();
@@ -538,13 +563,15 @@ export const deleteBlobCase = async (containerName, blobName) => {
//conLog"deleteBlobCase ");
- console.log("blob to delete:", blobName);
+ const folderPrefix = buildDeletionFolderPrefix(blobName, 1);
+
+ console.log("blob to delete:", folderPrefix);
for await (const blob of containerClient.listBlobsFlat({
- prefix: blobName
+ prefix: folderPrefix
})) {
console.log(" ------ :", blob.name);
- containerClient.deleteBlob(blob.name);
+ await containerClient.deleteBlob(blob.name);
}
//containerClient.deleteBlob(blobName);
@@ -566,17 +593,19 @@ export const deleteBlobRep = async (containerName, blobName) => {
const sasUrl = `${STORAGE_PATH}/${containerName}?${containerToken}`;
const containerClient = new ContainerClient(sasUrl);
- console.log("blob to delete:", blobName);
+ const folderPrefix = buildDeletionFolderPrefix(blobName, 2);
+
+ console.log("blob to delete:", folderPrefix);
for await (const blob of containerClient.listBlobsFlat({
- prefix: blobName
+ prefix: folderPrefix
})) {
console.log("------ :", blob.name);
const blockBlobClient = containerClient.getBlockBlobClient(blob.name);
console.log("=======:", await blockBlobClient.exists());
(await blockBlobClient.exists()) &&
- containerClient.deleteBlob(blob.name);
+ (await containerClient.deleteBlob(blob.name));
//containerClient.deleteIfExists();
//containerClient.deleteBlob(blob.name);
diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js
index 77ae6479..db6dd0b4 100644
--- a/actions/services/documentDirectService.js
+++ b/actions/services/documentDirectService.js
@@ -118,7 +118,7 @@ export const deleteAwaitingSubmissionsFromBlob = (
casefolderID
});
- return getSignedFileJson(queryUrl).catch((error) => {
+ return postSignedFileJson(queryUrl).catch((error) => {
consoleLogger(error);
});
};
@@ -134,7 +134,7 @@ export const deleteMyRepresentationsFromBlob = (
repfile
});
- return getSignedFileJson(queryUrl).catch((error) => {
+ return postSignedFileJson(queryUrl).catch((error) => {
consoleLogger(error);
});
};
diff --git a/actions/services/notifyDirectService.js b/actions/services/notifyDirectService.js
index b36b64de..8f1f70ea 100644
--- a/actions/services/notifyDirectService.js
+++ b/actions/services/notifyDirectService.js
@@ -25,3 +25,44 @@ 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;
+ }
+};
+
+export const sendContactUsPETNotification = async (formValues) => {
+ const mailData = {
+ "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/components/myportal/awaitingsubmissionfromblob.js b/components/myportal/awaitingsubmissionfromblob.js
index ce22c602..93f8b5e2 100644
--- a/components/myportal/awaitingsubmissionfromblob.js
+++ b/components/myportal/awaitingsubmissionfromblob.js
@@ -70,7 +70,7 @@ const AwaitingSubmissionFromBlob = (props) => {
const deleteCaseItem = (containerID, caseID) => {
let cookies = parseCookies();
- console.log("sssss", containerID, caseID);
+ //console.log("sssss", containerID, caseID);
deleteAwaitingSubmissionsFromBlob(containerID, caseID)
.then((data) => {
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/middleware.js b/middleware.js
index 185b9205..77e18ca9 100644
--- a/middleware.js
+++ b/middleware.js
@@ -63,7 +63,7 @@ export function middleware(request) {
);
// XSS protection (legacy)
- response.headers.set("X-XSS-Protection", "1; mode=block");
+ response.headers.set("X-XSS-Protection", "0");
// Prevent MIME type sniffing
response.headers.set("X-Content-Type-Options", "nosniff");
diff --git a/next.config.js b/next.config.js
index 2d7ee446..90d209ea 100644
--- a/next.config.js
+++ b/next.config.js
@@ -46,13 +46,19 @@ const securityHeaders = [
},
{
key: "X-XSS-Protection",
- value: "1; mode=block"
+ value: "0"
}
// {
// key: "Content-Security-Policy",
// value: "frame-src 'self'; child-src 'self';object-src 'none'; script-src 'self' 'https://www.googletagmanager.com' unsafe-inline",
// },
];
+const noStoreHeaders = [
+ {
+ key: "Cache-Control",
+ value: "no-store"
+ }
+];
//const buildId = nextBuildId.sync({ dir: __dirname });
const buildId = Date.now().toString();
@@ -77,6 +83,30 @@ module.exports = {
// Apply these headers to all routes in your application.
source: "/(.*)?",
headers: securityHeaders
+ },
+ {
+ source:
+ "/api/:family(admin|email|file|myportal|subscriptions)/:path*",
+ headers: noStoreHeaders
+ },
+ {
+ source: "/api/auth/resolve-locale",
+ headers: noStoreHeaders
+ },
+ {
+ source:
+ "/api/endpoint/:route(createaccount_api|getaccounts_api|getemailaccountcheck_api|getpersonalaccount_api|getportallogin_api|getportalloginproxy_api|getpreferredlanguage_api|updateaccount_api|gethash_api|createcrmtask_api)",
+ headers: noStoreHeaders
+ },
+ {
+ source:
+ "/api/endpoint/:route(getmycases_api|getmylpacases_api|getmyrepresentations_api|getmyrepresentationsproxy_api|getawaitingsubmission_api|getawaitingsubmissionproxy_api|getwatchedcases_api|getwatchedcasesproxy_api|createwatchedcases_api|deletewatchedcases_api|deletewatchedcasesproxy_api|deleteawaitingsubmissions_api)",
+ headers: noStoreHeaders
+ },
+ {
+ source:
+ "/api/endpoint/:route(getbasicpartsaveddetails_api|getpartsavedappeal_api|getappealid_api|getappealpdfdocuments_api|getportalmoduledetails_api|getportalmoduledetailsproxy_api|patchcase_api|updatecase_api)",
+ headers: noStoreHeaders
}
];
},
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/api/email/contactus-pet-notification.js b/pages/api/email/contactus-pet-notification.js
new file mode 100644
index 00000000..79b765d1
--- /dev/null
+++ b/pages/api/email/contactus-pet-notification.js
@@ -0,0 +1,212 @@
+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([
+ "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(process.env.PETEMAIL);
+ const contactEmail = sanitizeString(requestBody.contactEmail);
+ const contactSubject = sanitizeString(requestBody.contactSubject);
+ const contactBody = requestBody.contactbody;
+
+ if (
+ typeof process.env.PETEMAIL !== "string" ||
+ !isNonEmptyString(petEmail) ||
+ petEmail.length > 200 ||
+ !emailRegex.test(petEmail)
+ ) {
+ return respondError(res, {
+ status: 500,
+ code: "PET_RECIPIENT_NOT_CONFIGURED",
+ message: "PET notification recipient is not configured"
+ });
+ }
+
+ 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/api/endpoint/createcrmtask_api.js b/pages/api/endpoint/createcrmtask_api.js
index 964a3247..edf98f33 100644
--- a/pages/api/endpoint/createcrmtask_api.js
+++ b/pages/api/endpoint/createcrmtask_api.js
@@ -9,44 +9,219 @@ const WEBAPI_URL =
process.env.RELAY_ROOT ||
"https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/";
+const SECURITY_REMINDER =
+ "--- SECURITY REMINDER ---\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 teamMap = {
+ plq: "ec48450c-7e26-ec11-a97f-00224800e98c", //PET
+ ipq: "7fb9e598-7d26-ec11-a97f-00224800e98c", // Infrastructure
+ pcpq: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework
+ pi: "5554882f-4606-ec11-a97f-00224800d374", // Digital Support Team
+ appadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c",
+ repadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c",
+ ceq: "4fddd7e8-7d26-ec11-a97f-00224800e98c", // Inspector Scheduling
+ other: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework
+ postdec: "1357e457-7e26-ec11-a97f-00224800e98c", // SLT Team
+ comp: "1357e457-7e26-ec11-a97f-00224800e98c" // SLT Team
+};
+
+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"
+]);
+
+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]:.+)])$/;
+
+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 });
+
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 createTaskBody = req.body;
+ if (
+ createTaskBody === null ||
+ typeof createTaskBody !== "object" ||
+ Array.isArray(createTaskBody)
+ ) {
+ return validationError(
+ res,
+ "INVALID_REQUEST_BODY",
+ "Request body must be a JSON object"
+ );
+ }
+
+ if (Object.keys(createTaskBody).some((field) => !allowedFields.has(field))) {
+ return validationError(
+ res,
+ "UNSUPPORTED_REQUEST_FIELD",
+ "Request contains an unsupported field"
+ );
+ }
+
const contactEmail = sanitizeString(createTaskBody?.contactEmail);
const contactSubject = sanitizeString(createTaskBody?.contactSubject);
const contactBody = sanitizeString(createTaskBody?.contactbody);
- if (!isNonEmptyString(contactEmail) || !isNonEmptyString(contactSubject)) {
- return respondError(res, {
- status: 400,
- code: "CONTACT_EMAIL_AND_SUBJECT_REQUIRED",
- message: "contactEmail and contactSubject are required"
- });
+ if (
+ typeof createTaskBody.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 createTaskBody.typeOfContact !== "string" ||
+ !Object.prototype.hasOwnProperty.call(teamMap, createTaskBody.typeOfContact)
+ ) {
+ return validationError(
+ res,
+ "INVALID_CONTACT_TYPE",
+ "typeOfContact is not supported"
+ );
+ }
+
+ if (
+ typeof createTaskBody.contactbody !== "string" ||
+ !isNonEmptyString(contactBody) ||
+ contactBody.length > 800
+ ) {
+ return validationError(
+ res,
+ "INVALID_CONTACT_BODY",
+ "contactbody must contain between 1 and 800 characters"
+ );
+ }
+
+ const contactValue = createTaskBody.typeOfContact;
+ if (
+ typeof createTaskBody.contactSubject !== "string" ||
+ !allowedSubjects[contactValue].includes(contactSubject)
+ ) {
+ return validationError(
+ res,
+ "INVALID_CONTACT_SUBJECT",
+ "contactSubject does not match typeOfContact"
+ );
}
const queryUrl = "tasks";
const token = await getToken();
- const contactValue = createTaskBody.typeOfContact;
-
- const teamMap = {
- plq: "ec48450c-7e26-ec11-a97f-00224800e98c", //PET
- ipq: "7fb9e598-7d26-ec11-a97f-00224800e98c", // Infrastructure
- pcpq: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework
- pi: "5554882f-4606-ec11-a97f-00224800d374", // Digital Support Team
- appadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c",
- repadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c",
- ceq: "4fddd7e8-7d26-ec11-a97f-00224800e98c", // Inspector Scheduling
- other: "8ffabad9-7c26-ec11-a97f-00224800e98c", // Casework
- postdec: "1357e457-7e26-ec11-a97f-00224800e98c", // SLT Team
- comp: "1357e457-7e26-ec11-a97f-00224800e98c" // SLT Team
- };
-
let teamId = teamMap[contactValue];
const payload = {
"subject": `Contact Us Enquiry - ${contactSubject}`,
- "description": `From: ${contactEmail}\n\n${contactBody}`,
+ "description": `From: ${contactEmail}\n\n${contactBody}\n\n${SECURITY_REMINDER}`,
"scheduledstart": new Date().toISOString(),
"scheduledend": new Date(
new Date().getTime() + 60 * 60000 * 24
diff --git a/pages/api/file/deleteblobcase.js b/pages/api/file/deleteblobcase.js
index 002068a4..e79cdb5a 100644
--- a/pages/api/file/deleteblobcase.js
+++ b/pages/api/file/deleteblobcase.js
@@ -5,10 +5,17 @@ import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import nextConnect from "next-connect";
-import middleware from "../middleware/middleware";
-const ApiProxy = nextConnect();
-ApiProxy.use(middleware);
+const methodNotAllowed = (req, res) => {
+ res.setHeader("Allow", "POST");
+ return respondError(res, {
+ status: 405,
+ code: "METHOD_NOT_ALLOWED",
+ message: "Method not allowed"
+ });
+};
+
+const ApiProxy = nextConnect({ onNoMatch: methodNotAllowed });
const forbidden = (res) =>
respondError(res, {
@@ -17,7 +24,53 @@ const forbidden = (res) =>
message: "Request not permitted"
});
-ApiProxy.get(async (req, res) => {
+const isValidPathSegment = (value) =>
+ typeof value === "string" &&
+ value.length > 0 &&
+ value.length <= 255 &&
+ value === value.trim() &&
+ value !== "." &&
+ value !== ".." &&
+ !/[\\/\u0000-\u001f\u007f]/.test(value);
+
+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 normalizedRequestOrigin = requestUrl.origin;
+ 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(normalizedRequestOrigin);
+ } catch (_error) {
+ return false;
+ }
+};
+
+ApiProxy.post(async (req, res) => {
+ if (req.method !== "POST") {
+ return methodNotAllowed(req, res);
+ }
+
+ if (!hasTrustedOrigin(req)) {
+ return forbidden(res);
+ }
+
const containerName = req.query.container;
const casefolderID = req.query.casefolderID;
const checkHash = req.query.hash;
@@ -39,6 +92,14 @@ ApiProxy.get(async (req, res) => {
const casefolderIDTrimmed = casefolderID.trim();
+ if (!isValidPathSegment(casefolderIDTrimmed)) {
+ return respondError(res, {
+ status: 400,
+ code: "INVALID_REQUEST",
+ message: "Invalid request"
+ });
+ }
+
const session = await getServerSession(req, res, authOptions(req, res));
if (
typeof session?.user?.id !== "string" ||
diff --git a/pages/api/file/deleteblobrep.js b/pages/api/file/deleteblobrep.js
index d6c7f3f0..0ad7c394 100644
--- a/pages/api/file/deleteblobrep.js
+++ b/pages/api/file/deleteblobrep.js
@@ -5,10 +5,17 @@ import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import nextConnect from "next-connect";
-import middleware from "../middleware/middleware";
-const ApiProxy = nextConnect();
-ApiProxy.use(middleware);
+const methodNotAllowed = (req, res) => {
+ res.setHeader("Allow", "POST");
+ return respondError(res, {
+ status: 405,
+ code: "METHOD_NOT_ALLOWED",
+ message: "Method not allowed"
+ });
+};
+
+const ApiProxy = nextConnect({ onNoMatch: methodNotAllowed });
const forbidden = (res) =>
respondError(res, {
@@ -17,7 +24,53 @@ const forbidden = (res) =>
message: "Request not permitted"
});
-ApiProxy.get(async (req, res) => {
+const isValidPathSegment = (value) =>
+ typeof value === "string" &&
+ value.length > 0 &&
+ value.length <= 255 &&
+ value === value.trim() &&
+ value !== "." &&
+ value !== ".." &&
+ !/[\\/\u0000-\u001f\u007f]/.test(value);
+
+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 normalizedRequestOrigin = requestUrl.origin;
+ 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(normalizedRequestOrigin);
+ } catch (_error) {
+ return false;
+ }
+};
+
+ApiProxy.post(async (req, res) => {
+ if (req.method !== "POST") {
+ return methodNotAllowed(req, res);
+ }
+
+ if (!hasTrustedOrigin(req)) {
+ return forbidden(res);
+ }
+
const containerName = req.query.container;
const casefolderID = req.query.casefolderID;
const repfile = req.query.repfile;
@@ -43,6 +96,17 @@ ApiProxy.get(async (req, res) => {
const casefolderIDTrimmed = casefolderID.trim();
const repfileTrimmed = repfile.trim();
+ if (
+ !isValidPathSegment(casefolderIDTrimmed) ||
+ !isValidPathSegment(repfileTrimmed)
+ ) {
+ return respondError(res, {
+ status: 400,
+ code: "INVALID_REQUEST",
+ message: "Invalid request"
+ });
+ }
+
const session = await getServerSession(req, res, authOptions(req, res));
if (
typeof session?.user?.id !== "string" ||
diff --git a/pages/contactus.js b/pages/contactus.js
index e877ceb9..7c91a7ab 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 { wrapper } from "../store/store";
+import {
+ sendContactUsConfirmation,
+ sendContactUsPETNotification
+} from "../actions/services/notifyDirectService";
import { useState } from "react";
@@ -319,7 +321,7 @@ let ContactUs = (props) => {
sendContactEmail(formValues);
createCRMTask(formValues);
formValues.typeOfContact == "plq" &&
- sendPETEmail(formValues, props.petEmail);
+ sendPETEmail(formValues);
console.log("Form submitted:", formValues);
};
@@ -329,16 +331,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.");
@@ -353,20 +349,9 @@ 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);
-
+ const sendPETEmail = async (formValues) => {
try {
- const res = await sendEmail(
- templateId,
- petEmail,
- formValues,
- reference
- );
+ const res = await sendContactUsPETNotification(formValues);
console.log("Email sent successfully.");
setFormSubmitted(true);
@@ -531,17 +516,6 @@ const mapStateToProps = (state) => {
};
};
-export const getServerSideProps = wrapper.getServerSideProps(
- (store) => async (ctx) => {
- const { req, locale } = ctx;
- return {
- props: {
- petEmail: process.env.PETEMAIL
- }
- };
- }
-);
-
export default connect(
mapStateToProps,
mapDispatchToProps
diff --git a/pages/myportal/contactus.js b/pages/myportal/contactus.js
index 05ea94b7..4334fc25 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 { wrapper } from "../../store/store";
+import {
+ sendContactUsConfirmation,
+ sendContactUsPETNotification
+} from "../../actions/services/notifyDirectService";
import { useState } from "react";
@@ -319,7 +321,7 @@ let ContactUs = (props) => {
sendContactEmail(formValues);
createCRMTask(formValues);
formValues.typeOfContact == "plq" &&
- sendPETEmail(formValues, props.petEmail);
+ sendPETEmail(formValues);
console.log("Form submitted:", formValues);
};
@@ -329,16 +331,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.");
@@ -353,20 +349,9 @@ 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);
-
+ const sendPETEmail = async (formValues) => {
try {
- const res = await sendEmail(
- templateId,
- petEmail,
- formValues,
- reference
- );
+ const res = await sendContactUsPETNotification(formValues);
console.log("Email sent successfully.");
setFormSubmitted(true);
@@ -532,17 +517,6 @@ const mapStateToProps = (state) => {
};
};
-export const getServerSideProps = wrapper.getServerSideProps(
- (store) => async (ctx) => {
- const { req, locale } = ctx;
- return {
- props: {
- petEmail: process.env.PETEMAIL
- }
- };
- }
-);
-
export default connect(
mapStateToProps,
mapDispatchToProps
diff --git a/tests/phase21/email-handler-contract.test.cjs b/tests/phase21/email-handler-contract.test.cjs
index c648325c..da6d4537 100644
--- a/tests/phase21/email-handler-contract.test.cjs
+++ b/tests/phase21/email-handler-contract.test.cjs
@@ -337,6 +337,823 @@ 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;
+ const previousPetEmail = process.env.PETEMAIL;
+ process.env.NEXTAUTH_URL = trustedContactOrigin;
+ process.env.CY_API_ROOT = trustedWelshContactOrigin;
+ process.env.PETEMAIL = "pet@example.com";
+
+ 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;
+
+ if (previousPetEmail === undefined) delete process.env.PETEMAIL;
+ else process.env.PETEMAIL = previousPetEmail;
+ }
+};
+
+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 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: {
+ 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"
+ });
+});
+
+test("Contact Us PET notification rejects obsolete caller-supplied recipient", async () => {
+ await assertContactPetRejected({
+ req: validContactPetRequest({
+ body: {
+ ...validContactPetRequest().body,
+ petEmail: "pet@example.com"
+ }
+ }),
+ status: 400,
+ code: "INVALID_REQUEST_FIELDS"
+ });
+});
+
+for (const configuredRecipient of [undefined, "invalid"]) {
+ test(`Contact Us PET notification fails safely for configured recipient ${String(configuredRecipient)}`, async () => {
+ await withContactOrigins(async () => {
+ if (configuredRecipient === undefined) delete process.env.PETEMAIL;
+ else process.env.PETEMAIL = configuredRecipient;
+
+ const loaded = loadContactPetEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(validContactPetRequest(), res);
+
+ assert.strictEqual(res.state.statusCode, 500);
+ assert.strictEqual(
+ res.state.jsonBody.error.code,
+ "PET_RECIPIENT_NOT_CONFIGURED"
+ );
+ assert.strictEqual(loaded.notifyCalls.length, 0);
+ assert.strictEqual(
+ JSON.stringify(res.state.jsonBody).includes(
+ String(configuredRecipient)
+ ),
+ false
+ );
+ });
+ });
+}
+
+test("Contact Us PET notification rejects caller recipient before configured recipient validation", async () => {
+ await withContactOrigins(async () => {
+ delete process.env.PETEMAIL;
+ const loaded = loadContactPetEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(
+ validContactPetRequest({
+ body: {
+ ...validContactPetRequest().body,
+ petEmail: "attacker@example.com"
+ }
+ }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assert.strictEqual(
+ res.state.jsonBody.error.code,
+ "INVALID_REQUEST_FIELDS"
+ );
+ assert.strictEqual(loaded.notifyCalls.length, 0);
+ });
+});
+
+for (const contactEmail of [
+ undefined,
+ "invalid",
+ `${"a".repeat(190)}@example.com`
+]) {
+ test(`Contact Us PET notification rejects contact email ${String(contactEmail).slice(0, 30)}`, async () => {
+ await assertContactPetRejected({
+ req: validContactPetRequest({
+ body: { ...validContactPetRequest().body, contactEmail }
+ }),
+ status: 400,
+ code: "INVALID_CONTACT_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/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs
index 45078ec2..647747ab 100644
--- a/tests/phase21/endpoint-handler-contract.test.cjs
+++ b/tests/phase21/endpoint-handler-contract.test.cjs
@@ -4866,56 +4866,486 @@ test("patchcase catch path returns CASE_PATCH_FAILED", async () => {
assert.strictEqual(res.state.jsonBody.error.code, "CASE_PATCH_FAILED");
});
-test("createcrmtask returns CONTACT_EMAIL_AND_SUBJECT_REQUIRED when required fields missing", async () => {
+const trustedContactOrigin = "https://portal.example.test";
+const trustedWelshContactOrigin = "https://porth.example.test";
+const contactTaskSecurityReminder =
+ "--- SECURITY REMINDER ---\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 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 contactTeamIds = {
+ plq: "ec48450c-7e26-ec11-a97f-00224800e98c",
+ ipq: "7fb9e598-7d26-ec11-a97f-00224800e98c",
+ pcpq: "8ffabad9-7c26-ec11-a97f-00224800e98c",
+ pi: "5554882f-4606-ec11-a97f-00224800d374",
+ appadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c",
+ repadvice: "cc3d987e-7c26-ec11-a97f-00224800e98c",
+ ceq: "4fddd7e8-7d26-ec11-a97f-00224800e98c",
+ other: "8ffabad9-7c26-ec11-a97f-00224800e98c",
+ postdec: "1357e457-7e26-ec11-a97f-00224800e98c",
+ comp: "1357e457-7e26-ec11-a97f-00224800e98c"
+};
+
+const validContactTaskRequest = (overrides = {}) => ({
+ method: "POST",
+ headers: {
+ origin: trustedContactOrigin,
+ "content-type": "application/json; charset=utf-8"
+ },
+ body: {
+ contactEmail: "user@example.com",
+ contactSubject: contactSubjects.plq.en,
+ contactbody: "Please help with my enquiry.",
+ typeOfContact: "plq"
+ },
+ ...overrides
+});
+
+const loadContactTaskEndpoint = (overrides = {}) => {
+ let tokenCalls = 0;
+ const relayCalls = [];
+ const axios =
+ overrides.axios ||
+ (async (config) => {
+ relayCalls.push(config);
+ return { data: { taskId: "crm-1" } };
+ });
+
const mod = loadModule("pages/api/endpoint/createcrmtask_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
- isNonEmptyString: () => false,
- sanitizeString: (v) => v,
- getToken: async () => ({ access_token: "token" }),
- hashAPIPath: () => "&hash=expected",
- axios: async () => ({ data: {} }),
- consoleLogger: () => {}
+ isNonEmptyString: (value) =>
+ typeof value === "string" && value.trim().length > 0,
+ sanitizeString: (value) =>
+ typeof value === "string" ? value.trim() : "",
+ getToken: async () => {
+ tokenCalls += 1;
+ return { access_token: "token" };
+ },
+ hashAPIPath: () => "?hash=expected",
+ axios,
+ consoleLogger: () => {},
+ process: {
+ env: {
+ NEXTAUTH_URL: trustedContactOrigin,
+ CY_API_ROOT: trustedWelshContactOrigin,
+ RELAY_ROOT: "https://relay.example/"
+ }
+ },
+ ...overrides
});
- const req = { body: {} };
- const res = createRes();
- await mod.default(req, res);
+ return {
+ mod,
+ getTokenCalls: () => tokenCalls,
+ getRelayCalls: () => relayCalls
+ };
+};
- assert.strictEqual(res.state.statusCode, 400);
+const assertContactTaskRejectedBeforeRelay = async ({ req, status, code }) => {
+ const loaded = loadContactTaskEndpoint();
+ 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.getTokenCalls(), 0);
+ assert.strictEqual(loaded.getRelayCalls().length, 0);
+};
+
+test("createcrmtask accepts a valid anonymous public Contact Us POST", async () => {
+ const loaded = loadContactTaskEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(validContactTaskRequest(), res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.getTokenCalls(), 1);
+ assert.strictEqual(loaded.getRelayCalls().length, 1);
+});
+
+test("createcrmtask appends the security reminder after the unchanged enquiry", async () => {
+ const contactbody = "Please review application reference APP/123456.";
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail: "applicant@example.com",
+ contactSubject: contactSubjects.plq.en,
+ contactbody,
+ typeOfContact: "plq"
+ }
+ });
+ const loaded = loadContactTaskEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ const payload = JSON.parse(loaded.getRelayCalls()[0].data);
+ const expectedDescription =
+ "From: applicant@example.com\n\n" +
+ `${contactbody}\n\n` +
+ contactTaskSecurityReminder;
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(payload.description, expectedDescription);
assert.strictEqual(
- res.state.jsonBody.error.code,
- "CONTACT_EMAIL_AND_SUBJECT_REQUIRED"
+ payload.description.startsWith(`From: applicant@example.com\n\n${contactbody}`),
+ true
);
+ assert.strictEqual(
+ payload.subject,
+ `Contact Us Enquiry - ${contactSubjects.plq.en}`
+ );
+ assert.strictEqual(
+ payload["ownerid@odata.bind"],
+ `/teams(${contactTeamIds.plq})`
+ );
+ assert.strictEqual(req.body.contactbody, contactbody);
+});
+
+test("createcrmtask preserves original URLs before the appended security reminder", async () => {
+ const contactbody =
+ "Please review https://example.invalid/application/123";
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail: "applicant@example.com",
+ contactSubject: contactSubjects.plq.en,
+ contactbody,
+ typeOfContact: "plq"
+ }
+ });
+ const loaded = loadContactTaskEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ const payload = JSON.parse(loaded.getRelayCalls()[0].data);
+ assert.strictEqual(
+ payload.description,
+ `From: applicant@example.com\n\n${contactbody}\n\n${contactTaskSecurityReminder}`
+ );
+ assert.strictEqual(
+ payload.description.includes(
+ "https://example.invalid/application/123"
+ ),
+ true
+ );
+ assert.strictEqual(payload.description.includes("hxxp"), false);
+ assert.strictEqual(payload.description.includes("[.]"), false);
+ assert.strictEqual(req.body.contactbody, contactbody);
+});
+
+test("createcrmtask preserves a full 800-character message and complete reminder", async () => {
+ const contactbody = "a".repeat(800);
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail: "applicant@example.com",
+ contactSubject: contactSubjects.plq.en,
+ contactbody,
+ typeOfContact: "plq"
+ }
+ });
+ const loaded = loadContactTaskEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ const payload = JSON.parse(loaded.getRelayCalls()[0].data);
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(
+ payload.description,
+ `From: applicant@example.com\n\n${contactbody}\n\n${contactTaskSecurityReminder}`
+ );
+ assert.strictEqual(req.body.contactbody, contactbody);
+});
+
+test("createcrmtask accepts a valid portal Contact Us payload", async () => {
+ const loaded = loadContactTaskEndpoint();
+ const req = validContactTaskRequest({
+ headers: {
+ origin: trustedWelshContactOrigin,
+ "content-type": "application/json"
+ },
+ body: {
+ contactEmail: "portal.user@example.com",
+ contactSubject: contactSubjects.repadvice.cy,
+ contactbody: "Mae angen cymorth arnaf.",
+ typeOfContact: "repadvice"
+ }
+ });
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.getTokenCalls(), 1);
+ assert.strictEqual(loaded.getRelayCalls().length, 1);
+});
+
+test("createcrmtask preserves all category-to-team assignments", async () => {
+ for (const [typeOfContact, teamId] of Object.entries(contactTeamIds)) {
+ const loaded = loadContactTaskEndpoint();
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail: "user@example.com",
+ contactSubject: contactSubjects[typeOfContact].en,
+ contactbody: "Please help with my enquiry.",
+ typeOfContact
+ }
+ });
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ const payload = JSON.parse(loaded.getRelayCalls()[0].data);
+ assert.strictEqual(payload["ownerid@odata.bind"], `/teams(${teamId})`);
+ }
+});
+
+for (const method of ["GET", "PUT", "DELETE"]) {
+ test(`createcrmtask rejects ${method} before CRM processing`, async () => {
+ const req = validContactTaskRequest({ method });
+ const loaded = loadContactTaskEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 405);
+ assert.strictEqual(res.state.headers.allow, "POST");
+ assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
+ assert.strictEqual(loaded.getTokenCalls(), 0);
+ assert.strictEqual(loaded.getRelayCalls().length, 0);
+ });
+}
+
+for (const contentType of [
+ "application/x-www-form-urlencoded",
+ "text/plain",
+ "multipart/form-data; boundary=test"
+]) {
+ test(`createcrmtask rejects ${contentType} before CRM processing`, async () => {
+ const req = validContactTaskRequest({
+ headers: { origin: trustedContactOrigin, "content-type": contentType }
+ });
+ await assertContactTaskRejectedBeforeRelay({
+ req,
+ status: 415,
+ code: "UNSUPPORTED_MEDIA_TYPE"
+ });
+ });
+}
+
+test("createcrmtask rejects an untrusted Origin before CRM processing", async () => {
+ const req = validContactTaskRequest({
+ headers: {
+ origin: "https://attacker.example",
+ "content-type": "application/json"
+ }
+ });
+ await assertContactTaskRejectedBeforeRelay({ req, status: 403, code: "FORBIDDEN" });
+});
+
+test("createcrmtask accepts each configured trusted Origin", async () => {
+ for (const origin of [trustedContactOrigin, trustedWelshContactOrigin]) {
+ const loaded = loadContactTaskEndpoint();
+ const req = validContactTaskRequest({
+ headers: { origin, "content-type": "application/json" }
+ });
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.getTokenCalls(), 1);
+ }
+});
+
+test("createcrmtask conservatively rejects a missing Origin", async () => {
+ const req = validContactTaskRequest({ headers: { "content-type": "application/json" } });
+ await assertContactTaskRejectedBeforeRelay({ req, status: 403, code: "FORBIDDEN" });
+});
+
+for (const body of [null, [], "not-an-object"]) {
+ test(`createcrmtask rejects malformed body ${JSON.stringify(body)}`, async () => {
+ const req = validContactTaskRequest({ body });
+ await assertContactTaskRejectedBeforeRelay({
+ req,
+ status: 400,
+ code: "INVALID_REQUEST_BODY"
+ });
+ });
+}
+
+for (const contactEmail of [undefined, "invalid", `${"a".repeat(190)}@example.com`]) {
+ test(`createcrmtask rejects invalid email ${String(contactEmail).slice(0, 30)}`, async () => {
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail,
+ contactSubject: contactSubjects.plq.en,
+ contactbody: "Details",
+ typeOfContact: "plq"
+ }
+ });
+ await assertContactTaskRejectedBeforeRelay({
+ req,
+ status: 400,
+ code: "INVALID_CONTACT_EMAIL"
+ });
+ });
+}
+
+for (const typeOfContact of [undefined, "unsupported"]) {
+ test(`createcrmtask rejects category ${String(typeOfContact)}`, async () => {
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail: "user@example.com",
+ contactSubject: contactSubjects.plq.en,
+ contactbody: "Details",
+ typeOfContact
+ }
+ });
+ await assertContactTaskRejectedBeforeRelay({
+ req,
+ status: 400,
+ code: "INVALID_CONTACT_TYPE"
+ });
+ });
+}
+
+for (const contactbody of [undefined, " ", "a".repeat(801)]) {
+ test(`createcrmtask rejects invalid message ${String(contactbody).slice(0, 20)}`, async () => {
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail: "user@example.com",
+ contactSubject: contactSubjects.plq.en,
+ contactbody,
+ typeOfContact: "plq"
+ }
+ });
+ await assertContactTaskRejectedBeforeRelay({
+ req,
+ status: 400,
+ code: "INVALID_CONTACT_BODY"
+ });
+ });
+}
+
+test("createcrmtask rejects caller-supplied CRM ownership fields", async () => {
+ for (const field of ["ownerid", "ownerid@odata.bind", "teamId"]) {
+ const req = validContactTaskRequest({
+ body: { ...validContactTaskRequest().body, [field]: "attacker-owner" }
+ });
+ await assertContactTaskRejectedBeforeRelay({
+ req,
+ status: 400,
+ code: "UNSUPPORTED_REQUEST_FIELD"
+ });
+ }
+});
+
+test("createcrmtask accepts existing English and Welsh subjects for every category", async () => {
+ for (const [typeOfContact, subjects] of Object.entries(contactSubjects)) {
+ for (const contactSubject of [subjects.en, subjects.cy]) {
+ const loaded = loadContactTaskEndpoint();
+ const req = validContactTaskRequest({
+ body: {
+ contactEmail: "user@example.com",
+ contactSubject,
+ contactbody: "Details",
+ typeOfContact
+ }
+ });
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ const payload = JSON.parse(loaded.getRelayCalls()[0].data);
+ assert.strictEqual(payload.subject, `Contact Us Enquiry - ${contactSubject}`);
+ }
+ }
+});
+
+test("createcrmtask rejects an arbitrary or mismatched subject before CRM processing", async () => {
+ for (const contactSubject of ["Arbitrary subject", contactSubjects.ipq.en]) {
+ const req = validContactTaskRequest({
+ body: { ...validContactTaskRequest().body, contactSubject }
+ });
+ await assertContactTaskRejectedBeforeRelay({
+ req,
+ status: 400,
+ code: "INVALID_CONTACT_SUBJECT"
+ });
+ }
});
test("createcrmtask catch path returns CRM_TASK_CREATE_FAILED", async () => {
+ let tokenCalls = 0;
const mod = loadModule("pages/api/endpoint/createcrmtask_api.js", {
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
- isNonEmptyString: () => true,
- sanitizeString: (v) => v,
- getToken: async () => ({ access_token: "token" }),
- hashAPIPath: () => "&hash=expected",
+ isNonEmptyString: (value) =>
+ typeof value === "string" && value.trim().length > 0,
+ sanitizeString: (value) =>
+ typeof value === "string" ? value.trim() : "",
+ getToken: async () => {
+ tokenCalls += 1;
+ return { access_token: "token" };
+ },
+ hashAPIPath: () => "?hash=expected",
axios: async () => {
throw new Error("create failed");
},
- consoleLogger: () => {}
- });
-
- const req = {
- body: {
- contactEmail: "user@test.local",
- contactSubject: "Need help",
- contactbody: "Details",
- typeOfContact: "plq"
+ consoleLogger: () => {},
+ process: {
+ env: {
+ NEXTAUTH_URL: trustedContactOrigin,
+ CY_API_ROOT: trustedWelshContactOrigin,
+ RELAY_ROOT: "https://relay.example/"
+ }
}
- };
+ });
const res = createRes();
- await mod.default(req, res);
+
+ await mod.default(validContactTaskRequest(), res);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "CRM_TASK_CREATE_FAILED");
+ assert.strictEqual(tokenCalls, 1);
});
test("gethash returns UNAUTHENTICATED when session missing", async () => {
diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs
index 53fcc5cc..d93c8ca5 100644
--- a/tests/phase21/file-handler-contract.test.cjs
+++ b/tests/phase21/file-handler-contract.test.cjs
@@ -1,6 +1,7 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
+const vm = require("vm");
const {
loadModule,
createRes,
@@ -12,6 +13,65 @@ const {
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
+const TRUSTED_EN_ORIGIN = "https://portal.example.test";
+const TRUSTED_CY_ORIGIN = "https://porth.example.test";
+const deletionTestProcess = {
+ env: {
+ NEXTAUTH_URL: TRUSTED_EN_ORIGIN,
+ CY_API_ROOT: TRUSTED_CY_ORIGIN
+ }
+};
+
+const validCaseDeletionRequest = (overrides = {}) => ({
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN },
+ query: {
+ container: "c1",
+ casefolderID: "CASE-SUB",
+ hash: "expected"
+ },
+ ...overrides
+});
+
+const validRepDeletionRequest = (overrides = {}) => ({
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN },
+ query: {
+ container: "c1",
+ casefolderID: "CASE-1",
+ repfile: "representation.json",
+ hash: "expected"
+ },
+ ...overrides
+});
+
+const loadDocumentDirectService = (injected = {}) => {
+ const sourcePath = path.resolve(
+ __dirname,
+ "..",
+ "..",
+ "actions/services/documentDirectService.js"
+ );
+ let source = fs.readFileSync(sourcePath, "utf8");
+
+ source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, "");
+ source = source.replace(/export const\s+/g, "const ");
+ source += `
+module.exports = {
+ deleteAwaitingSubmissionsFromBlob,
+ deleteMyRepresentationsFromBlob
+};`;
+
+ const context = {
+ module: { exports: {} },
+ exports: {},
+ ...injected
+ };
+
+ vm.runInNewContext(source, context, { filename: sourcePath });
+ return context.module.exports;
+};
+
test("upload handler returns HASH_REQUIRED for missing hash", async () => {
const mod = loadModule("pages/api/file/upload.js", {
nextConnect: createNextConnectMock(),
@@ -338,7 +398,7 @@ test("deleteblob handler dependency failure returns DELETE_BLOB_FAILED", async (
assert.strictEqual(res.state.jsonBody.error.code, "DELETE_BLOB_FAILED");
});
-test("deleteblobcase handler accepts encoded hash variant", async () => {
+test("deleteblobcase handler accepts a valid case folder", async () => {
let deletedCaseFolder = null;
const mod = loadModule("pages/api/file/deleteblobcase.js", {
nextConnect: createNextConnectMock(),
@@ -346,29 +406,61 @@ test("deleteblobcase handler accepts encoded hash variant", async () => {
getServerSession: async () => ({ user: { id: "c1" } }),
authOptions: () => ({}),
hashAPIPath: (queryPath) =>
- queryPath.includes("casefolderID=CASE%2FSUB")
+ queryPath.includes("casefolderID=CASE-SUB")
? "&hash=expected"
: "&hash=other",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
deleteBlobCase: async (_container, casefolder) => {
deletedCaseFolder = casefolder;
return { deleted: true };
}
});
- const req = {
- query: {
- container: "c1",
- casefolderID: "CASE/SUB",
- hash: "expected"
- }
- };
+ const req = validCaseDeletionRequest();
const res = createRes();
await mod.default.handler(req, res);
assert.strictEqual(res.state.statusCode, 200);
- assert.strictEqual(deletedCaseFolder, "CASE/SUB");
+ assert.strictEqual(deletedCaseFolder, "CASE-SUB");
+});
+
+test("deleteblobcase rejects unsafe normalized folder identifiers before deletion", async () => {
+ for (const casefolderID of [" ", "CASE-1/REP-1", "..", "CASE\\REP"]) {
+ let deletionCalls = 0;
+ const mod = loadModule("pages/api/file/deleteblobcase.js", {
+ nextConnect: createNextConnectMock(),
+ middleware: () => {},
+ getServerSession: async () => ({ user: { id: "c1" } }),
+ authOptions: () => ({}),
+ hashAPIPath: () => "&hash=expected",
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
+ deleteBlobCase: async () => {
+ deletionCalls += 1;
+ }
+ });
+ const res = createRes();
+
+ await mod.default.handler(
+ {
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN },
+ query: {
+ container: "c1",
+ casefolderID,
+ hash: "expected"
+ }
+ },
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
+ assert.strictEqual(deletionCalls, 0);
+ }
});
test("deleteblobcase handler dependency failure returns DELETE_BLOB_CASE_FAILED", async () => {
@@ -380,12 +472,15 @@ test("deleteblobcase handler dependency failure returns DELETE_BLOB_CASE_FAILED"
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
deleteBlobCase: async () => {
throw new Error("delete case failed");
}
});
const req = {
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN },
query: {
container: "c1",
casefolderID: "f1",
@@ -402,6 +497,94 @@ test("deleteblobcase handler dependency failure returns DELETE_BLOB_CASE_FAILED"
);
});
+const loadCaseDeletionSecurity = (overrides = {}) => {
+ let deletionCalls = 0;
+ let sessionCalls = 0;
+ const mod = loadModule("pages/api/file/deleteblobcase.js", {
+ nextConnect: createNextConnectMock(),
+ getServerSession: async () => {
+ sessionCalls += 1;
+ return { user: { id: "c1" } };
+ },
+ authOptions: () => ({}),
+ hashAPIPath: () => "&hash=expected",
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
+ deleteBlobCase: async () => {
+ deletionCalls += 1;
+ return { deleted: true };
+ },
+ ...overrides
+ });
+
+ return {
+ mod,
+ getDeletionCalls: () => deletionCalls,
+ getSessionCalls: () => sessionCalls
+ };
+};
+
+for (const method of ["GET", "DELETE"]) {
+ test(`deleteblobcase rejects ${method} with 405 before deletion`, async () => {
+ const loaded = loadCaseDeletionSecurity();
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ validCaseDeletionRequest({ method }),
+ 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.getSessionCalls(), 0);
+ assert.strictEqual(loaded.getDeletionCalls(), 0);
+ });
+}
+
+for (const [name, origin] of [
+ ["cross-origin", "https://attacker.example.test"],
+ ["missing Origin", undefined],
+ ["malformed Origin", "not a valid origin"],
+ ["Origin containing a path", `${TRUSTED_EN_ORIGIN}/delete`],
+ ["null Origin", "null"],
+ ["subdomain spoof", "https://portal.example.test.attacker.test"],
+ ["hostname suffix spoof", "https://attacker-portal.example.test"]
+]) {
+ test(`deleteblobcase rejects ${name} before session or deletion`, async () => {
+ const loaded = loadCaseDeletionSecurity();
+ const headers = origin === undefined ? {} : { origin };
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ validCaseDeletionRequest({ headers }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
+ assert.strictEqual(loaded.getSessionCalls(), 0);
+ assert.strictEqual(loaded.getDeletionCalls(), 0);
+ });
+}
+
+test("deleteblobcase accepts the configured Welsh portal Origin", async () => {
+ const loaded = loadCaseDeletionSecurity();
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ validCaseDeletionRequest({
+ headers: { origin: TRUSTED_CY_ORIGIN }
+ }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.getSessionCalls(), 1);
+ assert.strictEqual(loaded.getDeletionCalls(), 1);
+});
+
test("deleteblobrep handler accepts encoded hash variant", async () => {
let deletedRepPath = null;
const mod = loadModule("pages/api/file/deleteblobrep.js", {
@@ -415,6 +598,7 @@ test("deleteblobrep handler accepts encoded hash variant", async () => {
: "&hash=other",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
deleteBlobRep: async (_container, repPath) => {
deletedRepPath = repPath;
return { deleted: true };
@@ -422,6 +606,8 @@ test("deleteblobrep handler accepts encoded hash variant", async () => {
});
const req = {
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN },
query: {
container: "c1",
casefolderID: "CASE-1",
@@ -436,6 +622,49 @@ test("deleteblobrep handler accepts encoded hash variant", async () => {
assert.strictEqual(deletedRepPath, "CASE-1/Rep Name");
});
+test("deleteblobrep rejects missing or unsafe folder segments before deletion", async () => {
+ for (const invalidQuery of [
+ { casefolderID: " ", repfile: "REP-1" },
+ { casefolderID: "CASE-1", repfile: " " },
+ { casefolderID: "CASE-1/REP-1", repfile: "REP-1" },
+ { casefolderID: "CASE-1", repfile: "REP-1/FILES" },
+ { casefolderID: "CASE-1", repfile: ".." }
+ ]) {
+ let deletionCalls = 0;
+ const mod = loadModule("pages/api/file/deleteblobrep.js", {
+ nextConnect: createNextConnectMock(),
+ middleware: () => {},
+ getServerSession: async () => ({ user: { id: "c1" } }),
+ authOptions: () => ({}),
+ hashAPIPath: () => "&hash=expected",
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
+ deleteBlobRep: async () => {
+ deletionCalls += 1;
+ }
+ });
+ const res = createRes();
+
+ await mod.default.handler(
+ {
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN },
+ query: {
+ container: "c1",
+ hash: "expected",
+ ...invalidQuery
+ }
+ },
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 400);
+ assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
+ assert.strictEqual(deletionCalls, 0);
+ }
+});
+
test("deleteblobrep handler dependency failure returns DELETE_BLOB_REP_FAILED", async () => {
const mod = loadModule("pages/api/file/deleteblobrep.js", {
nextConnect: createNextConnectMock(),
@@ -445,12 +674,15 @@ test("deleteblobrep handler dependency failure returns DELETE_BLOB_REP_FAILED",
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
deleteBlobRep: async () => {
throw new Error("delete rep failed");
}
});
const req = {
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN },
query: {
container: "c1",
casefolderID: "f1",
@@ -465,10 +697,102 @@ test("deleteblobrep handler dependency failure returns DELETE_BLOB_REP_FAILED",
assert.strictEqual(res.state.jsonBody.error.code, "DELETE_BLOB_REP_FAILED");
});
+const loadRepDeletionSecurity = (overrides = {}) => {
+ let deletionCalls = 0;
+ let sessionCalls = 0;
+ const mod = loadModule("pages/api/file/deleteblobrep.js", {
+ nextConnect: createNextConnectMock(),
+ getServerSession: async () => {
+ sessionCalls += 1;
+ return { user: { id: "c1" } };
+ },
+ authOptions: () => ({}),
+ hashAPIPath: () => "&hash=expected",
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
+ deleteBlobRep: async () => {
+ deletionCalls += 1;
+ return { deleted: true };
+ },
+ ...overrides
+ });
+
+ return {
+ mod,
+ getDeletionCalls: () => deletionCalls,
+ getSessionCalls: () => sessionCalls
+ };
+};
+
+for (const method of ["GET", "DELETE"]) {
+ test(`deleteblobrep rejects ${method} with 405 before deletion`, async () => {
+ const loaded = loadRepDeletionSecurity();
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ validRepDeletionRequest({ method }),
+ 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.getSessionCalls(), 0);
+ assert.strictEqual(loaded.getDeletionCalls(), 0);
+ });
+}
+
+for (const [name, origin] of [
+ ["cross-origin", "https://attacker.example.test"],
+ ["missing Origin", undefined],
+ ["malformed Origin", "not a valid origin"],
+ ["Origin containing a path", `${TRUSTED_EN_ORIGIN}/delete`],
+ ["null Origin", "null"],
+ ["subdomain spoof", "https://portal.example.test.attacker.test"],
+ ["hostname suffix spoof", "https://attacker-portal.example.test"]
+]) {
+ test(`deleteblobrep rejects ${name} before session or deletion`, async () => {
+ const loaded = loadRepDeletionSecurity();
+ const headers = origin === undefined ? {} : { origin };
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ validRepDeletionRequest({ headers }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 403);
+ assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
+ assert.strictEqual(loaded.getSessionCalls(), 0);
+ assert.strictEqual(loaded.getDeletionCalls(), 0);
+ });
+}
+
+test("deleteblobrep accepts the configured Welsh portal Origin", async () => {
+ const loaded = loadRepDeletionSecurity();
+ const res = createRes();
+
+ await loaded.mod.default.handler(
+ validRepDeletionRequest({
+ headers: { origin: TRUSTED_CY_ORIGIN }
+ }),
+ res
+ );
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.getSessionCalls(), 1);
+ assert.strictEqual(loaded.getDeletionCalls(), 1);
+});
+
const ordinaryDeletionCases = [
{
name: "deleteblobcase",
route: "pages/api/file/deleteblobcase.js",
+ request: {
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN }
+ },
query: {
container: "user-container",
casefolderID: "CASE-1",
@@ -479,6 +803,10 @@ const ordinaryDeletionCases = [
{
name: "deleteblobrep",
route: "pages/api/file/deleteblobrep.js",
+ request: {
+ method: "POST",
+ headers: { origin: TRUSTED_EN_ORIGIN }
+ },
query: {
container: "user-container",
casefolderID: "CASE-1",
@@ -500,13 +828,17 @@ for (const deletionCase of ordinaryDeletionCases) {
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
[deletionCase.dependency]: async () => {
deletionCalls += 1;
}
});
const res = createRes();
- await mod.default.handler({ query: deletionCase.query }, res);
+ await mod.default.handler(
+ { ...deletionCase.request, query: deletionCase.query },
+ res
+ );
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
@@ -523,13 +855,17 @@ for (const deletionCase of ordinaryDeletionCases) {
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
[deletionCase.dependency]: async () => {
deletionCalls += 1;
}
});
const res = createRes();
- await mod.default.handler({ query: deletionCase.query }, res);
+ await mod.default.handler(
+ { ...deletionCase.request, query: deletionCase.query },
+ res
+ );
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(deletionCalls, 0);
@@ -549,13 +885,17 @@ for (const deletionCase of ordinaryDeletionCases) {
},
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
[deletionCase.dependency]: async () => {
deletionCalls += 1;
}
});
const res = createRes();
- await mod.default.handler({ query: deletionCase.query }, res);
+ await mod.default.handler(
+ { ...deletionCase.request, query: deletionCase.query },
+ res
+ );
assert.strictEqual(res.state.statusCode, 403);
assert.strictEqual(hashCalls, 0);
@@ -580,9 +920,11 @@ for (const deletionCase of ordinaryDeletionCases) {
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
[deletionCase.dependency]: async () => ({ deleted: true })
});
const req = {
+ ...deletionCase.request,
query: deletionCase.query,
body: { parsed: ["multipart"] }
};
@@ -608,13 +950,17 @@ for (const deletionCase of ordinaryDeletionCases) {
hashAPIPath: () => "&hash=different",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
[deletionCase.dependency]: async () => {
deletionCalls += 1;
}
});
const res = createRes();
- await mod.default.handler({ query: deletionCase.query }, res);
+ await mod.default.handler(
+ { ...deletionCase.request, query: deletionCase.query },
+ res
+ );
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
@@ -631,6 +977,7 @@ for (const deletionCase of ordinaryDeletionCases) {
hashAPIPath: () => "&hash=expected",
respondError: respondErrorMock,
respondSuccess: respondSuccessMock,
+ process: deletionTestProcess,
[deletionCase.dependency]: async (...args) => {
deletionCalls.push(args);
return { deleted: true };
@@ -638,7 +985,10 @@ for (const deletionCase of ordinaryDeletionCases) {
});
const res = createRes();
- await mod.default.handler({ query: deletionCase.query }, res);
+ await mod.default.handler(
+ { ...deletionCase.request, query: deletionCase.query },
+ res
+ );
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(deletionCalls.length, 1);
@@ -784,7 +1134,7 @@ test("admin storage caller uses dedicated POST deletion while ordinary caller re
assert.doesNotMatch(adminSource, /deleteMyRepresentationsFromBlob/);
assert.match(
ordinarySource,
- /buildFileQuery\("\/api\/file\/deleteblobrep"[\s\S]*getSignedFileJson\(queryUrl\)/
+ /buildFileQuery\("\/api\/file\/deleteblobrep"[\s\S]*postSignedFileJson\(queryUrl\)/
);
});
@@ -971,28 +1321,36 @@ test("admin case deletion dependency failure returns DELETE_BLOB_CASE_FAILED", a
assert.strictEqual(loaded.calls.deletion.length, 1);
});
-test("admin storage case deletion uses dedicated POST route while ordinary callers remain signed GET", () => {
- const rootDir = path.resolve(__dirname, "..", "..");
- const adminSource = fs.readFileSync(
- path.join(rootDir, "components/admin/tabs/storage.js"),
- "utf8"
- );
- const ordinarySource = fs.readFileSync(
- path.join(rootDir, "actions/services/documentDirectService.js"),
- "utf8"
- );
+test("case and representation deletion use signed POST", async () => {
+ const calls = [];
+ const mod = loadDocumentDirectService({
+ buildFileQuery: (route, params) => {
+ const query = Object.entries(params)
+ .map(([key, value]) => `${key}=${value}`)
+ .join("&");
+ return `${route}?${query}`;
+ },
+ postSignedFileJson: async (...args) => {
+ calls.push(["post", ...args]);
+ return { deleted: true };
+ },
+ getSignedFileJson: async (...args) => {
+ calls.push(["get", ...args]);
+ return { deleted: true };
+ },
+ consoleLogger: () => {}
+ });
- assert.match(adminSource, /fetch\(\s*"\/api\/admin\/deleteblobcase"/);
- assert.match(
- adminSource,
- /"\/api\/admin\/deleteblobcase"[\s\S]*method:\s*"POST"[\s\S]*container:\s*c\.container[\s\S]*casefolderID:\s*folder\.folderPath/
- );
- assert.match(adminSource, /if \(!response\.ok\)/);
- assert.doesNotMatch(adminSource, /deleteAwaitingSubmissionsFromBlob/);
- assert.match(
- ordinarySource,
- /buildFileQuery\("\/api\/file\/deleteblobcase"[\s\S]*getSignedFileJson\(queryUrl\)/
- );
+ await mod.deleteAwaitingSubmissionsFromBlob("c1", "CASE-1");
+ await mod.deleteMyRepresentationsFromBlob("c1", "CASE-1", "REP-1");
+
+ assert.deepStrictEqual(JSON.parse(JSON.stringify(calls)), [
+ ["post", "/api/file/deleteblobcase?container=c1&casefolderID=CASE-1"],
+ [
+ "post",
+ "/api/file/deleteblobrep?container=c1&casefolderID=CASE-1&repfile=REP-1"
+ ]
+ ]);
});
const loadAdminRepJsonEdit = (overrides = {}) => {
@@ -5684,6 +6042,10 @@ test("whole-representation deletion remains cross-user protected with zero Azure
await loaded.mod.default.handler(
{
+ method: "POST",
+ headers: {
+ origin: "http://localhost:3000"
+ },
query: {
container: "organisation-container",
casefolderID: "CASE-1",
diff --git a/tests/phase22/azurestorage-helper-behaviour.test.cjs b/tests/phase22/azurestorage-helper-behaviour.test.cjs
index e9f68049..1ead7376 100644
--- a/tests/phase22/azurestorage-helper-behaviour.test.cjs
+++ b/tests/phase22/azurestorage-helper-behaviour.test.cjs
@@ -33,9 +33,87 @@ const loadAzureStorageHelperModule = (injected = {}) => {
return context.module.exports;
};
+const loadRecursiveDeletionHelpers = ({
+ blobNames = [],
+ deleteBlob = () => Promise.resolve()
+} = {}) => {
+ const filePath = path.join(rootDir, "actions", "azurestorage.js");
+ const source = fs.readFileSync(filePath, "utf8");
+ const start = source.indexOf("const isSafeDeletionPathSegment =");
+ const end = source.indexOf("export const uploadFile =");
+
+ if (start < 0 || end < 0 || end <= start) {
+ throw new Error("Unable to isolate recursive deletion helper block");
+ }
+
+ const listedPrefixes = [];
+ const deletedBlobNames = [];
+ const containerClient = {
+ async *listBlobsFlat({ prefix }) {
+ listedPrefixes.push(prefix);
+ for (const name of blobNames.filter((blobName) =>
+ blobName.startsWith(prefix)
+ )) {
+ yield { name };
+ }
+ },
+ deleteBlob(name) {
+ deletedBlobNames.push(name);
+ return deleteBlob(name);
+ },
+ getBlockBlobClient() {
+ return { exists: async () => true };
+ }
+ };
+
+ let helperSource = source
+ .slice(start, end)
+ .replace(/export const /g, "const ");
+ helperSource +=
+ "\nmodule.exports = { deleteBlobCase, deleteBlobRep };\n";
+
+ const context = {
+ module: { exports: {} },
+ exports: {},
+ console: { log: () => {} },
+ DefaultAzureCredential: class {},
+ createContainerSas: async () => "sas-token",
+ STORAGE_PATH: "https://storage.example.test",
+ ContainerClient: class {
+ constructor() {
+ return containerClient;
+ }
+ }
+ };
+
+ vm.runInNewContext(helperSource, context, { filename: filePath });
+
+ return {
+ ...context.module.exports,
+ listedPrefixes,
+ deletedBlobNames
+ };
+};
+
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
+const createDeferred = () => {
+ let resolve;
+ let reject;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+
+ return { promise, resolve, reject };
+};
+
+const flushMicrotasks = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
test("azurestorage helper builds download path with default encoding", () => {
const mod = loadAzureStorageHelperModule();
@@ -103,6 +181,159 @@ test("azurestorage helper builds hash metadata map with stable keys", () => {
});
});
+test("case deletion uses a complete folder prefix and excludes lexical siblings", async () => {
+ const loaded = loadRecursiveDeletionHelpers({
+ blobNames: [
+ "TMP-ABC/TMP-ABC_appeal.json",
+ "TMP-ABC/files/evidence.pdf",
+ "TMP-ABC1/TMP-ABC1_appeal.json",
+ "TMP-ABCD/files/sibling.pdf"
+ ]
+ });
+
+ await loaded.deleteBlobCase("container-1", "TMP-ABC");
+
+ assert.deepStrictEqual(loaded.listedPrefixes, ["TMP-ABC/"]);
+ assert.deepStrictEqual(loaded.deletedBlobNames, [
+ "TMP-ABC/TMP-ABC_appeal.json",
+ "TMP-ABC/files/evidence.pdf"
+ ]);
+});
+
+test("representation deletion uses a complete folder prefix and preserves parent and siblings", async () => {
+ const loaded = loadRecursiveDeletionHelpers({
+ blobNames: [
+ "CASE-1/CASE-1_case.json",
+ "CASE-1/REP-1/REP-1_rep.json",
+ "CASE-1/REP-1/files/evidence.pdf",
+ "CASE-1/REP-10/REP-10_rep.json",
+ "CASE-1/REP-2/REP-2_rep.json"
+ ]
+ });
+
+ await loaded.deleteBlobRep("container-1", "CASE-1/REP-1");
+
+ assert.deepStrictEqual(loaded.listedPrefixes, ["CASE-1/REP-1/"]);
+ assert.deepStrictEqual(loaded.deletedBlobNames, [
+ "CASE-1/REP-1/REP-1_rep.json",
+ "CASE-1/REP-1/files/evidence.pdf"
+ ]);
+});
+
+test("recursive deletion helpers reject empty and unsafe resource paths before listing", async () => {
+ for (const [helperName, unsafePath] of [
+ ["deleteBlobCase", ""],
+ ["deleteBlobCase", "CASE-1/REP-1"],
+ ["deleteBlobRep", "CASE-1"],
+ ["deleteBlobRep", "CASE-1/REP-1/FILES"],
+ ["deleteBlobRep", "CASE-1/../REP-1"]
+ ]) {
+ const loaded = loadRecursiveDeletionHelpers();
+
+ await assert.rejects(
+ () => loaded[helperName]("container-1", unsafePath),
+ /Invalid deletion resource path/
+ );
+ assert.deepStrictEqual(loaded.listedPrefixes, []);
+ }
+});
+
+for (const deletionCase of [
+ {
+ helperName: "deleteBlobCase",
+ resourcePath: "TMP-ABC",
+ blobName: "TMP-ABC/files/evidence.pdf"
+ },
+ {
+ helperName: "deleteBlobRep",
+ resourcePath: "CASE-1/REP-1",
+ blobName: "CASE-1/REP-1/files/evidence.pdf"
+ }
+]) {
+ test(`${deletionCase.helperName} remains pending until Azure deletion completes`, async () => {
+ const deletion = createDeferred();
+ const deletionStarted = createDeferred();
+ const loaded = loadRecursiveDeletionHelpers({
+ blobNames: [deletionCase.blobName],
+ deleteBlob: () => {
+ deletionStarted.resolve();
+ return deletion.promise;
+ }
+ });
+ let settled = false;
+
+ const helperPromise = loaded[
+ deletionCase.helperName
+ ]("container-1", deletionCase.resourcePath).then((result) => {
+ settled = true;
+ return result;
+ });
+
+ await deletionStarted.promise;
+ await flushMicrotasks();
+ assert.strictEqual(settled, false);
+
+ deletion.resolve();
+ assert.deepStrictEqual(
+ JSON.parse(JSON.stringify(await helperPromise)),
+ { deleted: deletionCase.resourcePath }
+ );
+ });
+
+ test(`${deletionCase.helperName} propagates Azure deletion rejection`, async () => {
+ const deletionError = new Error("controlled Azure deletion failure");
+ const rejectedDeletion = Promise.reject(deletionError);
+ rejectedDeletion.catch(() => {});
+ const loaded = loadRecursiveDeletionHelpers({
+ blobNames: [deletionCase.blobName],
+ deleteBlob: () => rejectedDeletion
+ });
+
+ await assert.rejects(
+ () =>
+ loaded[deletionCase.helperName](
+ "container-1",
+ deletionCase.resourcePath
+ ),
+ /controlled Azure deletion failure/
+ );
+ });
+}
+
+test("recursive deletion does not complete until all selected blob deletions complete", async () => {
+ const firstDeletion = createDeferred();
+ const secondDeletion = createDeferred();
+ const deletionPromises = new Map([
+ ["TMP-ABC/files/first.pdf", firstDeletion.promise],
+ ["TMP-ABC/files/second.pdf", secondDeletion.promise]
+ ]);
+ const loaded = loadRecursiveDeletionHelpers({
+ blobNames: [...deletionPromises.keys()],
+ deleteBlob: (name) => deletionPromises.get(name)
+ });
+ let settled = false;
+
+ const helperPromise = loaded
+ .deleteBlobCase("container-1", "TMP-ABC")
+ .then((result) => {
+ settled = true;
+ return result;
+ });
+
+ await flushMicrotasks();
+ assert.strictEqual(settled, false);
+
+ firstDeletion.resolve();
+ await flushMicrotasks();
+ assert.strictEqual(settled, false);
+
+ secondDeletion.resolve();
+ assert.deepStrictEqual(
+ JSON.parse(JSON.stringify(await helperPromise)),
+ { deleted: "TMP-ABC" }
+ );
+});
+
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..286ebcb9 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,
@@ -208,7 +210,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 +400,164 @@ 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("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);
+
+ 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), {
+ 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"
+ }),
+ (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);"
+ );
+
+ 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)"),
+ true
+ );
+ assert.strictEqual(
+ source.includes("sendContactUsConfirmation("),
+ true
+ );
+ assert.strictEqual(source.includes("sendEmail(templateId,"), false);
+ assert.strictEqual(source.includes("props.petEmail"), false);
+ assert.strictEqual(source.includes("process.env.PETEMAIL"), false);
+ }
+});
+
test("integration/createCRMTask posts payload and returns data", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();
diff --git a/tests/security/security-headers.test.cjs b/tests/security/security-headers.test.cjs
new file mode 100644
index 00000000..cfe41763
--- /dev/null
+++ b/tests/security/security-headers.test.cjs
@@ -0,0 +1,318 @@
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+const { pathToRegexp } = require("next/dist/compiled/path-to-regexp");
+const {
+ createNextConnectMock,
+ createRes,
+ loadModule,
+ respondErrorMock,
+ respondSuccessMock
+} = require("../phase21/_shared.cjs");
+
+const rootDirectory = path.join(__dirname, "..", "..");
+
+const toHeaderObject = (headers) =>
+ Object.fromEntries(headers.map(({ key, value }) => [key, value]));
+
+const getMatchingHeaders = (configuredRules, pathname) =>
+ configuredRules
+ .filter(({ source }) => pathToRegexp(source).test(pathname))
+ .flatMap(({ headers }) => headers);
+
+const getCacheControl = (configuredRules, pathname) =>
+ getMatchingHeaders(configuredRules, pathname).find(
+ ({ key }) => key.toLowerCase() === "cache-control"
+ )?.value;
+
+const applyConfiguredHeaders = (configuredRules, pathname, res) => {
+ for (const { key, value } of getMatchingHeaders(
+ configuredRules,
+ pathname
+ )) {
+ res.setHeader(key, value);
+ }
+};
+
+const loadAdminNoStore = () => {
+ const adminAuthPath = path.join(rootDirectory, "lib", "admin", "adminAuth.js");
+ const moduleSource = fs.readFileSync(adminAuthPath, "utf8");
+ const declaration = moduleSource.match(
+ /export const setAdminAuthNoStore = \(res\) => \{[\s\S]*?\n\};/
+ );
+
+ assert.ok(declaration, "Expected to find setAdminAuthNoStore declaration");
+
+ const source = declaration[0]
+ .replace("export const", "const")
+ .concat("\nmodule.exports = { setAdminAuthNoStore };\n");
+ const context = {
+ module: { exports: {} }
+ };
+
+ vm.runInNewContext(source, context, { filename: adminAuthPath });
+ return context.module.exports.setAdminAuthNoStore;
+};
+
+const loadMiddleware = () => {
+ const middlewarePath = path.join(rootDirectory, "middleware.js");
+ const source = fs
+ .readFileSync(middlewarePath, "utf8")
+ .replace(
+ 'import { NextResponse } from "next/server";',
+ 'const { NextResponse } = require("next/server");'
+ )
+ .replace("export function middleware", "function middleware")
+ .concat("\nmodule.exports = { middleware };\n");
+ const context = {
+ Buffer,
+ Headers,
+ module: { exports: {} },
+ process: {
+ env: {
+ NEXTAUTH_URL: "https://example.test",
+ NODE_ENV: "production"
+ }
+ },
+ require: (moduleName) => {
+ assert.strictEqual(moduleName, "next/server");
+ return {
+ NextResponse: {
+ next: () => ({ headers: new Headers() })
+ }
+ };
+ },
+ crypto: {
+ randomUUID: () => "00000000-0000-4000-8000-000000000000"
+ }
+ };
+
+ vm.runInNewContext(source, context, { filename: middlewarePath });
+ return context.module.exports.middleware;
+};
+
+const run = async () => {
+ const nextConfig = require(path.join(rootDirectory, "next.config.js"));
+ const configuredRules = await nextConfig.headers();
+ const globalRule = configuredRules.find(
+ ({ source }) => source === "/(.*)?"
+ );
+
+ assert.ok(globalRule, "Expected the global Next.js security-header rule");
+ assert.deepStrictEqual(toHeaderObject(globalRule.headers), {
+ "X-DNS-Prefetch-Control": "on",
+ "X-Frame-Options": "SAMEORIGIN",
+ "X-Content-Type-Options": "nosniff",
+ "X-Permitted-Cross-Domain-Policies": "none",
+ "Referrer-Policy": "origin-when-cross-origin",
+ "Strict-Transport-Security":
+ "max-age=63072000; includeSubDomains; preload",
+ "X-XSS-Protection": "0"
+ });
+
+ const sensitivePaths = [
+ "/api/admin/auth/session",
+ "/api/email/notify",
+ "/api/file/getprogressobjblob",
+ "/api/file/nested/private/resource",
+ "/api/myportal/get-my-cases",
+ "/api/subscriptions/get-watched-cases",
+ "/api/auth/resolve-locale",
+ "/api/endpoint/createaccount_api",
+ "/api/endpoint/getaccounts_api",
+ "/api/endpoint/getemailaccountcheck_api",
+ "/api/endpoint/getpersonalaccount_api",
+ "/api/endpoint/getportallogin_api",
+ "/api/endpoint/getportalloginproxy_api",
+ "/api/endpoint/getpreferredlanguage_api",
+ "/api/endpoint/updateaccount_api",
+ "/api/endpoint/gethash_api",
+ "/api/endpoint/getmycases_api",
+ "/api/endpoint/getmylpacases_api",
+ "/api/endpoint/getmyrepresentations_api",
+ "/api/endpoint/getmyrepresentationsproxy_api",
+ "/api/endpoint/getawaitingsubmission_api",
+ "/api/endpoint/getawaitingsubmissionproxy_api",
+ "/api/endpoint/getwatchedcases_api",
+ "/api/endpoint/getwatchedcasesproxy_api",
+ "/api/endpoint/createwatchedcases_api",
+ "/api/endpoint/deletewatchedcases_api",
+ "/api/endpoint/deletewatchedcasesproxy_api",
+ "/api/endpoint/deleteawaitingsubmissions_api",
+ "/api/endpoint/getbasicpartsaveddetails_api",
+ "/api/endpoint/getpartsavedappeal_api",
+ "/api/endpoint/getappealid_api",
+ "/api/endpoint/getappealpdfdocuments_api",
+ "/api/endpoint/getportalmoduledetails_api",
+ "/api/endpoint/getportalmoduledetailsproxy_api",
+ "/api/endpoint/patchcase_api",
+ "/api/endpoint/updatecase_api",
+ "/api/endpoint/createcrmtask_api"
+ ];
+
+ for (const pathname of sensitivePaths) {
+ assert.strictEqual(
+ getCacheControl(configuredRules, pathname),
+ "no-store",
+ `Expected ${pathname} to receive Cache-Control: no-store`
+ );
+ }
+
+ const excludedPaths = [
+ "/",
+ "/search",
+ "/dnsapplications",
+ "/_next/static/chunks/example.js",
+ "/_next/image",
+ "/api/search/advanced",
+ "/api/cases/get-incident-by-id",
+ "/api/documents/download/example",
+ "/api/notices",
+ "/api/health",
+ "/api/auth/session",
+ "/api/auth/csrf",
+ "/api/endpoint/getbasicsearch_api",
+ "/api/endpoint/getrepresentations_api",
+ "/api/endpoint/getappealtypes_api",
+ "/api/endpoint/getpicklists_api"
+ ];
+
+ for (const pathname of excludedPaths) {
+ assert.strictEqual(
+ getCacheControl(configuredRules, pathname),
+ undefined,
+ `Expected ${pathname} to remain outside the no-store rules`
+ );
+ }
+
+ const middleware = loadMiddleware();
+ const response = middleware({ headers: new Headers() });
+
+ assert.strictEqual(response.headers.get("X-XSS-Protection"), "0");
+ assert.strictEqual(response.headers.get("X-Content-Type-Options"), "nosniff");
+ assert.strictEqual(response.headers.get("X-Frame-Options"), "DENY");
+ assert.strictEqual(
+ response.headers.get("Referrer-Policy"),
+ "strict-origin-when-cross-origin"
+ );
+ assert.strictEqual(
+ response.headers.get("Permissions-Policy"),
+ "geolocation=(), camera=(), microphone=(), fullscreen=(self)"
+ );
+ assert.strictEqual(
+ response.headers.get("Strict-Transport-Security"),
+ "max-age=63072000; includeSubDomains; preload"
+ );
+ assert.match(
+ response.headers.get("Content-Security-Policy"),
+ /default-src 'self'/
+ );
+
+ const ordinaryResponse = createRes();
+ applyConfiguredHeaders(
+ configuredRules,
+ "/api/endpoint/getpersonalaccount_api",
+ ordinaryResponse
+ );
+ respondSuccessMock(ordinaryResponse, { ok: true });
+ assert.strictEqual(
+ ordinaryResponse.state.headers["cache-control"],
+ "no-store"
+ );
+
+ const validationErrorResponse = createRes();
+ applyConfiguredHeaders(
+ configuredRules,
+ "/api/endpoint/getpersonalaccount_api",
+ validationErrorResponse
+ );
+ respondErrorMock(validationErrorResponse, {
+ status: 400,
+ code: "CONTACT_ID_REQUIRED",
+ message: "contactid is required"
+ });
+ assert.strictEqual(
+ validationErrorResponse.state.headers["cache-control"],
+ "no-store"
+ );
+
+ const adminResponse = createRes();
+ applyConfiguredHeaders(
+ configuredRules,
+ "/api/admin/auth/session",
+ adminResponse
+ );
+ loadAdminNoStore()(adminResponse);
+ assert.strictEqual(
+ adminResponse.state.headers["cache-control"],
+ "no-store, max-age=0"
+ );
+ assert.strictEqual(adminResponse.state.headers.pragma, "no-cache");
+
+ const fileHandler = loadModule("pages/api/file/getrepsblob.js", {
+ nextConnect: createNextConnectMock(),
+ middleware: () => {},
+ hashAPIPath: () => "&hash=expected",
+ getServerSession: async () => ({ user: { id: "owner-container" } }),
+ authOptions: () => ({}),
+ readAdminSessionCookie: () => null,
+ validateAdminSession: () => false,
+ getRepsBlobs: async () => [{ name: "representation.json" }],
+ downloadAllRepsFiles: async () => [{ id: "representation" }],
+ consoleLogger: () => {},
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock
+ });
+ const fileResponse = createRes();
+ applyConfiguredHeaders(
+ configuredRules,
+ "/api/file/getrepsblob",
+ fileResponse
+ );
+ await fileHandler.default.handler(
+ {
+ query: {
+ container: "owner-container",
+ hash: "expected"
+ }
+ },
+ fileResponse
+ );
+ assert.strictEqual(fileResponse.state.statusCode, 200);
+ assert.strictEqual(
+ fileResponse.state.headers["cache-control"],
+ "no-store"
+ );
+
+ const publicCacheDirective =
+ "public, s-maxage=10, stale-while-revalidate=59";
+ for (const relativePath of [
+ "pages/dns/index.js",
+ "pages/dnsapplications.js",
+ "pages/myportal/dnsapplications.js"
+ ]) {
+ const pageSource = fs.readFileSync(
+ path.join(rootDirectory, relativePath),
+ "utf8"
+ );
+ assert.match(
+ pageSource,
+ new RegExp(
+ `res\\.setHeader\\(\\s*["']Cache-Control["']\\s*,\\s*["']${publicCacheDirective.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*\\)`
+ ),
+ `Expected ${relativePath} to preserve its public cache directive`
+ );
+ }
+
+ console.log("Security-header regression tests passed (10/10).");
+};
+
+if (require.main === module) {
+ run().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
+
+module.exports = run;