diff --git a/actions/services/documentDirectService.js b/actions/services/documentDirectService.js index d0afabb8..4934576a 100644 --- a/actions/services/documentDirectService.js +++ b/actions/services/documentDirectService.js @@ -92,11 +92,13 @@ export const deleteAwaitingSubmissionsFromBlob = ( "&casefolderID=" + casefolderID; - var config = { - method: "get", - url: queryUrl + hashAPIPath(queryUrl) - }; - return axios(config) + return buildHashedQueryUrl(queryUrl) + .then((signedUrl) => + axios({ + method: "get", + url: signedUrl + }) + ) .then((res) => { return res.data; }) @@ -118,11 +120,13 @@ export const deleteMyRepresentationsFromBlob = ( "&repfile=" + repfile; - var config = { - method: "get", - url: queryUrl + hashAPIPath(queryUrl) - }; - return axios(config) + return buildHashedQueryUrl(queryUrl) + .then((signedUrl) => + axios({ + method: "get", + url: signedUrl + }) + ) .then((res) => { return res.data; }) diff --git a/actions/services/portalDirectService.js b/actions/services/portalDirectService.js index 9bf17190..1e112810 100644 --- a/actions/services/portalDirectService.js +++ b/actions/services/portalDirectService.js @@ -182,19 +182,21 @@ export const deleteMyRepresentations = (myRepresentationsID) => { "/api/endpoint/deletemyrepresentations_api?myRepresentationsID=" + myRepresentationsID; - var config = { - method: "delete", - url: queryUrl + hashAPIPath(queryUrl), - headers: { - "OData-MaxVersion": "4.0", - "OData-Version": "4.0", - "Accept": "application/json;odata.metadata=none", - "Prefer": 'odata.include-annotations="*",return=representation', - "Content-Type": "application/json" - } - }; - - return axios(config) + return buildHashedQueryUrl(queryUrl) + .then((signedUrl) => + axios({ + method: "delete", + url: signedUrl, + headers: { + "OData-MaxVersion": "4.0", + "OData-Version": "4.0", + "Accept": "application/json;odata.metadata=none", + "Prefer": + 'odata.include-annotations="*",return=representation', + "Content-Type": "application/json" + } + }) + ) .then((res) => { return res.data; }) @@ -224,17 +226,20 @@ export const deleteAwaitingSubmissions = (incidentID) => { export const deleteWatchedCases = async (watchedCaseID) => { var queryUrl = "/api/endpoint/deletewatchedcases_api?watchedCaseID=" + watchedCaseID; - var config = { - method: "delete", - url: queryUrl - }; - try { - const res = await axios(config); - return res.data; - } catch (error) { - consoleLogger(error); - } + return buildHashedQueryUrl(queryUrl) + .then((signedUrl) => + axios({ + method: "delete", + url: signedUrl + }) + ) + .then((res) => { + return res.data; + }) + .catch((error) => { + consoleLogger(error); + }); }; export const sendCaseCompleteMessage = async ( diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index d327bdce..09b6101a 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -529,6 +529,18 @@ - Added focused behavioural test: - `tests/phase14/service-behaviour.test.cjs` now includes allow-list coverage for deleteblobcase path. +## Follow-up status (2026-03-13 — additional delete flows) + +- Expanded signer allow-list for additional browser delete paths: + - `/api/endpoint/deletemyrepresentations_api` + - `/api/endpoint/deletewatchedcases_api` + - `/api/file/deleteblobrep` +- Migrated affected direct-service delete calls to signer-based hash retrieval: + - `portalDirectService.deleteWatchedCases` + - `portalDirectService.deleteMyRepresentations` + - `documentDirectService.deleteAwaitingSubmissionsFromBlob` + - `documentDirectService.deleteMyRepresentationsFromBlob` + ## Likely next steps 1. Stabilize and simplify breadcrumb/back-link decision logic with focused regression checks. diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 03f54885..dbc43589 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -1045,3 +1045,40 @@ Validation: Follow-ups: - Keep signer allow-list additions minimal and path-specific. + +--- + +### CL-029: Follow-up fix — signer coverage for additional delete flows + +date: 2026-03-13 +author: Cline +scope: `actions/services/{portalDirectService,documentDirectService}.js`, `pages/api/endpoint/gethash_api.js`, `tests/phase7/service-behaviour.test.cjs`, `tests/phase14/service-behaviour.test.cjs` +type: change +rationale: Resolve browser hash-signing gaps for additional delete operations still using direct client hash generation. +impact: Restores browser-safe signing for delete watched cases, delete my representations, and delete blob representation/awaiting-submission paths while retaining authenticated allow-list signer model. +status: completed + +Summary: + +- Updated signer allow-list to include: + - `/api/endpoint/deletemyrepresentations_api` + - `/api/endpoint/deletewatchedcases_api` + - `/api/file/deleteblobrep` +- Migrated direct service calls to signer-based hash retrieval for: + - `portalDirectService.deleteWatchedCases` + - `portalDirectService.deleteMyRepresentations` + - `documentDirectService.deleteAwaitingSubmissionsFromBlob` + - `documentDirectService.deleteMyRepresentationsFromBlob` +- Extended focused tests: + - `tests/phase7/service-behaviour.test.cjs` (service signer usage) + - `tests/phase14/service-behaviour.test.cjs` (allow-list coverage) + +Validation: + +- `node tests/phase7/service-behaviour.test.cjs` -> pass (12/12) +- `node tests/phase14/service-behaviour.test.cjs` -> pass (9/9) +- `npm run lint` -> warnings only (pre-existing) + +Follow-ups: + +- Keep allow-list expansion path-scoped and driven by concrete browser-call requirements only. diff --git a/memory-bank/refactor-plan-actions-index.md b/memory-bank/refactor-plan-actions-index.md index 3dd92adc..cbca2cb2 100644 --- a/memory-bank/refactor-plan-actions-index.md +++ b/memory-bank/refactor-plan-actions-index.md @@ -551,6 +551,20 @@ Status key: `[x] done`, `[ ] pending` - Revert commit `49dd990` to undo allow-list/test follow-up. +### Follow-up completion snapshot (2026-03-13 — additional delete flows) + +1. `[x]` Expand signer allow-list for required browser delete paths +2. `[x]` Move remaining delete service calls to signer-based hash retrieval +3. `[x]` Extend focused service/signer tests +4. `[x]` Validate + - phase7 behaviour tests pass (12/12) + - phase14 behaviour tests pass (9/9) + - lint warnings only (pre-existing) + +### Follow-up rollback notes (additional delete flows) + +- Revert the follow-up commit for this slice to restore prior hashing behavior. + ## Safe execution mode for migration chunks (required) To reduce terminal hangs during bulk migration work, run refactor chunks in **safe stepwise mode** instead of long chained commands. diff --git a/pages/api/endpoint/gethash_api.js b/pages/api/endpoint/gethash_api.js index 7b241718..4c1ce596 100644 --- a/pages/api/endpoint/gethash_api.js +++ b/pages/api/endpoint/gethash_api.js @@ -14,9 +14,12 @@ ApiProxy.get(async (req, res) => { const queryPath = req.query.path; const allowedPrefix = [ "/api/endpoint/getportallogin_api", + "/api/endpoint/deletemyrepresentations_api", + "/api/endpoint/deletewatchedcases_api", "/api/file/upload", "/api/file/uploadsinglefile", "/api/file/deleteblobcase", + "/api/file/deleteblobrep", "/api/file/createrepcompletemessage_api", "/api/file/createappealcompletemessage_api" ]; diff --git a/tests/phase14/service-behaviour.test.cjs b/tests/phase14/service-behaviour.test.cjs index 28e7a8a7..31a3f599 100644 --- a/tests/phase14/service-behaviour.test.cjs +++ b/tests/phase14/service-behaviour.test.cjs @@ -152,6 +152,72 @@ test("gethash_api returns hash for allow-listed deleteblobcase path", async () = }); }); +test("gethash_api returns hash for allow-listed deleteblobrep path", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "&hash=deleterep", + getSession: async () => ({ user: { id: "u1" } }), + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { + query: { + path: "/api/file/deleteblobrep?container=c1&casefolderID=r1&repfile=f1" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + hash: "&hash=deleterep" + }); +}); + +test("gethash_api returns hash for allow-listed deletewatchedcases path", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "&hash=watch", + getSession: async () => ({ user: { id: "u1" } }), + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { + query: { + path: "/api/endpoint/deletewatchedcases_api?watchedCaseID=123" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + hash: "&hash=watch" + }); +}); + +test("gethash_api returns hash for allow-listed deletemyrepresentations path", async () => { + const mod = loadModule("pages/api/endpoint/gethash_api.js", { + hashAPIPath: () => "&hash=delrep", + getSession: async () => ({ user: { id: "u1" } }), + nextConnect: createNextConnectMock(), + middleware: () => {} + }); + + const req = { + query: { + path: "/api/endpoint/deletemyrepresentations_api?myRepresentationsID=abc" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + hash: "&hash=delrep" + }); +}); + test("gethash_api rejects unauthenticated requests with 401", async () => { const mod = loadModule("pages/api/endpoint/gethash_api.js", { hashAPIPath: () => "&hash=expected", diff --git a/tests/phase7/service-behaviour.test.cjs b/tests/phase7/service-behaviour.test.cjs index 00da59c0..305ac424 100644 --- a/tests/phase7/service-behaviour.test.cjs +++ b/tests/phase7/service-behaviour.test.cjs @@ -155,10 +155,15 @@ test("document/getAwaitingSubmissionFromBlob logs and returns undefined on failu test("portal/deleteMyRepresentations appends hash and returns data", async () => { const axios = createAxiosMock(); const logger = createLoggerMock(); - const hashCalls = []; - const hashAPIPath = (queryPath) => { - hashCalls.push(queryPath); - return "&hash=portal123"; + const signCalls = []; + + axios.getHandler = async (url) => { + if (url.startsWith("/api/endpoint/gethash_api?path=")) { + signCalls.push(url); + return { data: { hash: "&hash=portal123" } }; + } + + throw new Error("Unexpected get url: " + url); }; axios.requestHandler = async () => ({ data: { removed: true } }); @@ -167,28 +172,39 @@ test("portal/deleteMyRepresentations appends hash and returns data", async () => axios, BASE_URL: "", consoleLogger: logger.consoleLogger, - hashAPIPath + hashAPIPath: () => "&hash=fallback" }); const result = await portal.deleteMyRepresentations("rep-1"); assert.deepStrictEqual(normalize(result), { removed: true }); - assert.strictEqual(hashCalls.length, 1); assert.strictEqual( - hashCalls[0], - "/api/endpoint/deletemyrepresentations_api?myRepresentationsID=rep-1" + signCalls[0], + "/api/endpoint/gethash_api?path=%2Fapi%2Fendpoint%2Fdeletemyrepresentations_api%3FmyRepresentationsID%3Drep-1" ); assert.strictEqual( - axios.calls[0].config.url, + axios.calls[0].url, + "/api/endpoint/gethash_api?path=%2Fapi%2Fendpoint%2Fdeletemyrepresentations_api%3FmyRepresentationsID%3Drep-1" + ); + assert.strictEqual( + axios.calls[1].config.url, "/api/endpoint/deletemyrepresentations_api?myRepresentationsID=rep-1&hash=portal123" ); - assert.strictEqual(axios.calls[0].config.method, "delete"); + assert.strictEqual(axios.calls[1].config.method, "delete"); }); test("portal/deleteMyRepresentations logs and returns undefined on failure", async () => { const axios = createAxiosMock(); const logger = createLoggerMock(); + axios.getHandler = async (url) => { + if (url.startsWith("/api/endpoint/gethash_api?path=")) { + return { data: { hash: "&hash=portal-fail" } }; + } + + throw new Error("Unexpected get url: " + url); + }; + axios.requestHandler = async () => Promise.reject(createAxiosError(401, "Unauthorized")); @@ -196,7 +212,7 @@ test("portal/deleteMyRepresentations logs and returns undefined on failure", asy axios, BASE_URL: "", consoleLogger: logger.consoleLogger, - hashAPIPath: () => "&hash=portal-fail" + hashAPIPath: () => "&hash=fallback" }); const result = await portal.deleteMyRepresentations("rep-2"); @@ -205,6 +221,87 @@ test("portal/deleteMyRepresentations logs and returns undefined on failure", asy assert.strictEqual(logger.calls.length, 1); }); +test("portal/deleteWatchedCases uses signer hash and returns data", async () => { + const axios = createAxiosMock(); + const logger = createLoggerMock(); + const signCalls = []; + + axios.getHandler = async (url) => { + if (url.startsWith("/api/endpoint/gethash_api?path=")) { + signCalls.push(url); + return { data: { hash: "&hash=watch123" } }; + } + + throw new Error("Unexpected get url: " + url); + }; + + axios.requestHandler = async () => ({ data: { removed: true } }); + + const portal = loadServiceModule("portalDirectService.js", { + axios, + BASE_URL: "", + consoleLogger: logger.consoleLogger, + hashAPIPath: () => "&hash=fallback" + }); + + const result = await portal.deleteWatchedCases("watch-1"); + + assert.deepStrictEqual(normalize(result), { removed: true }); + assert.strictEqual( + signCalls[0], + "/api/endpoint/gethash_api?path=%2Fapi%2Fendpoint%2Fdeletewatchedcases_api%3FwatchedCaseID%3Dwatch-1" + ); + assert.strictEqual( + axios.calls[1].config.url, + "/api/endpoint/deletewatchedcases_api?watchedCaseID=watch-1&hash=watch123" + ); +}); + +test("document delete blob flows use signer hash", async () => { + const axios = createAxiosMock(); + const logger = createLoggerMock(); + const signCalls = []; + + axios.getHandler = async (url) => { + if (url.startsWith("/api/endpoint/gethash_api?path=")) { + signCalls.push(url); + return { data: { hash: "&hash=doc123" } }; + } + + throw new Error("Unexpected get url: " + url); + }; + + axios.requestHandler = async () => ({ data: { ok: true } }); + + const document = loadServiceModule("documentDirectService.js", { + axios, + BASE_URL: "", + consoleLogger: logger.consoleLogger, + hashAPIPath: () => "&hash=fallback" + }); + + const one = await document.deleteAwaitingSubmissionsFromBlob( + "c1", + "case-1" + ); + const two = await document.deleteMyRepresentationsFromBlob( + "c1", + "case-1", + "rep-a" + ); + + assert.deepStrictEqual(normalize(one), { ok: true }); + assert.deepStrictEqual(normalize(two), { ok: true }); + assert.strictEqual( + signCalls[0], + "/api/endpoint/gethash_api?path=%2Fapi%2Ffile%2Fdeleteblobcase%3Fcontainer%3Dc1%26casefolderID%3Dcase-1" + ); + assert.strictEqual( + signCalls[1], + "/api/endpoint/gethash_api?path=%2Fapi%2Ffile%2Fdeleteblobrep%3Fcontainer%3Dc1%26casefolderID%3Dcase-1%26repfile%3Drep-a" + ); +}); + test("account/getPortalLogin appends hash and returns res.data", async () => { const axios = createAxiosMock(); const logger = createLoggerMock();