Merged PR 2677: Feature/ithc mitigation final
Related work items: #25509, #25515, #25516
This commit is contained in:
+35
-6
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user