Merged PR 2677: Feature/ithc mitigation final

Related work items: #25509, #25515, #25516
This commit is contained in:
Robert Bond
2026-11-10 11:04:13 +00:00
20 changed files with 3396 additions and 177 deletions
+35 -6
View File
@@ -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);
+2 -2
View File
@@ -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);
});
};
+41
View File
@@ -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;
}
};
@@ -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) => {
+81
View File
@@ -0,0 +1,81 @@
const LINK_REMOVED = "[web link removed]";
const OBFUSCATING_SPACE = "[\\s\\u0000-\\u001f\\u007f\\u200b-\\u200d\\ufeff]*";
const SCHEME = `h${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}p(?:${OBFUSCATING_SPACE}s)?${OBFUSCATING_SPACE}:${OBFUSCATING_SPACE}\\/${OBFUSCATING_SPACE}\\/`;
const DOMAIN = "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}";
const URL_TAIL = "[^\\s<>\\\"']*";
const explicitUrlPattern = () => new RegExp(`${SCHEME}${DOMAIN}${URL_TAIL}`, "gi");
const wwwUrlPattern = () => new RegExp(`\\bwww\\.${DOMAIN}${URL_TAIL}`, "gi");
const bareDomainWithPathPattern = () =>
new RegExp(`(^|[^a-z0-9@._-])(${DOMAIN}\\/[^\\s<>\\\"']*)`, "gi");
const stripTrailingPunctuation = (value) => {
let destination = value;
let punctuation = "";
while (/[.,!;:]$/.test(destination)) {
punctuation = destination.slice(-1) + punctuation;
destination = destination.slice(0, -1);
}
while (/[)\]}]$/.test(destination)) {
punctuation = destination.slice(-1) + punctuation;
destination = destination.slice(0, -1);
}
return { destination, punctuation };
};
const replaceUrlMatch = (value) => {
const { destination, punctuation } = stripTrailingPunctuation(value);
return destination ? LINK_REMOVED + punctuation : value;
};
const containsWebDestination = (value) => {
explicitUrlPattern().lastIndex = 0;
wwwUrlPattern().lastIndex = 0;
bareDomainWithPathPattern().lastIndex = 0;
return (
explicitUrlPattern().test(value) ||
wwwUrlPattern().test(value) ||
bareDomainWithPathPattern().test(value)
);
};
export default function removeContactUsEmailLinks(message) {
if (typeof message !== "string") {
return message;
}
let transformed = message;
transformed = transformed.replace(
/<a\b[^>]*>([\s\S]*?)<\/a\s*>/gi,
(match, linkText) =>
containsWebDestination(match)
? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}`
: match
);
transformed = transformed.replace(/<[^>]+>/gi, (tag) =>
containsWebDestination(tag) ? LINK_REMOVED : tag
);
transformed = transformed.replace(
/\[([^\]]*)\]\(([^)]*)\)/gi,
(match, linkText, destination) =>
containsWebDestination(destination)
? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}`
: match
);
transformed = transformed.replace(explicitUrlPattern(), replaceUrlMatch);
transformed = transformed.replace(wwwUrlPattern(), replaceUrlMatch);
transformed = transformed.replace(
bareDomainWithPathPattern(),
(match, prefix, destination) => prefix + replaceUrlMatch(destination)
);
return transformed;
}
+1 -1
View File
@@ -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");
+31 -1
View File
@@ -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
}
];
},
+246
View File
@@ -0,0 +1,246 @@
import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards";
import { consoleLogger } from "../../../actions/core/logger";
import removeContactUsEmailLinks from "../../../lib/security/contactus-email-links";
import { respondError, respondSuccess } from "../middleware/apiResponse";
const TEMPLATE_IDS = {
en: "69228c5f-8500-40ff-a6c5-95a9434c3ec8",
cy: "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3"
};
const REFERENCE = "PEDW-CONTACT-US";
const allowedSubjects = {
plq: [
"Questions about Planning legislation or policy",
"Planning legislation query",
"Ymholiad deddfwriaeth gynllunio"
],
ipq: [
"Questions or comments about applications for major infrastructure projects",
"Infrastructure procedure query",
"Ymholiad gweithdrefn seilwaith"
],
pcpq: [
"Questions or comments about planning and environmental appeals",
"Planning casework procedure query",
"Ymholiad gweithdrefn gwaith achos cynllunio"
],
pi: ["Query about using the Casework Portal", "Portal Issues", "Materion Porth"],
appadvice: [
"Advice How to submit an appeal",
"Appeal submission advice",
"Cyngor cyflwyno apêl"
],
repadvice: [
"Advice How to comment on an appeal or application",
"Representation submission advice",
"Cyngor cyflwyno sylwadau"
],
ceq: [
"Queries about forthcoming Hearing or Inquiry event",
"Casework Events Query",
"Ymholiad Digwyddiadau Gwaith Achos"
],
other: ["Other enquiry for PEDW", "Any Other Enquiries", "Unrhyw Ymholiadau Eraill"],
postdec: [
"Issues or questions about a case after the decision is issued",
"Post Decision",
"Ar ôl Penderfyniad"
],
comp: [
"General Complaints of Compliments",
"Complaints/Complements",
"Cwynion/Canmoliaethau"
]
};
const allowedFields = new Set([
"contactEmail",
"contactSubject",
"contactbody",
"typeOfContact",
"language"
]);
const emailRegex =
/^(?!.*(?:.+\.)?(?:burpcollaborator\.net|interactsh\.com|mailinator\.com|10minutemail\.com|yopmail\.com|trashmail\.com)$)(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:.+)])$/;
// Origin checking is browser defence in depth only. It is not authentication and
// does not prevent a direct HTTP client from attempting abuse.
const hasTrustedOrigin = (req) => {
const requestOrigin = req?.headers?.origin;
if (typeof requestOrigin !== "string" || requestOrigin === "null") {
return false;
}
try {
const requestUrl = new URL(requestOrigin);
if (requestOrigin !== requestUrl.origin) {
return false;
}
const trustedOrigins = [process.env.NEXTAUTH_URL, process.env.CY_API_ROOT]
.filter(
(configuredUrl) =>
typeof configuredUrl === "string" && configuredUrl.length > 0
)
.map((configuredUrl) => new URL(configuredUrl).origin);
return trustedOrigins.includes(requestUrl.origin);
} catch (_error) {
return false;
}
};
const validationError = (res, code, message) =>
respondError(res, { status: 400, code, message });
const isPlainObject = (value) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype?.constructor?.name === "Object";
};
export default async function ApiProxy(req, res) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return respondError(res, {
status: 405,
code: "METHOD_NOT_ALLOWED",
message: "Method not allowed"
});
}
const contentType = req.headers?.["content-type"];
if (
typeof contentType !== "string" ||
contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json"
) {
return respondError(res, {
status: 415,
code: "UNSUPPORTED_MEDIA_TYPE",
message: "Content-Type must be application/json"
});
}
if (!hasTrustedOrigin(req)) {
return respondError(res, {
status: 403,
code: "FORBIDDEN",
message: "Request not permitted"
});
}
const requestBody = req.body;
if (!isPlainObject(requestBody)) {
return validationError(
res,
"INVALID_REQUEST_BODY",
"Request body must be a JSON object"
);
}
const requestFields = Object.keys(requestBody);
if (
requestFields.length !== allowedFields.size ||
requestFields.some((field) => !allowedFields.has(field))
) {
return validationError(
res,
"INVALID_REQUEST_FIELDS",
"Request must contain exactly the supported fields"
);
}
const contactEmail = sanitizeString(requestBody.contactEmail);
const contactSubject = sanitizeString(requestBody.contactSubject);
const contactBody = sanitizeString(requestBody.contactbody);
const typeOfContact = requestBody.typeOfContact;
const language = requestBody.language;
if (
typeof requestBody.contactEmail !== "string" ||
!isNonEmptyString(contactEmail) ||
contactEmail.length > 200 ||
!emailRegex.test(contactEmail)
) {
return validationError(
res,
"INVALID_CONTACT_EMAIL",
"contactEmail must be a valid email address of 200 characters or fewer"
);
}
if (
typeof typeOfContact !== "string" ||
!Object.prototype.hasOwnProperty.call(allowedSubjects, typeOfContact)
) {
return validationError(
res,
"INVALID_CONTACT_TYPE",
"typeOfContact is not supported"
);
}
if (
typeof requestBody.contactbody !== "string" ||
!isNonEmptyString(contactBody) ||
contactBody.length > 800
) {
return validationError(
res,
"INVALID_CONTACT_BODY",
"contactbody must contain between 1 and 800 characters"
);
}
if (
typeof requestBody.contactSubject !== "string" ||
!allowedSubjects[typeOfContact].includes(contactSubject)
) {
return validationError(
res,
"INVALID_CONTACT_SUBJECT",
"contactSubject does not match typeOfContact"
);
}
if (language !== "en" && language !== "cy") {
return validationError(
res,
"INVALID_LANGUAGE",
"language must be either en or cy"
);
}
const personalisation = {
contactEmail,
contactSubject,
contactbody: removeContactUsEmailLinks(contactBody),
typeOfContact
};
const NotifyClient = require("notifications-node-client").NotifyClient;
const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY);
try {
await notifyClient.sendEmail(TEMPLATE_IDS[language], contactEmail, {
personalisation,
reference: REFERENCE
});
return respondSuccess(res, { sent: true });
} catch (error) {
consoleLogger(error);
return respondError(res, {
status: 400,
code: "CONTACT_US_CONFIRMATION_FAILED",
message: "Failed to send Contact Us confirmation email"
});
}
}
@@ -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"
});
}
}
+197 -22
View File
@@ -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
+65 -4
View File
@@ -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" ||
+68 -4
View File
@@ -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" ||
+9 -35
View File
@@ -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
+9 -35
View File
@@ -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
@@ -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 <a href="https://html.invalid/login">review this</a>.',
'Tag <img src="https://images.invalid/pixel"> remains non-actionable.',
"Obfuscated h\u200btt\u200bps://obfuscated.invalid/login is removed."
].join("\n");
const output = removeLinks(input);
assert.strictEqual(
output,
[
"Please review [web link removed].",
"Also [web link removed] and [web link removed].",
"Bare [web link removed] remains represented safely.",
"Email applicant@example.com and reference APP/123456 must remain.",
"Markdown review this [web link removed].",
"HTML review this [web link removed].",
"Tag [web link removed] remains non-actionable.",
"Obfuscated [web link removed] is removed."
].join("\n")
);
assert.strictEqual(input.includes("https://example.invalid"), true);
});
test("Contact Us confirmation is POST-only with Allow header", async () => {
const req = validContactConfirmationRequest({ method: "GET" });
await withContactOrigins(async () => {
const loaded = loadContactConfirmationEndpoint();
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 405);
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
assert.strictEqual(res.state.headers.allow, "POST");
assert.strictEqual(loaded.notifyCalls.length, 0);
});
});
for (const contentType of [
undefined,
"text/plain",
"application/x-www-form-urlencoded"
]) {
test(`Contact Us confirmation rejects content type ${String(contentType)}`, async () => {
const req = validContactConfirmationRequest({
headers: {
origin: trustedContactOrigin,
...(contentType ? { "content-type": contentType } : {})
}
});
await assertContactConfirmationRejected({
req,
status: 415,
code: "UNSUPPORTED_MEDIA_TYPE"
});
});
}
for (const origin of [
undefined,
"null",
"https://attacker.example",
`${trustedContactOrigin}/path`
]) {
test(`Contact Us confirmation rejects origin ${String(origin)}`, async () => {
const req = validContactConfirmationRequest({
headers: {
...(origin ? { origin } : {}),
"content-type": "application/json"
}
});
await assertContactConfirmationRejected({
req,
status: 403,
code: "FORBIDDEN"
});
});
}
test("Contact Us confirmation accepts each configured exact origin", async () => {
await withContactOrigins(async () => {
for (const origin of [
trustedContactOrigin,
trustedWelshContactOrigin
]) {
const loaded = loadContactConfirmationEndpoint();
const req = validContactConfirmationRequest({
headers: { origin, "content-type": "application/json" }
});
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.notifyCalls.length, 1);
}
});
});
for (const body of [null, [], "not-an-object", new Date()]) {
test(`Contact Us confirmation rejects non-plain body ${Object.prototype.toString.call(body)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({ body }),
status: 400,
code: "INVALID_REQUEST_BODY"
});
});
}
for (const bodyOverride of [
{ extra: "field" },
{ language: undefined },
{ contactSubject: undefined }
]) {
test(`Contact Us confirmation enforces exact fields ${Object.keys(bodyOverride).join(",")}`, async () => {
const body = {
...validContactConfirmationRequest().body,
...bodyOverride
};
if (Object.values(bodyOverride).includes(undefined)) {
for (const key of Object.keys(bodyOverride)) delete body[key];
}
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({ body }),
status: 400,
code: "INVALID_REQUEST_FIELDS"
});
});
}
for (const contactEmail of [
undefined,
"invalid",
`${"a".repeat(190)}@example.com`
]) {
test(`Contact Us confirmation rejects invalid email ${String(contactEmail).slice(0, 30)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: {
...validContactConfirmationRequest().body,
contactEmail
}
}),
status: 400,
code: "INVALID_CONTACT_EMAIL"
});
});
}
for (const typeOfContact of [undefined, "unsupported"]) {
test(`Contact Us confirmation rejects category ${String(typeOfContact)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: {
...validContactConfirmationRequest().body,
typeOfContact
}
}),
status: 400,
code: "INVALID_CONTACT_TYPE"
});
});
}
for (const contactbody of [undefined, " ", "a".repeat(801)]) {
test(`Contact Us confirmation rejects message ${String(contactbody).slice(0, 20)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: { ...validContactConfirmationRequest().body, contactbody }
}),
status: 400,
code: "INVALID_CONTACT_BODY"
});
});
}
test("Contact Us confirmation rejects a subject not matching its category", async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: {
...validContactConfirmationRequest().body,
contactSubject: contactSubjects.plq.en
}
}),
status: 400,
code: "INVALID_CONTACT_SUBJECT"
});
});
for (const language of [undefined, "CY", "fr", ""]) {
test(`Contact Us confirmation rejects language ${String(language)}`, async () => {
await assertContactConfirmationRejected({
req: validContactConfirmationRequest({
body: { ...validContactConfirmationRequest().body, language }
}),
status: 400,
code: "INVALID_LANGUAGE"
});
});
}
test("Contact Us confirmation selects English template and exact safe personalisation", async () => {
await withContactOrigins(async () => {
const loaded = loadContactConfirmationEndpoint();
const req = validContactConfirmationRequest({
body: {
...validContactConfirmationRequest().body,
contactbody:
"Please review (https://example.invalid/path?q=1#part), then email applicant@example.com about APP/123456."
}
});
const originalBody = JSON.parse(JSON.stringify(req.body));
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(loaded.notifyCalls.length, 1);
assert.strictEqual(
loaded.notifyCalls[0][0],
contactConfirmationTemplateEn
);
assert.strictEqual(loaded.notifyCalls[0][1], "applicant@example.com");
assert.deepStrictEqual(
JSON.parse(JSON.stringify(loaded.notifyCalls[0][2])),
{
personalisation: {
contactEmail: "applicant@example.com",
contactSubject: contactSubjects.ipq.en,
contactbody:
"Please review ([web link removed]), then email applicant@example.com about APP/123456.",
typeOfContact: "ipq"
},
reference: "PEDW-CONTACT-US"
}
);
assert.deepStrictEqual(req.body, originalBody);
});
});
test("Contact Us confirmation selects Welsh template and supports an 800-character message", async () => {
await withContactOrigins(async () => {
const loaded = loadContactConfirmationEndpoint();
const message = "a".repeat(800);
const req = validContactConfirmationRequest({
body: {
contactEmail: "applicant@example.com",
contactSubject: contactSubjects.plq.cy,
contactbody: message,
typeOfContact: "plq",
language: "cy"
}
});
const res = createRes();
await loaded.mod.default(req, res);
assert.strictEqual(res.state.statusCode, 200);
assert.strictEqual(
loaded.notifyCalls[0][0],
contactConfirmationTemplateCy
);
assert.strictEqual(
loaded.notifyCalls[0][2].personalisation.contactbody,
message
);
});
});
test("Contact Us confirmation returns a bounded error when Notify fails", async () => {
await withContactOrigins(async () => {
let sendCalls = 0;
const loaded = loadContactConfirmationEndpoint({
sendEmail: async () => {
sendCalls += 1;
throw new Error("notify failed");
}
});
const res = createRes();
await loaded.mod.default(validContactConfirmationRequest(), res);
assert.strictEqual(sendCalls, 1);
assert.strictEqual(res.state.statusCode, 400);
assert.strictEqual(
res.state.jsonBody.error.code,
"CONTACT_US_CONFIRMATION_FAILED"
);
});
});
const 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) {
+458 -28
View File
@@ -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 () => {
+399 -37
View File
@@ -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",
@@ -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) {
+175 -1
View File
@@ -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();
+318
View File
@@ -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;