diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 1f31a8d4..f39569eb 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -215,3 +215,44 @@ Follow-ups: - Next recommended slice on this branch: complete file-route guard parity for `deleteblob.js`, `deleteblobcase.js`, and `deleteblobrep.js` by aligning hash validation canonicalization and explicit `respondError` contracts (`MISSING_REQUIRED_QUERY`, `INVALID_HASH`, operation-specific `*_FAILED`). - Extend `tests/phase21/file-handler-contract.test.cjs` for the above routes with mixed encoded/raw hash cases to lock compatibility. + +--- + +### CL-006: TASK22224 file delete-route guard parity slice + +date: 2026-03-23 +author: Cline +scope: `pages/api/file/{deleteblob,deleteblobcase,deleteblobrep}.js`, `tests/phase21/file-handler-contract.test.cjs` +type: change +rationale: Execute the next planned slice to align hash/canonicalization behavior and negative-path contracts across high-risk file delete routes, matching the compatibility posture established for `downloadblob`. +impact: Reduces false `INVALID_HASH` failures for legitimate encoded/raw caller variants while preserving strict hash enforcement and improving resilience via explicit catch-path contracts. +status: completed + +Summary: + +- `deleteblob.js` + - added bounded hash candidate validation for encoded/raw combinations of `casefolderID` and `blobname` + - normalized delete path handling for both filename-only and already-prefixed blob paths + - added explicit catch-path contract: `DELETE_BLOB_FAILED` +- `deleteblobcase.js` + - added hash candidate validation for raw/encoded `casefolderID` + - added explicit catch-path contract: `DELETE_BLOB_CASE_FAILED` +- `deleteblobrep.js` + - added hash candidate validation for encoded/raw `casefolderID` + `repfile` + - added explicit catch-path contract: `DELETE_BLOB_REP_FAILED` +- Phase21 tests expanded (`file-handler-contract.test.cjs`): + - encoded hash-variant acceptance cases for all three delete routes + - explicit dependency-failure contract assertions for all three delete routes + +Validation: + +- `node tests/phase21/file-handler-contract.test.cjs` -> pass (23/23) +- `node tests/phase21/api-contract-slice1.test.cjs` -> pass + - helper: 4/4 + - file-handler: 23/23 + - email-handler: 12/12 + - endpoint-handler: 149/149 + +Follow-ups: + +- Optional next slice: apply same bounded hash-canonicalization parity to remaining high-sensitivity file routes where mixed encoded/raw callers may exist (`getbloblist`, `getprogressobjblob`) and add regression cases to phase21. diff --git a/pages/api/file/deleteblob.js b/pages/api/file/deleteblob.js index 5c1d52fe..5402078b 100644 --- a/pages/api/file/deleteblob.js +++ b/pages/api/file/deleteblob.js @@ -9,10 +9,10 @@ const ApiProxy = nextConnect(); ApiProxy.use(middleware); ApiProxy.get(async (req, res) => { - var containerName = req.query.container; - var casefolderID = req.query.casefolderID; - var blobName = req.query.blobname; - var checkHash = req.query.hash; + const containerName = req.query.container; + const casefolderID = req.query.casefolderID; + const blobName = req.query.blobname; + const checkHash = req.query.hash; if ( typeof containerName === "undefined" || @@ -31,15 +31,41 @@ ApiProxy.get(async (req, res) => { }); } - var checkquerypath = - "/api/file/deleteblob?container=" + - containerName + - "&casefolderID=" + - encodeURIComponent(casefolderID) + - "&blobname=" + - encodeURIComponent(blobName); + const casefolderIDTrimmed = casefolderID.trim(); + const blobNameTrimmed = blobName.trim(); - if (hashAPIPath(checkquerypath) != "&hash=" + checkHash) { + const hashCandidatePaths = [ + "/api/file/deleteblob?container=" + + containerName + + "&casefolderID=" + + casefolderIDTrimmed + + "&blobname=" + + blobNameTrimmed, + "/api/file/deleteblob?container=" + + containerName + + "&casefolderID=" + + encodeURIComponent(casefolderIDTrimmed) + + "&blobname=" + + blobNameTrimmed, + "/api/file/deleteblob?container=" + + containerName + + "&casefolderID=" + + casefolderIDTrimmed + + "&blobname=" + + encodeURIComponent(blobNameTrimmed), + "/api/file/deleteblob?container=" + + containerName + + "&casefolderID=" + + encodeURIComponent(casefolderIDTrimmed) + + "&blobname=" + + encodeURIComponent(blobNameTrimmed) + ]; + + const isHashValid = hashCandidatePaths.some( + (candidatePath) => hashAPIPath(candidatePath) == "&hash=" + checkHash + ); + + if (!isHashValid) { return respondError(res, { status: 400, code: "INVALID_HASH", @@ -47,11 +73,22 @@ ApiProxy.get(async (req, res) => { }); } - await deleteBlob(containerName, casefolderID + "/files/" + blobName).then( - (data) => { - return respondSuccess(res, { data: data }); - } - ); + try { + const normalizedBlobName = blobNameTrimmed.startsWith( + casefolderIDTrimmed + "/" + ) + ? blobNameTrimmed + : casefolderIDTrimmed + "/files/" + blobNameTrimmed; + + const data = await deleteBlob(containerName, normalizedBlobName); + return respondSuccess(res, { data: data }); + } catch (error) { + return respondError(res, { + status: 400, + code: "DELETE_BLOB_FAILED", + message: "Unable to delete blob" + }); + } }); export const config = { diff --git a/pages/api/file/deleteblobcase.js b/pages/api/file/deleteblobcase.js index ba48e9d5..62764132 100644 --- a/pages/api/file/deleteblobcase.js +++ b/pages/api/file/deleteblobcase.js @@ -9,9 +9,9 @@ const ApiProxy = nextConnect(); ApiProxy.use(middleware); ApiProxy.get(async (req, res) => { - var containerName = req.query.container; - var casefolderID = req.query.casefolderID; - var checkHash = req.query.hash; + const containerName = req.query.container; + const casefolderID = req.query.casefolderID; + const checkHash = req.query.hash; if ( typeof containerName === "undefined" || @@ -28,13 +28,24 @@ ApiProxy.get(async (req, res) => { }); } - var checkquerypath = - "/api/file/deleteblobcase?container=" + - containerName + - "&casefolderID=" + - casefolderID; + const casefolderIDTrimmed = casefolderID.trim(); - if (hashAPIPath(checkquerypath) != "&hash=" + checkHash) { + const hashCandidatePaths = [ + "/api/file/deleteblobcase?container=" + + containerName + + "&casefolderID=" + + casefolderIDTrimmed, + "/api/file/deleteblobcase?container=" + + containerName + + "&casefolderID=" + + encodeURIComponent(casefolderIDTrimmed) + ]; + + const isHashValid = hashCandidatePaths.some( + (candidatePath) => hashAPIPath(candidatePath) == "&hash=" + checkHash + ); + + if (!isHashValid) { return respondError(res, { status: 400, code: "INVALID_HASH", @@ -42,9 +53,16 @@ ApiProxy.get(async (req, res) => { }); } - await deleteBlobCase(containerName, casefolderID).then((data) => { + try { + const data = await deleteBlobCase(containerName, casefolderIDTrimmed); return respondSuccess(res, { data: data }); - }); + } catch (error) { + return respondError(res, { + status: 400, + code: "DELETE_BLOB_CASE_FAILED", + message: "Unable to delete blob case" + }); + } }); export const config = { diff --git a/pages/api/file/deleteblobrep.js b/pages/api/file/deleteblobrep.js index fd80f28b..b327837c 100644 --- a/pages/api/file/deleteblobrep.js +++ b/pages/api/file/deleteblobrep.js @@ -9,10 +9,10 @@ const ApiProxy = nextConnect(); ApiProxy.use(middleware); ApiProxy.get(async (req, res) => { - var containerName = req.query.container; - var casefolderID = req.query.casefolderID; - var repfile = req.query.repfile; - var checkHash = req.query.hash; + const containerName = req.query.container; + const casefolderID = req.query.casefolderID; + const repfile = req.query.repfile; + const checkHash = req.query.hash; if ( typeof containerName === "undefined" || @@ -31,15 +31,41 @@ ApiProxy.get(async (req, res) => { }); } - var checkquerypath = - "/api/file/deleteblobrep?container=" + - containerName + - "&casefolderID=" + - casefolderID + - "&repfile=" + - repfile; + const casefolderIDTrimmed = casefolderID.trim(); + const repfileTrimmed = repfile.trim(); - if (hashAPIPath(checkquerypath) != "&hash=" + checkHash) { + const hashCandidatePaths = [ + "/api/file/deleteblobrep?container=" + + containerName + + "&casefolderID=" + + casefolderIDTrimmed + + "&repfile=" + + repfileTrimmed, + "/api/file/deleteblobrep?container=" + + containerName + + "&casefolderID=" + + encodeURIComponent(casefolderIDTrimmed) + + "&repfile=" + + repfileTrimmed, + "/api/file/deleteblobrep?container=" + + containerName + + "&casefolderID=" + + casefolderIDTrimmed + + "&repfile=" + + encodeURIComponent(repfileTrimmed), + "/api/file/deleteblobrep?container=" + + containerName + + "&casefolderID=" + + encodeURIComponent(casefolderIDTrimmed) + + "&repfile=" + + encodeURIComponent(repfileTrimmed) + ]; + + const isHashValid = hashCandidatePaths.some( + (candidatePath) => hashAPIPath(candidatePath) == "&hash=" + checkHash + ); + + if (!isHashValid) { return respondError(res, { status: 400, code: "INVALID_HASH", @@ -47,11 +73,17 @@ ApiProxy.get(async (req, res) => { }); } - casefolderID = casefolderID + "/" + repfile; - - await deleteBlobRep(containerName, casefolderID).then((data) => { + try { + const normalizedRepPath = casefolderIDTrimmed + "/" + repfileTrimmed; + const data = await deleteBlobRep(containerName, normalizedRepPath); return respondSuccess(res, { data: data }); - }); + } catch (error) { + return respondError(res, { + status: 400, + code: "DELETE_BLOB_REP_FAILED", + message: "Unable to delete blob representation" + }); + } }); export const config = { diff --git a/tests/phase21/file-handler-contract.test.cjs b/tests/phase21/file-handler-contract.test.cjs index 18d573aa..8b7342c9 100644 --- a/tests/phase21/file-handler-contract.test.cjs +++ b/tests/phase21/file-handler-contract.test.cjs @@ -71,6 +71,188 @@ test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing", assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY"); }); +test("deleteblob handler accepts encoded hash variant and normalizes prefixed path", async () => { + let deletedPath = null; + const mod = loadModule("pages/api/file/deleteblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: (queryPath) => + queryPath.includes("/api/file/deleteblob?container=c1") + ? "&hash=expected" + : "&hash=other", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + deleteBlob: async (_container, blobPath) => { + deletedPath = blobPath; + return { deleted: true }; + } + }); + + const req = { + query: { + container: "c1", + casefolderID: "TMP-LFE1C", + blobname: + "TMP-LFE1C/files/2026-03-18_-_Site_Location_Plan_-_test document 021.xlsx", + hash: "expected" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual( + deletedPath, + "TMP-LFE1C/files/2026-03-18_-_Site_Location_Plan_-_test document 021.xlsx" + ); +}); + +test("deleteblob handler dependency failure returns DELETE_BLOB_FAILED", async () => { + const mod = loadModule("pages/api/file/deleteblob.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + deleteBlob: async () => { + throw new Error("delete failed"); + } + }); + + const req = { + query: { + container: "c1", + casefolderID: "f1", + blobname: "doc.pdf", + hash: "expected" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "DELETE_BLOB_FAILED"); +}); + +test("deleteblobcase handler accepts encoded hash variant", async () => { + let deletedCaseFolder = null; + const mod = loadModule("pages/api/file/deleteblobcase.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: (queryPath) => + queryPath.includes("casefolderID=CASE%2FSUB") + ? "&hash=expected" + : "&hash=other", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + deleteBlobCase: async (_container, casefolder) => { + deletedCaseFolder = casefolder; + return { deleted: true }; + } + }); + + const req = { + query: { + container: "c1", + casefolderID: "CASE/SUB", + hash: "expected" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(deletedCaseFolder, "CASE/SUB"); +}); + +test("deleteblobcase handler dependency failure returns DELETE_BLOB_CASE_FAILED", async () => { + const mod = loadModule("pages/api/file/deleteblobcase.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + deleteBlobCase: async () => { + throw new Error("delete case failed"); + } + }); + + const req = { + query: { + container: "c1", + casefolderID: "f1", + hash: "expected" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "DELETE_BLOB_CASE_FAILED" + ); +}); + +test("deleteblobrep handler accepts encoded hash variant", async () => { + let deletedRepPath = null; + const mod = loadModule("pages/api/file/deleteblobrep.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: (queryPath) => + queryPath.includes("repfile=Rep%20Name") + ? "&hash=expected" + : "&hash=other", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + deleteBlobRep: async (_container, repPath) => { + deletedRepPath = repPath; + return { deleted: true }; + } + }); + + const req = { + query: { + container: "c1", + casefolderID: "CASE-1", + repfile: "Rep Name", + hash: "expected" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.strictEqual(deletedRepPath, "CASE-1/Rep Name"); +}); + +test("deleteblobrep handler dependency failure returns DELETE_BLOB_REP_FAILED", async () => { + const mod = loadModule("pages/api/file/deleteblobrep.js", { + nextConnect: createNextConnectMock(), + middleware: () => {}, + hashAPIPath: () => "&hash=expected", + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + deleteBlobRep: async () => { + throw new Error("delete rep failed"); + } + }); + + const req = { + query: { + container: "c1", + casefolderID: "f1", + repfile: "rep1", + hash: "expected" + } + }; + const res = createRes(); + await mod.default.handler(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual(res.state.jsonBody.error.code, "DELETE_BLOB_REP_FAILED"); +}); + test("getbloblist handler returns INVALID_HASH for mismatch", async () => { const mod = loadModule("pages/api/file/getbloblist.js", { nextConnect: createNextConnectMock(),