fix(security): enforce ownership on private draft blob deletion
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { hashAPIPath } from "../../../actions/core/hash";
|
||||
import { deleteBlob } from "../../../actions/azurestorage";
|
||||
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";
|
||||
@@ -8,6 +10,22 @@ import middleware from "../middleware/middleware";
|
||||
const ApiProxy = nextConnect();
|
||||
ApiProxy.use(middleware);
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
ApiProxy.get(async (req, res) => {
|
||||
const containerName = req.query.container;
|
||||
const casefolderID = req.query.casefolderID;
|
||||
@@ -15,14 +33,10 @@ ApiProxy.get(async (req, res) => {
|
||||
const checkHash = req.query.hash;
|
||||
|
||||
if (
|
||||
typeof containerName === "undefined" ||
|
||||
containerName.length === 0 ||
|
||||
typeof casefolderID === "undefined" ||
|
||||
casefolderID.length === 0 ||
|
||||
typeof blobName === "undefined" ||
|
||||
blobName.length === 0 ||
|
||||
typeof checkHash === "undefined" ||
|
||||
checkHash.length === 0
|
||||
!hasValue(containerName) ||
|
||||
!hasValue(casefolderID) ||
|
||||
!hasValue(blobName) ||
|
||||
!hasValue(checkHash)
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
@@ -34,6 +48,23 @@ ApiProxy.get(async (req, res) => {
|
||||
const casefolderIDTrimmed = casefolderID.trim();
|
||||
const blobNameTrimmed = blobName.trim();
|
||||
|
||||
if (!isValidContainer(containerName)) {
|
||||
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/deleteawaitingsubmissionfromblob?container=" +
|
||||
containerName +
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { hashAPIPath } from "../../../actions/core/hash";
|
||||
import { deleteBlob } from "../../../actions/azurestorage";
|
||||
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";
|
||||
@@ -8,6 +10,22 @@ import middleware from "../middleware/middleware";
|
||||
const ApiProxy = nextConnect();
|
||||
ApiProxy.use(middleware);
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
ApiProxy.get(async (req, res) => {
|
||||
const containerName = req.query.container;
|
||||
const casefolderID = req.query.casefolderID;
|
||||
@@ -15,14 +33,10 @@ ApiProxy.get(async (req, res) => {
|
||||
const checkHash = req.query.hash;
|
||||
|
||||
if (
|
||||
typeof containerName === "undefined" ||
|
||||
containerName.length === 0 ||
|
||||
typeof casefolderID === "undefined" ||
|
||||
casefolderID.length === 0 ||
|
||||
typeof blobName === "undefined" ||
|
||||
blobName.length === 0 ||
|
||||
typeof checkHash === "undefined" ||
|
||||
checkHash.length === 0
|
||||
!hasValue(containerName) ||
|
||||
!hasValue(casefolderID) ||
|
||||
!hasValue(blobName) ||
|
||||
!hasValue(checkHash)
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
@@ -34,6 +48,23 @@ ApiProxy.get(async (req, res) => {
|
||||
const casefolderIDTrimmed = casefolderID.trim();
|
||||
const blobNameTrimmed = blobName.trim();
|
||||
|
||||
if (!isValidContainer(containerName)) {
|
||||
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/deleteblob?container=" +
|
||||
containerName +
|
||||
|
||||
@@ -247,6 +247,8 @@ test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing",
|
||||
const mod = loadModule("pages/api/file/deleteblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => ({ user: { id: "container-1" } }),
|
||||
authOptions: () => ({}),
|
||||
hashAPIPath: () => "&hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -254,7 +256,11 @@ test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing",
|
||||
});
|
||||
|
||||
const req = {
|
||||
query: { container: "c1", casefolderID: "f1", hash: "expected" }
|
||||
query: {
|
||||
container: "container-1",
|
||||
casefolderID: "f1",
|
||||
hash: "expected"
|
||||
}
|
||||
};
|
||||
const res = createRes();
|
||||
await mod.default.handler(req, res);
|
||||
@@ -268,8 +274,10 @@ test("deleteblob handler accepts encoded hash variant and normalizes prefixed pa
|
||||
const mod = loadModule("pages/api/file/deleteblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => ({ user: { id: "container-1" } }),
|
||||
authOptions: () => ({}),
|
||||
hashAPIPath: (queryPath) =>
|
||||
queryPath.includes("/api/file/deleteblob?container=c1")
|
||||
queryPath.includes("/api/file/deleteblob?container=container-1")
|
||||
? "&hash=expected"
|
||||
: "&hash=other",
|
||||
respondError: respondErrorMock,
|
||||
@@ -282,7 +290,7 @@ test("deleteblob handler accepts encoded hash variant and normalizes prefixed pa
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
container: "c1",
|
||||
container: "container-1",
|
||||
casefolderID: "TMP-LFE1C",
|
||||
blobname:
|
||||
"TMP-LFE1C/files/2026-03-18_-_Site_Location_Plan_-_test document 021.xlsx",
|
||||
@@ -303,6 +311,8 @@ test("deleteblob handler dependency failure returns DELETE_BLOB_FAILED", async (
|
||||
const mod = loadModule("pages/api/file/deleteblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => ({ user: { id: "container-1" } }),
|
||||
authOptions: () => ({}),
|
||||
hashAPIPath: () => "&hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -313,7 +323,7 @@ test("deleteblob handler dependency failure returns DELETE_BLOB_FAILED", async (
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
container: "c1",
|
||||
container: "container-1",
|
||||
casefolderID: "f1",
|
||||
blobname: "doc.pdf",
|
||||
hash: "expected"
|
||||
@@ -1221,8 +1231,12 @@ test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant",
|
||||
{
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => ({ user: { id: "container-1" } }),
|
||||
authOptions: () => ({}),
|
||||
hashAPIPath: (queryPath) =>
|
||||
queryPath.includes("/api/file/deleteblob?container=c1")
|
||||
queryPath.includes(
|
||||
"/api/file/deleteblob?container=container-1"
|
||||
)
|
||||
? "&hash=expected"
|
||||
: "&hash=other",
|
||||
respondError: respondErrorMock,
|
||||
@@ -1236,7 +1250,7 @@ test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant",
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
container: "c1",
|
||||
container: "container-1",
|
||||
casefolderID: "CASE-1",
|
||||
blobname: "CASE-1/files/doc.pdf",
|
||||
hash: "expected"
|
||||
@@ -1255,6 +1269,8 @@ test("deleteawaitingsubmissionfromblob dependency failure returns DELETE_AWAITIN
|
||||
{
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => ({ user: { id: "container-1" } }),
|
||||
authOptions: () => ({}),
|
||||
hashAPIPath: () => "&hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -1266,7 +1282,7 @@ test("deleteawaitingsubmissionfromblob dependency failure returns DELETE_AWAITIN
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
container: "c1",
|
||||
container: "container-1",
|
||||
casefolderID: "CASE-1",
|
||||
blobname: "doc.pdf",
|
||||
hash: "expected"
|
||||
@@ -1282,6 +1298,245 @@ test("deleteawaitingsubmissionfromblob dependency failure returns DELETE_AWAITIN
|
||||
);
|
||||
});
|
||||
|
||||
const ownerBoundBlobDeletionCases = [
|
||||
{
|
||||
name: "deleteblob",
|
||||
route: "pages/api/file/deleteblob.js"
|
||||
},
|
||||
{
|
||||
name: "deleteawaitingsubmissionfromblob",
|
||||
route: "pages/api/file/deleteawaitingsubmissionfromblob.js"
|
||||
}
|
||||
];
|
||||
|
||||
const loadOwnerBoundBlobDeletion = (
|
||||
deletionCase,
|
||||
{ session, ...overrides } = {}
|
||||
) => {
|
||||
const calls = {
|
||||
options: [],
|
||||
session: [],
|
||||
hash: [],
|
||||
deletion: []
|
||||
};
|
||||
const options = { id: `${deletionCase.name}-options` };
|
||||
const resolvedSession =
|
||||
typeof session === "undefined"
|
||||
? { user: { id: "owner-container" } }
|
||||
: session;
|
||||
const mod = loadModule(deletionCase.route, {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
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";
|
||||
},
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
deleteBlob: async (...args) => {
|
||||
calls.deletion.push(args);
|
||||
return { deleted: true };
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
return { mod, calls, options };
|
||||
};
|
||||
|
||||
const ownerBoundBlobDeletionRequest = (overrides = {}) => ({
|
||||
query: {
|
||||
container: "owner-container",
|
||||
casefolderID: "CASE-1",
|
||||
blobname: "document.pdf",
|
||||
hash: "expected"
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
const assertNoOwnerBoundBlobDeletion = (calls) => {
|
||||
assert.strictEqual(calls.hash.length, 0);
|
||||
assert.strictEqual(calls.deletion.length, 0);
|
||||
};
|
||||
|
||||
for (const deletionCase of ownerBoundBlobDeletionCases) {
|
||||
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 } }
|
||||
}
|
||||
]) {
|
||||
test(`${deletionCase.name} rejects ${sessionCase.name} before HMAC or Azure deletion`, async () => {
|
||||
const loaded = loadOwnerBoundBlobDeletion(deletionCase, {
|
||||
session: sessionCase.session
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
ownerBoundBlobDeletionRequest(),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 403);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
||||
assertNoOwnerBoundBlobDeletion(loaded.calls);
|
||||
});
|
||||
}
|
||||
|
||||
test(`${deletionCase.name} rejects cross-container deletion before HMAC or Azure deletion`, async () => {
|
||||
const loaded = loadOwnerBoundBlobDeletion(deletionCase, {
|
||||
session: { user: { id: "different-container" } }
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(ownerBoundBlobDeletionRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 403);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
||||
assertNoOwnerBoundBlobDeletion(loaded.calls);
|
||||
});
|
||||
|
||||
test(`${deletionCase.name} rejects an invalid container before authentication or deletion`, async () => {
|
||||
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
ownerBoundBlobDeletionRequest({
|
||||
query: {
|
||||
container: "Invalid_Container",
|
||||
casefolderID: "CASE-1",
|
||||
blobname: "document.pdf",
|
||||
hash: "expected"
|
||||
}
|
||||
}),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
||||
assert.strictEqual(loaded.calls.options.length, 0);
|
||||
assert.strictEqual(loaded.calls.session.length, 0);
|
||||
assertNoOwnerBoundBlobDeletion(loaded.calls);
|
||||
});
|
||||
|
||||
test(`${deletionCase.name} rejects a missing blob name before authentication or deletion`, async () => {
|
||||
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
ownerBoundBlobDeletionRequest({
|
||||
query: {
|
||||
container: "owner-container",
|
||||
casefolderID: "CASE-1",
|
||||
hash: "expected"
|
||||
}
|
||||
}),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(
|
||||
res.state.jsonBody.error.code,
|
||||
"MISSING_REQUIRED_QUERY"
|
||||
);
|
||||
assert.strictEqual(loaded.calls.options.length, 0);
|
||||
assert.strictEqual(loaded.calls.session.length, 0);
|
||||
assertNoOwnerBoundBlobDeletion(loaded.calls);
|
||||
});
|
||||
|
||||
test(`${deletionCase.name} rejects malformed folder and blob parameters before authentication or deletion`, async () => {
|
||||
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
|
||||
|
||||
for (const query of [
|
||||
{
|
||||
container: "owner-container",
|
||||
casefolderID: ["CASE-1"],
|
||||
blobname: "document.pdf",
|
||||
hash: "expected"
|
||||
},
|
||||
{
|
||||
container: "owner-container",
|
||||
casefolderID: "CASE-1",
|
||||
blobname: ["document.pdf"],
|
||||
hash: "expected"
|
||||
},
|
||||
{
|
||||
container: "owner-container",
|
||||
casefolderID: " ",
|
||||
blobname: "document.pdf",
|
||||
hash: "expected"
|
||||
},
|
||||
{
|
||||
container: "owner-container",
|
||||
casefolderID: "CASE-1",
|
||||
blobname: " ",
|
||||
hash: "expected"
|
||||
}
|
||||
]) {
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
ownerBoundBlobDeletionRequest({ query }),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(
|
||||
res.state.jsonBody.error.code,
|
||||
"MISSING_REQUIRED_QUERY"
|
||||
);
|
||||
}
|
||||
|
||||
assert.strictEqual(loaded.calls.options.length, 0);
|
||||
assert.strictEqual(loaded.calls.session.length, 0);
|
||||
assertNoOwnerBoundBlobDeletion(loaded.calls);
|
||||
});
|
||||
|
||||
test(`${deletionCase.name} rejects an invalid HMAC after owner authentication and before deletion`, async () => {
|
||||
const loaded = loadOwnerBoundBlobDeletion(deletionCase, {
|
||||
hashAPIPath: () => "&hash=different"
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(ownerBoundBlobDeletionRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
||||
assert.strictEqual(loaded.calls.deletion.length, 0);
|
||||
});
|
||||
|
||||
test(`${deletionCase.name} preserves owner deletion and request-aware session arguments`, async () => {
|
||||
const loaded = loadOwnerBoundBlobDeletion(deletionCase);
|
||||
const req = ownerBoundBlobDeletionRequest();
|
||||
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.strictEqual(loaded.calls.hash.length, 1);
|
||||
assert.deepStrictEqual(loaded.calls.deletion, [
|
||||
["owner-container", "CASE-1/files/document.pdf"]
|
||||
]);
|
||||
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
||||
data: { deleted: true }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("getrepsblob handler success returns representations payload", async () => {
|
||||
const mod = loadModule("pages/api/file/getrepsblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
@@ -3527,6 +3782,176 @@ test("downloadblob handler success sets attachment header and returns body", asy
|
||||
assert.strictEqual(res.state.sentBody.toString(), "file-content");
|
||||
});
|
||||
|
||||
const loadDownloadBlobCapability = (overrides = {}) => {
|
||||
const calls = { hash: [], download: [] };
|
||||
const mod = loadModule("pages/api/file/downloadblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
hashAPIPath: (queryPath) => {
|
||||
calls.hash.push(queryPath);
|
||||
return queryPath ===
|
||||
"/api/file/downloadblob?container=selected-container&casefolderID=CASE%2F1&blobname=document.pdf"
|
||||
? "&hash=valid-capability"
|
||||
: "&hash=other-capability";
|
||||
},
|
||||
respondError: respondErrorMock,
|
||||
downloadFile: async (...args) => {
|
||||
calls.download.push(args);
|
||||
return Buffer.from("private-draft-content");
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
return { mod, calls };
|
||||
};
|
||||
|
||||
const downloadBlobCapabilityRequest = (overrides = {}) => ({
|
||||
query: {
|
||||
container: "selected-container",
|
||||
casefolderID: "CASE/1",
|
||||
blobname: "document.pdf",
|
||||
hash: "valid-capability"
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
test("downloadblob preserves anonymous access with a valid exact-resource capability", async () => {
|
||||
const loaded = loadDownloadBlobCapability();
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(downloadBlobCapabilityRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 200);
|
||||
assert.deepStrictEqual(loaded.calls.download, [
|
||||
["selected-container", "CASE/1/files/document.pdf"]
|
||||
]);
|
||||
assert.strictEqual(
|
||||
res.state.headers["content-disposition"],
|
||||
"attachment; filename=document.pdf"
|
||||
);
|
||||
assert.strictEqual(res.state.sentBody.toString(), "private-draft-content");
|
||||
});
|
||||
|
||||
test("downloadblob rejects a missing capability before Azure download", async () => {
|
||||
const loaded = loadDownloadBlobCapability();
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
downloadBlobCapabilityRequest({
|
||||
query: {
|
||||
container: "selected-container",
|
||||
casefolderID: "CASE/1",
|
||||
blobname: "document.pdf"
|
||||
}
|
||||
}),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY");
|
||||
assert.strictEqual(loaded.calls.hash.length, 0);
|
||||
assert.strictEqual(loaded.calls.download.length, 0);
|
||||
});
|
||||
|
||||
test("downloadblob rejects an invalid capability before Azure download", async () => {
|
||||
const loaded = loadDownloadBlobCapability();
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
downloadBlobCapabilityRequest({
|
||||
query: {
|
||||
container: "selected-container",
|
||||
casefolderID: "CASE/1",
|
||||
blobname: "document.pdf",
|
||||
hash: "invalid-capability"
|
||||
}
|
||||
}),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
||||
assert.strictEqual(loaded.calls.download.length, 0);
|
||||
});
|
||||
|
||||
for (const capabilityCase of [
|
||||
{
|
||||
name: "different blob",
|
||||
query: {
|
||||
container: "selected-container",
|
||||
casefolderID: "CASE/1",
|
||||
blobname: "other-document.pdf",
|
||||
hash: "valid-capability"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "different folder",
|
||||
query: {
|
||||
container: "selected-container",
|
||||
casefolderID: "CASE/2",
|
||||
blobname: "document.pdf",
|
||||
hash: "valid-capability"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "different container",
|
||||
query: {
|
||||
container: "other-container",
|
||||
casefolderID: "CASE/1",
|
||||
blobname: "document.pdf",
|
||||
hash: "valid-capability"
|
||||
}
|
||||
}
|
||||
]) {
|
||||
test(`downloadblob rejects a capability issued for a ${capabilityCase.name}`, async () => {
|
||||
const loaded = loadDownloadBlobCapability();
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
downloadBlobCapabilityRequest({ query: capabilityCase.query }),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
||||
assert.strictEqual(loaded.calls.download.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("downloadblob rejects a delete-operation capability before Azure download", async () => {
|
||||
const loaded = loadDownloadBlobCapability({
|
||||
hashAPIPath: (queryPath) => {
|
||||
loaded.calls.hash.push(queryPath);
|
||||
return queryPath.startsWith("/api/file/deleteblob?")
|
||||
? "&hash=valid-capability"
|
||||
: "&hash=other-capability";
|
||||
}
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(downloadBlobCapabilityRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
||||
assert.strictEqual(loaded.calls.download.length, 0);
|
||||
});
|
||||
|
||||
test("downloadblob preserves dependency failure contract for a valid capability", async () => {
|
||||
const loaded = loadDownloadBlobCapability({
|
||||
downloadFile: async (...args) => {
|
||||
loaded.calls.download.push(args);
|
||||
throw new Error("download failed");
|
||||
}
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(downloadBlobCapabilityRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "DOWNLOAD_BLOB_FAILED");
|
||||
assert.strictEqual(loaded.calls.download.length, 1);
|
||||
});
|
||||
|
||||
test("upload handler dependency failure returns UPLOAD_FAILED", async () => {
|
||||
const mod = loadModule("pages/api/file/upload.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
|
||||
Reference in New Issue
Block a user