diff --git a/pages/api/endpoint/gethash_api.js b/pages/api/endpoint/gethash_api.js index f0ef869e..c7fc3714 100644 --- a/pages/api/endpoint/gethash_api.js +++ b/pages/api/endpoint/gethash_api.js @@ -1,12 +1,25 @@ import { hashAPIPath } from "../../../actions/core/hash"; import nextConnect from "next-connect"; -import { getSession } from "next-auth/react"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; import { respondError, respondSuccess } from "../middleware/apiResponse"; const ApiProxy = nextConnect(); +const isValidContainer = (value) => + typeof value === "string" && + value.length >= 3 && + value.length <= 63 && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value); + +const isValidTempCaseRef = (value) => + typeof value === "string" && + value.length <= 100 && + !value.includes("..") && + /^[A-Za-z0-9](?:[A-Za-z0-9/-]*[A-Za-z0-9])?$/.test(value); + ApiProxy.get(async (req, res) => { - const session = await getSession({ req }); + const session = await getServerSession(req, res, authOptions(req, res)); if (!session) { return respondError(res, { @@ -51,6 +64,59 @@ ApiProxy.get(async (req, res) => { }); } + if ( + queryPath.startsWith("/api/file/createappealcompletemessage_api") && + queryPath !== "/api/file/createappealcompletemessage_api" + ) { + return respondError(res, { + status: 400, + code: "INVALID_HASH_PATH", + message: "Invalid path for hash generation" + }); + } + + if (queryPath === "/api/file/createappealcompletemessage_api") { + let completionPath; + + try { + completionPath = new URL(rawQueryPath, "http://localhost"); + } catch (_error) { + return respondError(res, { + status: 400, + code: "INVALID_HASH_PATH", + message: "Invalid path for hash generation" + }); + } + + const containerName = completionPath.searchParams.get("container"); + const tempCaseRef = completionPath.searchParams.get("tempcaseref"); + + if ( + !isValidContainer(containerName) || + !isValidTempCaseRef(tempCaseRef) || + completionPath.searchParams.getAll("container").length !== 1 || + completionPath.searchParams.getAll("tempcaseref").length !== 1 + ) { + return respondError(res, { + status: 400, + code: "INVALID_HASH_PATH", + message: "Invalid path for hash generation" + }); + } + + if ( + typeof session?.user?.id !== "string" || + session.user.id.length === 0 || + session.user.id !== containerName + ) { + return respondError(res, { + status: 403, + code: "FORBIDDEN", + message: "Request not permitted" + }); + } + } + return respondSuccess(res, { hash: hashAPIPath(rawQueryPath) }); }); diff --git a/pages/api/file/createappealcompletemessage_api.js b/pages/api/file/createappealcompletemessage_api.js index 17c43b1e..ebe4e59e 100644 --- a/pages/api/file/createappealcompletemessage_api.js +++ b/pages/api/file/createappealcompletemessage_api.js @@ -13,10 +13,34 @@ import _ from "lodash"; import nextConnect from "next-connect"; import middleware from "../middleware/middleware"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; const ApiProxy = nextConnect(); ApiProxy.use(middleware); +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 isValidTempCaseRef = (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" + }); + ApiProxy.get(async (req, res) => { const containerName = req.query.container; const tempCaseRef = req.query.tempcaseref; @@ -24,12 +48,9 @@ ApiProxy.get(async (req, res) => { const checkHash = req.query.hash; if ( - typeof containerName === "undefined" || - containerName.length === 0 || - typeof tempCaseRef === "undefined" || - tempCaseRef.length === 0 || - typeof checkHash === "undefined" || - checkHash.length === 0 + !hasValue(containerName) || + !hasValue(tempCaseRef) || + !hasValue(checkHash) ) { return respondError(res, { status: 400, @@ -38,6 +59,26 @@ ApiProxy.get(async (req, res) => { }); } + if ( + !isValidContainer(containerName) || + !isValidTempCaseRef(tempCaseRef) + ) { + 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); + } + const hashCandidatePaths = [ "/api/file/createappealcompletemessage_api?container=" + containerName + diff --git a/pages/api/file/createappealcompletemessageproxy_api.js b/pages/api/file/createappealcompletemessageproxy_api.js index 81d3c443..0e89180e 100644 --- a/pages/api/file/createappealcompletemessageproxy_api.js +++ b/pages/api/file/createappealcompletemessageproxy_api.js @@ -4,6 +4,8 @@ import { azureHeaders } from "../../../actions/core/headers"; import { consoleLogger } from "../../../actions/core/logger"; import axios from "axios"; import { respondError, respondSuccess } from "../middleware/apiResponse"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; import nextConnect from "next-connect"; import middleware from "../middleware/middleware"; @@ -16,6 +18,25 @@ const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`; 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 isValidTempCaseRef = (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" + }); + ApiProxy.get(async (req, res) => { const containerName = req.query.container; const tempCaseRef = req.query.tempcaseref; @@ -28,6 +49,26 @@ ApiProxy.get(async (req, res) => { }); } + if ( + !isValidContainer(containerName) || + !isValidTempCaseRef(tempCaseRef) + ) { + 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); + } + const token = await getToken(); const queryUrl = @@ -37,9 +78,17 @@ ApiProxy.get(async (req, res) => { encodeURIComponent(tempCaseRef); try { + const requestOptions = azureHeaders(token.access_token); + if (typeof req.headers?.cookie === "string") { + requestOptions.headers = { + ...requestOptions.headers, + cookie: req.headers.cookie + }; + } + const { data } = await axios.get( BASE_URL + queryUrl + hashAPIPath(queryUrl), - azureHeaders(token.access_token) + requestOptions ); return respondSuccess(res, data); } catch (error) { diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index c3ba9cac..83f86e28 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -1628,7 +1628,8 @@ test("general hash endpoint does not authorize getrepsblob signing", async () => let hashCalls = 0; const mod = loadModule("pages/api/endpoint/gethash_api.js", { nextConnect: createNextConnectMock(), - getSession: async () => ({ user: { id: "user-container" } }), + getServerSession: async () => ({ user: { id: "user-container" } }), + authOptions: () => ({}), hashAPIPath: () => { hashCalls += 1; return "&hash=unexpected"; @@ -1652,6 +1653,146 @@ test("general hash endpoint does not authorize getrepsblob signing", async () => assert.strictEqual(hashCalls, 0); }); +const loadAppealCompletionSigning = ({ session, ...overrides } = {}) => { + const calls = { options: [], session: [], hash: [] }; + const options = { id: "appeal-completion-signing-options" }; + const resolvedSession = + typeof session === "undefined" + ? { user: { id: "owner-container" } } + : session; + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + nextConnect: createNextConnectMock(), + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return resolvedSession; + }, + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=completion-capability"; + }, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + URL, + ...overrides + }); + + return { mod, calls, options }; +}; + +const appealCompletionSigningRequest = (pathValue) => ({ + query: { + path: + pathValue || + "/api/file/createappealcompletemessage_api?container=owner-container&tempcaseref=TMP-CASE-1" + } +}); + +for (const sessionCase of [ + { name: "missing session", session: null, status: 401, code: "UNAUTHENTICATED" }, + { name: "session without user id", session: { user: {} } }, + { name: "session with empty user id", session: { user: { id: "" } } }, + { + name: "session with non-string user id", + session: { user: { id: 123 } } + }, + { + name: "foreign-container session", + session: { user: { id: "foreign-container" } } + } +]) { + test(`gethash rejects appeal completion signing for ${sessionCase.name} before HMAC generation`, async () => { + const loaded = loadAppealCompletionSigning({ session: sessionCase.session }); + const res = createRes(); + + await loaded.mod.default.handler(appealCompletionSigningRequest(), res); + + assert.strictEqual(res.state.statusCode, sessionCase.status || 403); + assert.strictEqual( + res.state.jsonBody.error.code, + sessionCase.code || "FORBIDDEN" + ); + assert.strictEqual(loaded.calls.hash.length, 0); + }); +} + +for (const pathCase of [ + { + name: "missing container", + path: "/api/file/createappealcompletemessage_api?tempcaseref=TMP-CASE-1" + }, + { + name: "invalid container", + path: "/api/file/createappealcompletemessage_api?container=Invalid_Container&tempcaseref=TMP-CASE-1" + }, + { + name: "missing draft reference", + path: "/api/file/createappealcompletemessage_api?container=owner-container" + }, + { + name: "malformed draft reference", + path: "/api/file/createappealcompletemessage_api?container=owner-container&tempcaseref=..%2Fforeign" + }, + { + name: "duplicate container binding", + path: "/api/file/createappealcompletemessage_api?container=owner-container&container=foreign-container&tempcaseref=TMP-CASE-1" + }, + { + name: "duplicate draft binding", + path: "/api/file/createappealcompletemessage_api?container=owner-container&tempcaseref=TMP-CASE-1&tempcaseref=TMP-CASE-2" + }, + { + name: "completion route lookalike", + path: "/api/file/createappealcompletemessage_api-foreign?container=owner-container&tempcaseref=TMP-CASE-1" + } +]) { + test(`gethash rejects appeal completion signing with ${pathCase.name} before HMAC generation`, async () => { + const loaded = loadAppealCompletionSigning(); + const res = createRes(); + + await loaded.mod.default.handler( + appealCompletionSigningRequest(pathCase.path), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH_PATH"); + assert.strictEqual(loaded.calls.hash.length, 0); + }); +} + +test("gethash issues the existing appeal completion capability only to the owning session", async () => { + const loaded = loadAppealCompletionSigning(); + const req = appealCompletionSigningRequest(); + const res = createRes(); + + await loaded.mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(loaded.calls.options, [[req, res]]); + assert.deepStrictEqual(loaded.calls.session, [[req, res, loaded.options]]); + assert.deepStrictEqual(loaded.calls.hash, [req.query.path]); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + hash: "&hash=completion-capability" + }); +}); + +test("gethash preserves unrelated allow-listed signing without applying completion ownership", async () => { + const loaded = loadAppealCompletionSigning({ + session: { user: { email: "user@test.local" } } + }); + const req = appealCompletionSigningRequest("/api/file/upload"); + const res = createRes(); + + await loaded.mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(loaded.calls.hash, [req.query.path]); +}); + test("getrepsblob handler dependency failure returns GET_REPS_BLOB_FAILED", async () => { const mod = loadModule("pages/api/file/getrepsblob.js", { nextConnect: createNextConnectMock(), @@ -3216,12 +3357,488 @@ test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING assert.strictEqual(loaded.calls.axios.length, 1); }); +const loadAppealCompletionProxy = ({ session, ...overrides } = {}) => { + const calls = { + options: [], + session: [], + token: 0, + hash: [], + headers: [], + axios: [] + }; + const options = { id: "appeal-completion-proxy-options" }; + const payload = { status: "success" }; + const resolvedSession = + typeof session === "undefined" + ? { user: { id: "owner-container" } } + : session; + const mod = loadModule( + "pages/api/file/createappealcompletemessageproxy_api.js", + { + nextConnect: createNextConnectMock(), + middleware: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return resolvedSession; + }, + getToken: async () => { + calls.token += 1; + return { access_token: "synthetic-token" }; + }, + azureHeaders: (token) => { + calls.headers.push(token); + return { headers: { Authorization: `Bearer ${token}` } }; + }, + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=synthetic-hash"; + }, + consoleLogger: () => {}, + process: { env: { API_ROOT: "http://example.com" } }, + axios: { + get: async (...args) => { + calls.axios.push(args); + return { data: payload }; + } + }, + ...overrides + } + ); + + return { mod, calls, options, payload }; +}; + +const appealCompletionProxyRequest = (overrides = {}) => ({ + query: { + container: "owner-container", + tempcaseref: "TMP-CASE-1" + }, + headers: { cookie: "next-auth.session-token=synthetic-session" }, + ...overrides +}); + +const assertNoAppealCompletionProxyWork = (calls) => { + assert.strictEqual(calls.token, 0); + assert.strictEqual(calls.hash.length, 0); + assert.strictEqual(calls.headers.length, 0); + assert.strictEqual(calls.axios.length, 0); +}; + +for (const sessionCase of [ + { name: "missing session", session: null }, + { name: "session without user id", session: { user: {} } }, + { name: "session with empty user id", session: { user: { id: "" } } }, + { + name: "session with non-string user id", + session: { user: { id: 123 } } + }, + { + name: "cross-container session", + session: { user: { id: "foreign-container" } } + } +]) { + test(`createappealcompletemessageproxy rejects ${sessionCase.name} before token, signing or submission`, async () => { + const loaded = loadAppealCompletionProxy({ + session: sessionCase.session + }); + const res = createRes(); + + await loaded.mod.default.handler(appealCompletionProxyRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assertNoAppealCompletionProxyWork(loaded.calls); + }); +} + +for (const inputCase of [ + { + name: "invalid container", + query: { container: "Invalid_Container", tempcaseref: "TMP-CASE-1" } + }, + { + name: "missing temporary case reference", + query: { container: "owner-container" }, + code: "MISSING_REQUIRED_QUERY" + }, + { + name: "malformed temporary case reference", + query: { container: "owner-container", tempcaseref: "../foreign" } + }, + { + name: "array temporary case reference", + query: { container: "owner-container", tempcaseref: ["TMP-CASE-1"] }, + code: "MISSING_REQUIRED_QUERY" + } +]) { + test(`createappealcompletemessageproxy rejects ${inputCase.name} before authentication or submission`, async () => { + const loaded = loadAppealCompletionProxy(); + const res = createRes(); + + await loaded.mod.default.handler( + appealCompletionProxyRequest({ query: inputCase.query }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + inputCase.code || "INVALID_REQUEST" + ); + assert.strictEqual(loaded.calls.options.length, 0); + assert.strictEqual(loaded.calls.session.length, 0); + assertNoAppealCompletionProxyWork(loaded.calls); + }); +} + +test("createappealcompletemessageproxy preserves owner submission and forwards the authenticated cookie", async () => { + const loaded = loadAppealCompletionProxy(); + const req = appealCompletionProxyRequest(); + const res = createRes(); + + await loaded.mod.default.handler(req, res); + + const expectedPath = + "/api/file/createappealcompletemessage_api?container=owner-container&tempcaseref=TMP-CASE-1"; + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(loaded.calls.options, [[req, res]]); + assert.deepStrictEqual(loaded.calls.session, [[req, res, loaded.options]]); + assert.strictEqual(loaded.calls.token, 1); + assert.deepStrictEqual(loaded.calls.hash, [expectedPath]); + assert.deepStrictEqual(loaded.calls.headers, ["synthetic-token"]); + assert.strictEqual(loaded.calls.axios.length, 1); + assert.strictEqual( + loaded.calls.axios[0][0], + `http://example.com${expectedPath}&hash=synthetic-hash` + ); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(loaded.calls.axios[0][1])), + { + headers: { + Authorization: "Bearer synthetic-token", + cookie: "next-auth.session-token=synthetic-session" + } + } + ); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(res.state.jsonBody)), + loaded.payload + ); +}); + +const loadAppealCompletionDirect = ({ session, ...overrides } = {}) => { + const calls = { + options: [], + session: [], + hash: [], + progress: [], + download: [], + createBlob: [], + caseBlob: [], + account: [], + queue: [] + }; + const options = { id: "appeal-completion-direct-options" }; + const resolvedSession = + typeof session === "undefined" + ? { user: { id: "owner-container" } } + : session; + const progress = { + pinswg_siteaddressline1: "Line 1", + pinswg_siteaddressline2: "", + pinswg_siteaddresstown: "Town", + pinswg_siteaddresscounty: "County", + pinswg_siteaddresspostcode: "CF1 1AA", + pinswg_lpaapplicationreference: "LPA-1", + pinswg_developmentdescription: "Description", + pinswg_name: "draft-name" + }; + const caseObject = { + "customerid_contact@odata.bind": + "/contacts(00000000-0000-0000-0000-000000000001)" + }; + const mod = loadModule("pages/api/file/createappealcompletemessage_api.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + authOptions: (req, res) => { + calls.options.push([req, res]); + return options; + }, + getServerSession: async (...args) => { + calls.session.push(args); + return resolvedSession; + }, + hashAPIPath: (queryPath) => { + calls.hash.push(queryPath); + return "&hash=expected"; + }, + getProgressBlobs: async (...args) => { + calls.progress.push(args); + return { path: "TMP-CASE-1/progress.json" }; + }, + downloadProgressFile: async (...args) => { + calls.download.push(args); + return args[1].endsWith("_case.json") + ? { ...caseObject } + : { ...progress }; + }, + createBlob: async (...args) => { + calls.createBlob.push(args); + return {}; + }, + getCaseBlob: async (...args) => { + calls.caseBlob.push(args); + return {}; + }, + updateAccount: async (...args) => { + calls.account.push(args); + return {}; + }, + createCaseCompleteMessage: async (...args) => { + calls.queue.push(args); + return {}; + }, + consoleLogger: () => {}, + _: { isEmpty: (value) => value === undefined || value === null || value === "" }, + ...overrides + }); + + return { mod, calls, options }; +}; + +const appealCompletionDirectRequest = (overrides = {}) => ({ + query: { + container: "owner-container", + tempcaseref: "TMP-CASE-1", + inv: "846040001", + hash: "expected" + }, + ...overrides +}); + +const assertNoAppealCompletionEffects = (calls) => { + assert.strictEqual(calls.progress.length, 0); + assert.strictEqual(calls.download.length, 0); + assert.strictEqual(calls.createBlob.length, 0); + assert.strictEqual(calls.caseBlob.length, 0); + assert.strictEqual(calls.account.length, 0); + assert.strictEqual(calls.queue.length, 0); +}; + +for (const sessionCase of [ + { name: "missing session", session: null }, + { name: "session without user id", session: { user: {} } }, + { name: "session with empty user id", session: { user: { id: "" } } }, + { + name: "session with non-string user id", + session: { user: { id: 123 } } + }, + { + name: "cross-container session", + session: { user: { id: "foreign-container" } } + } +]) { + test(`createappealcompletemessage_api blocks direct bypass with ${sessionCase.name} before HMAC or side effects`, async () => { + const loaded = loadAppealCompletionDirect({ session: sessionCase.session }); + const res = createRes(); + + await loaded.mod.default.handler(appealCompletionDirectRequest(), res); + + assert.strictEqual(res.state.statusCode, 403); + assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); + assert.strictEqual(loaded.calls.hash.length, 0); + assertNoAppealCompletionEffects(loaded.calls); + }); +} + +for (const inputCase of [ + { + name: "invalid container", + query: { + container: "Invalid_Container", + tempcaseref: "TMP-CASE-1", + hash: "expected" + } + }, + { + name: "missing temporary case reference", + query: { container: "owner-container", hash: "expected" }, + code: "MISSING_REQUIRED_QUERY" + }, + { + name: "malformed temporary case reference", + query: { + container: "owner-container", + tempcaseref: "../foreign", + hash: "expected" + } + } +]) { + test(`createappealcompletemessage_api rejects ${inputCase.name} before authentication or side effects`, async () => { + const loaded = loadAppealCompletionDirect(); + const res = createRes(); + + await loaded.mod.default.handler( + appealCompletionDirectRequest({ query: inputCase.query }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + inputCase.code || "INVALID_REQUEST" + ); + assert.strictEqual(loaded.calls.options.length, 0); + assert.strictEqual(loaded.calls.session.length, 0); + assert.strictEqual(loaded.calls.hash.length, 0); + assertNoAppealCompletionEffects(loaded.calls); + }); +} + +for (const hashCase of [ + { name: "missing HMAC", hash: undefined }, + { name: "invalid HMAC", hash: "wrong" } +]) { + test(`createappealcompletemessage_api rejects ${hashCase.name} after owner authentication and before side effects`, async () => { + const loaded = loadAppealCompletionDirect(); + const request = appealCompletionDirectRequest(); + const res = createRes(); + + if (typeof hashCase.hash === "undefined") delete request.query.hash; + else request.query.hash = hashCase.hash; + await loaded.mod.default.handler(request, res); + + assert.strictEqual(res.state.statusCode, 400); + assertNoAppealCompletionEffects(loaded.calls); + }); +} + +for (const resourceCase of [ + { + name: "another container", + query: { + container: "other-container", + tempcaseref: "TMP-CASE-1", + inv: "846040001", + hash: "expected" + }, + session: { user: { id: "other-container" } } + }, + { + name: "another draft folder", + query: { + container: "owner-container", + tempcaseref: "TMP-CASE-2", + inv: "846040001", + hash: "expected" + } + } +]) { + test(`createappealcompletemessage_api rejects an HMAC issued for ${resourceCase.name}`, async () => { + const loaded = loadAppealCompletionDirect({ + session: resourceCase.session, + hashAPIPath: (queryPath) => { + loaded.calls.hash.push(queryPath); + return queryPath === + "/api/file/createappealcompletemessage_api?container=owner-container&tempcaseref=TMP-CASE-1" + ? "&hash=expected" + : "&hash=other"; + } + }); + const res = createRes(); + + await loaded.mod.default.handler( + appealCompletionDirectRequest({ query: resourceCase.query }), + res + ); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); + assertNoAppealCompletionEffects(loaded.calls); + }); +} + +test("createappealcompletemessage_api rejects a capability for another operation", async () => { + const loaded = loadAppealCompletionDirect({ + hashAPIPath: (queryPath) => { + loaded.calls.hash.push(queryPath); + return queryPath.startsWith("/api/file/deleteblob?") + ? "&hash=expected" + : "&hash=other"; + } + }); + const res = createRes(); + + await loaded.mod.default.handler(appealCompletionDirectRequest(), res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); + assertNoAppealCompletionEffects(loaded.calls); +}); + +test("createappealcompletemessage_api preserves owner payloads and downstream completion calls", async () => { + const loaded = loadAppealCompletionDirect(); + const req = appealCompletionDirectRequest(); + const res = createRes(); + + await loaded.mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(loaded.calls.options, [[req, res]]); + assert.deepStrictEqual(loaded.calls.session, [[req, res, loaded.options]]); + assert.deepStrictEqual(loaded.calls.progress, [ + ["owner-container", "TMP-CASE-1"] + ]); + assert.deepStrictEqual(loaded.calls.download, [ + ["owner-container", "TMP-CASE-1/progress.json", "TMP-CASE-1"], + [ + "owner-container", + "TMP-CASE-1/TMP-CASE-1_case.json", + "TMP-CASE-1" + ] + ]); + const savedProgress = JSON.parse(loaded.calls.createBlob[0][0]); + assert.strictEqual(savedProgress.appealComplete, "TMP-CASE-1"); + assert.strictEqual(Object.hasOwn(savedProgress, "pinswg_name"), false); + assert.deepStrictEqual(loaded.calls.createBlob[0].slice(1), [ + "owner-container", + "TMP-CASE-1" + ]); + assert.strictEqual(loaded.calls.caseBlob.length, 1); + assert.deepStrictEqual( + JSON.parse(JSON.stringify(loaded.calls.account)), + [ + [ + "00000000-0000-0000-0000-000000000001", + { pinswg_typeofinvolvement: 846040001 }, + true + ] + ] + ); + assert.deepStrictEqual(loaded.calls.queue, [ + ["owner-container", "TMP-CASE-1"] + ]); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + status: "success" + }); +}); + test("createappealcompletemessageproxy dependency failure returns CREATE_APPEAL_COMPLETE_MESSAGE_PROXY_FAILED", async () => { const mod = loadModule( "pages/api/file/createappealcompletemessageproxy_api.js", { nextConnect: createNextConnectMock(), middleware: () => {}, + getServerSession: async () => ({ user: { id: "container-1" } }), + authOptions: () => ({}), respondError: respondErrorMock, respondSuccess: respondSuccessMock, getToken: async () => ({ access_token: "tok" }), @@ -3238,7 +3855,8 @@ test("createappealcompletemessageproxy dependency failure returns CREATE_APPEAL_ ); const req = { - query: { container: "c1", tempcaseref: "TMP1" } + query: { container: "container-1", tempcaseref: "TMP1" }, + headers: { cookie: "next-auth.session-token=synthetic" } }; const res = createRes(); await mod.default.handler(req, res); @@ -3256,6 +3874,8 @@ test("createappealcompletemessage_api accepts encoded hash variant and returns s { nextConnect: createNextConnectMock(), middleware: () => {}, + getServerSession: async () => ({ user: { id: "container-1" } }), + authOptions: () => ({}), respondError: respondErrorMock, respondSuccess: respondSuccessMock, hashAPIPath: (queryPath) => @@ -3293,7 +3913,7 @@ test("createappealcompletemessage_api accepts encoded hash variant and returns s const req = { query: { - container: "cont/1", + container: "container-1", tempcaseref: "TMP/1", inv: "846040001", hash: "expected" @@ -3314,6 +3934,8 @@ test("createappealcompletemessage_api dependency failure returns CREATE_APPEAL_C { nextConnect: createNextConnectMock(), middleware: () => {}, + getServerSession: async () => ({ user: { id: "owner-container" } }), + authOptions: () => ({}), respondError: respondErrorMock, respondSuccess: respondSuccessMock, hashAPIPath: () => "&hash=expected", @@ -3327,7 +3949,7 @@ test("createappealcompletemessage_api dependency failure returns CREATE_APPEAL_C const req = { query: { - container: "c1", + container: "owner-container", tempcaseref: "TMP1", hash: "expected" }