diff --git a/pages/api/file/createcase_api.js b/pages/api/file/createcase_api.js index 95360e26..1b3d8c4b 100644 --- a/pages/api/file/createcase_api.js +++ b/pages/api/file/createcase_api.js @@ -9,30 +9,59 @@ import { getTempCaseRef } from "../../../components/utils"; import { v4 as uuidv4 } from "uuid"; import { hashAPIPath } from "../../../actions/core/hash"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; const WEBAPI_URL = process.env.RELAY_ROOT || "https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/"; +const hasValue = (value) => + typeof value === "string" && value.trim().length > 0; + +const isValidContainer = (value) => + typeof value === "string" && + value.length >= 3 && + value.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value); + +const isValidDraftFolder = (value) => + typeof value === "string" && + value.length <= 100 && + !value.includes("..") && + /^[A-Za-z0-9](?:[A-Za-z0-9/-]*[A-Za-z0-9])?$/.test(value); + +const forbidden = (res) => + respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + export default async function ApiProxy(req, res) { - var createCaseBody = req.body; + let createCaseBody; + try { + createCaseBody = + typeof req.body === "string" ? JSON.parse(req.body) : req.body; + } catch (_error) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + var contactid = req.query.contactid; var appealTypeId = req.query.appealTypeId; var containerName = req.query.containername; var lpaID = req.query.lpaID; var queryUrl = "incidents"; - var token = await getToken(); - var tempCaseRef = getTempCaseRef(); if ( - typeof contactid === "undefined" || - contactid.length === 0 || - typeof appealTypeId === "undefined" || - appealTypeId.length === 0 || - typeof containerName === "undefined" || - containerName.length === 0 || - typeof lpaID === "undefined" || - lpaID.length === 0 + !hasValue(contactid) || + !hasValue(appealTypeId) || + !hasValue(containerName) || + !hasValue(lpaID) ) { return respondError(res, { status: 400, @@ -42,6 +71,46 @@ export default async function ApiProxy(req, res) { }); } + if ( + !createCaseBody || + typeof createCaseBody !== "object" || + Array.isArray(createCaseBody) || + !isValidContainer(containerName) || + ["container", "containerID", "containername"].some( + (key) => Object.prototype.hasOwnProperty.call(createCaseBody, key) + ) || + ["container", "containerID"].some((key) => + Object.prototype.hasOwnProperty.call(req.query, key) + ) + ) { + 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" || + session.user.id.length === 0 || + session.user.id !== containerName + ) { + return forbidden(res); + } + + var tempCaseRef = getTempCaseRef(); + + if (!isValidDraftFolder(tempCaseRef)) { + return respondError(res, { + status: 400, + code: "INVALID_REQUEST", + message: "Invalid request" + }); + } + + var token = await getToken(); + var data = { "title": tempCaseRef, "ticketnumber": tempCaseRef, diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index cb195bab..3f3a18c3 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -5282,6 +5282,264 @@ test("generateappealpdf catch path returns GENERATE_APPEAL_PDF_FAILED for an aut assert.strictEqual(loaded.calls.create.length, 0); }); +const CASE_OWNER_CONTAINER = "case-owner-container"; +const GENERATED_CASE_REFERENCE = "TMP-CASE-04E"; + +const loadCreateCase = (overrides = {}) => { + const calls = { + session: [], + options: [], + token: 0, + reference: 0, + caseBlob: [], + appealBlob: [] + }; + const options = { id: "create-case-options" }; + const mod = loadModule("pages/api/file/createcase_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return { user: { id: CASE_OWNER_CONTAINER } }; + }, + getToken: async () => { + calls.token += 1; + return { access_token: "synthetic-token" }; + }, + getTempCaseRef: () => { + calls.reference += 1; + return GENERATED_CASE_REFERENCE; + }, + uuidv4: () => "00000000-0000-4000-8000-00000000004e", + getCaseBlob: async (...args) => { + calls.caseBlob.push(args); + return `${GENERATED_CASE_REFERENCE}/${GENERATED_CASE_REFERENCE}_case.json`; + }, + createBlob: async (...args) => { + calls.appealBlob.push(args); + return GENERATED_CASE_REFERENCE; + }, + _: { isEmpty: (value) => !value || Object.keys(value).length === 0 }, + ...overrides + }); + + return { mod, calls, options }; +}; + +const createCaseRequest = (overrides = {}) => ({ + method: "POST", + query: { + contactid: "00000000-0000-4000-8000-000000000001", + appealTypeId: "846040000", + containername: CASE_OWNER_CONTAINER, + lpaID: "00000000-0000-4000-8000-000000000002" + }, + body: { + pinswg_lpaname: "Synthetic LPA", + createdon: "2026-11-04T00:00:00.000Z" + }, + ...overrides +}); + +const assertNoCreateCaseSideEffects = (calls) => { + assert.strictEqual(calls.token, 0); + assert.strictEqual(calls.reference, 0); + assert.strictEqual(calls.caseBlob.length, 0); + assert.strictEqual(calls.appealBlob.length, 0); +}; + +for (const sessionCase of [ + { name: "missing session", session: null }, + { name: "missing session user ID", session: { user: {} } }, + { name: "empty session user ID", session: { user: { id: "" } } }, + { + name: "non-string session user ID", + session: { user: { id: 123 } } + } +]) { + test(`createcase_api rejects ${sessionCase.name} before private draft creation`, async () => { + const loaded = loadCreateCase({ + getServerSession: async () => sessionCase.session + }); + const res = createRes(); + + await loaded.mod.default(createCaseRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoCreateCaseSideEffects(loaded.calls); + }); +} + +test("createcase_api rejects a foreign destination container before private draft creation", async () => { + const loaded = loadCreateCase({ + getServerSession: async () => ({ user: { id: "different-container" } }) + }); + const res = createRes(); + + await loaded.mod.default(createCaseRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoCreateCaseSideEffects(loaded.calls); +}); + +test("createcase_api does not trust a caller-supplied identity", async () => { + const loaded = loadCreateCase({ + getServerSession: async () => ({ user: { id: "different-container" } }) + }); + const req = createCaseRequest(); + req.body.userID = CASE_OWNER_CONTAINER; + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 403); + assertNoCreateCaseSideEffects(loaded.calls); +}); + +test("createcase_api passes req, res and request-scoped options to getServerSession", async () => { + const loaded = loadCreateCase(); + const req = createCaseRequest(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.deepStrictEqual(loaded.calls.options, [[req, res]]); + assert.deepStrictEqual(loaded.calls.session, [[req, res, loaded.options]]); +}); + +for (const containerCase of [ + { name: "missing", value: undefined }, + { name: "invalid", value: "Invalid_Container" }, + { name: "array-valued", value: [CASE_OWNER_CONTAINER] } +]) { + test(`createcase_api rejects a ${containerCase.name} container before private draft creation`, async () => { + const loaded = loadCreateCase(); + const req = createCaseRequest(); + if (typeof containerCase.value === "undefined") { + delete req.query.containername; + } else { + req.query.containername = containerCase.value; + } + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assertNoCreateCaseSideEffects(loaded.calls); + }); +} + +test("createcase_api rejects malformed JSON before private draft creation", async () => { + const loaded = loadCreateCase(); + const res = createRes(); + + await loaded.mod.default(createCaseRequest({ body: "{" }), res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST"); + assertNoCreateCaseSideEffects(loaded.calls); +}); + +test("createcase_api rejects ambiguous destination identifiers before private draft creation", async () => { + for (const request of [ + createCaseRequest({ + query: { + ...createCaseRequest().query, + containerID: CASE_OWNER_CONTAINER + } + }), + createCaseRequest({ + body: { + ...createCaseRequest().body, + containername: CASE_OWNER_CONTAINER + } + }) + ]) { + const loaded = loadCreateCase(); + const res = createRes(); + + await loaded.mod.default(request, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST"); + assertNoCreateCaseSideEffects(loaded.calls); + } +}); + +test("createcase_api rejects an invalid generated folder before token acquisition and Blob writes", async () => { + const loaded = loadCreateCase({ getTempCaseRef: () => "../foreign-case" }); + const res = createRes(); + + await loaded.mod.default(createCaseRequest(), res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST"); + assert.strictEqual(loaded.calls.token, 0); + assert.strictEqual(loaded.calls.caseBlob.length, 0); + assert.strictEqual(loaded.calls.appealBlob.length, 0); +}); + +test("createcase_api creates both draft JSON blobs in the authenticated owner's container", async () => { + const loaded = loadCreateCase(); + const req = createCaseRequest(); + const res = createRes(); + + await loaded.mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(loaded.calls.token, 1); + assert.strictEqual(loaded.calls.reference, 1); + assert.strictEqual(loaded.calls.caseBlob.length, 1); + assert.strictEqual(loaded.calls.appealBlob.length, 1); + assert.strictEqual(loaded.calls.caseBlob[0][0], CASE_OWNER_CONTAINER); + assert.strictEqual( + loaded.calls.caseBlob[0][1], + GENERATED_CASE_REFERENCE + ); + assert.strictEqual(loaded.calls.appealBlob[0][1], CASE_OWNER_CONTAINER); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(loaded.calls.caseBlob[0][2])), + JSON.parse(JSON.stringify(res.state.jsonBody)) + ); + assert.deepStrictEqual(JSON.parse(loaded.calls.appealBlob[0][0]), { + pinswg_name: GENERATED_CASE_REFERENCE, + pinswg_appealcasetype: "846040000", + _pinswg_associatedlpa_value: + "00000000-0000-4000-8000-000000000002" + }); + assert.strictEqual(res.state.jsonBody.ticketnumber, GENERATED_CASE_REFERENCE); + assert.strictEqual(res.state.jsonBody.title, GENERATED_CASE_REFERENCE); + assert.strictEqual( + res.state.jsonBody.incidentid, + "00000000-0000-4000-8000-00000000004e" + ); +}); + +test("createcase_api preserves the existing propagated token dependency failure for an authorised owner", async () => { + const loaded = loadCreateCase({ + getToken: async () => { + loaded.calls.token += 1; + throw new Error("token failure"); + } + }); + const res = createRes(); + + await assert.rejects( + loaded.mod.default(createCaseRequest(), res), + /token failure/ + ); + + assert.strictEqual(loaded.calls.token, 1); + assert.strictEqual(loaded.calls.caseBlob.length, 0); + assert.strictEqual(loaded.calls.appealBlob.length, 0); +}); + const run = async () => { let passed = 0; for (const currentTest of tests) {