ITHC: enforce container ownership on uploads
This commit is contained in:
@@ -16,6 +16,7 @@ test("upload handler returns HASH_REQUIRED for missing hash", async () => {
|
||||
const mod = loadModule("pages/api/file/upload.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "c1" } }),
|
||||
hashAPIPath: () => "?hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -35,6 +36,7 @@ test("upload handler returns INVALID_HASH for wrong hash", async () => {
|
||||
const mod = loadModule("pages/api/file/upload.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "c1" } }),
|
||||
hashAPIPath: () => "?hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -57,6 +59,7 @@ test("upload handler accepts alternate canonical hash candidate", async () => {
|
||||
const mod = loadModule("pages/api/file/upload.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "c1" } }),
|
||||
hashAPIPath: (queryPath) =>
|
||||
queryPath === "/api/file/upload?"
|
||||
? "?hash=expected"
|
||||
@@ -85,6 +88,130 @@ test("upload handler accepts alternate canonical hash candidate", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
const loadUploadAuthorization = (overrides = {}) => {
|
||||
const calls = { createBlob: [], createRepBlob: [], hash: [] };
|
||||
const mod = loadModule("pages/api/file/upload.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "user-container" } }),
|
||||
hashAPIPath: (queryPath) => {
|
||||
calls.hash.push(queryPath);
|
||||
return "?hash=expected";
|
||||
},
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
createBlob: async (...args) => {
|
||||
calls.createBlob.push(args);
|
||||
return { id: "appeal-blob" };
|
||||
},
|
||||
createRepBlob: async (...args) => {
|
||||
calls.createRepBlob.push(args);
|
||||
return { id: "representation-blob" };
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
return { mod, calls };
|
||||
};
|
||||
|
||||
const uploadAuthorizationRequest = (overrides = {}) => ({
|
||||
query: { hash: "expected" },
|
||||
body: {
|
||||
appealData: [JSON.stringify({ key: "value" })],
|
||||
containerID: ["user-container"],
|
||||
casefolderID: ["CASE-1"],
|
||||
repOrAppeal: ["false"]
|
||||
},
|
||||
files: {},
|
||||
...overrides
|
||||
});
|
||||
|
||||
const assertNoUploadWrites = (calls) => {
|
||||
assert.strictEqual(calls.createBlob.length, 0);
|
||||
assert.strictEqual(calls.createRepBlob.length, 0);
|
||||
};
|
||||
|
||||
test("upload rejects a missing NextAuth session before HMAC or Azure write", async () => {
|
||||
const loaded = loadUploadAuthorization({ getSession: async () => null });
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 401);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
|
||||
assert.strictEqual(loaded.calls.hash.length, 0);
|
||||
assertNoUploadWrites(loaded.calls);
|
||||
});
|
||||
|
||||
test("upload rejects an unusable NextAuth session before HMAC or Azure write", async () => {
|
||||
const loaded = loadUploadAuthorization({
|
||||
getSession: async () => ({ user: {} })
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 401);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
|
||||
assert.strictEqual(loaded.calls.hash.length, 0);
|
||||
assertNoUploadWrites(loaded.calls);
|
||||
});
|
||||
|
||||
test("upload forbids another user's container before HMAC or Azure write", async () => {
|
||||
const loaded = loadUploadAuthorization();
|
||||
const req = uploadAuthorizationRequest();
|
||||
req.body.containerID = ["other-container"];
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(req, res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 403);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
||||
assert.strictEqual(loaded.calls.hash.length, 0);
|
||||
assertNoUploadWrites(loaded.calls);
|
||||
});
|
||||
|
||||
test("upload rejects invalid HMAC for the owning user without Azure write", async () => {
|
||||
const loaded = loadUploadAuthorization({
|
||||
hashAPIPath: () => "?hash=different"
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
||||
assertNoUploadWrites(loaded.calls);
|
||||
});
|
||||
|
||||
test("upload requires HMAC for the authenticated owning user", async () => {
|
||||
const loaded = loadUploadAuthorization();
|
||||
const req = uploadAuthorizationRequest({ query: {} });
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(req, res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
|
||||
assertNoUploadWrites(loaded.calls);
|
||||
});
|
||||
|
||||
test("upload preserves the authorised owner success contract and helper arguments", async () => {
|
||||
const loaded = loadUploadAuthorization();
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(uploadAuthorizationRequest(), res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 200);
|
||||
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
||||
data: { id: "appeal-blob" }
|
||||
});
|
||||
assert.deepStrictEqual(loaded.calls.createBlob, [
|
||||
[JSON.stringify({ key: "value" }), "user-container", "CASE-1"]
|
||||
]);
|
||||
assert.strictEqual(loaded.calls.createRepBlob.length, 0);
|
||||
});
|
||||
|
||||
test("deleteblob handler returns MISSING_REQUIRED_QUERY when blobname missing", async () => {
|
||||
const mod = loadModule("pages/api/file/deleteblob.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
@@ -1249,6 +1376,7 @@ test("uploadsinglefile handler returns HASH_REQUIRED when hash missing", async (
|
||||
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "c1" } }),
|
||||
hashAPIPath: () => "?hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -1270,6 +1398,7 @@ test("uploadsinglefile handler dependency failure returns UPLOAD_SINGLE_FILE_FAI
|
||||
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "c1" } }),
|
||||
hashAPIPath: () => "?hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -1307,6 +1436,176 @@ test("uploadsinglefile handler dependency failure returns UPLOAD_SINGLE_FILE_FAI
|
||||
);
|
||||
});
|
||||
|
||||
const loadSingleFileUploadAuthorization = (overrides = {}) => {
|
||||
const calls = { upload: [], hash: [], fileReads: 0, typeChecks: 0 };
|
||||
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "user-container" } }),
|
||||
hashAPIPath: (queryPath) => {
|
||||
calls.hash.push(queryPath);
|
||||
return "?hash=expected";
|
||||
},
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
uploadSingleFile: async (...args) => {
|
||||
calls.upload.push(args);
|
||||
return [{ file: "document.pdf" }];
|
||||
},
|
||||
fileTypeFromBuffer: async () => {
|
||||
calls.typeChecks += 1;
|
||||
return { mime: "application/pdf" };
|
||||
},
|
||||
fs: {
|
||||
readFileSync: () => {
|
||||
calls.fileReads += 1;
|
||||
return Buffer.from("synthetic-pdf");
|
||||
}
|
||||
},
|
||||
path: { basename: (value) => value },
|
||||
...overrides
|
||||
});
|
||||
|
||||
return { mod, calls };
|
||||
};
|
||||
|
||||
const singleFileUploadAuthorizationRequest = (overrides = {}) => ({
|
||||
query: { hash: "expected" },
|
||||
body: {
|
||||
containerID: ["user-container"],
|
||||
casefolderID: ["CASE-1"]
|
||||
},
|
||||
files: {
|
||||
document: [
|
||||
{
|
||||
fieldName: "document.pdf",
|
||||
originalFilename: "document.pdf",
|
||||
path: "/tmp/document.pdf",
|
||||
size: 1,
|
||||
headers: { "content-type": "application/pdf" }
|
||||
}
|
||||
]
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
const assertNoSingleFileWork = (calls, { noHash = false } = {}) => {
|
||||
assert.strictEqual(calls.upload.length, 0);
|
||||
assert.strictEqual(calls.fileReads, 0);
|
||||
assert.strictEqual(calls.typeChecks, 0);
|
||||
if (noHash) assert.strictEqual(calls.hash.length, 0);
|
||||
};
|
||||
|
||||
test("uploadsinglefile rejects a missing NextAuth session before HMAC, file processing or Azure write", async () => {
|
||||
const loaded = loadSingleFileUploadAuthorization({
|
||||
getSession: async () => null
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
singleFileUploadAuthorizationRequest(),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 401);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
|
||||
assertNoSingleFileWork(loaded.calls, { noHash: true });
|
||||
});
|
||||
|
||||
test("uploadsinglefile rejects an unusable NextAuth session before HMAC, file processing or Azure write", async () => {
|
||||
const loaded = loadSingleFileUploadAuthorization({
|
||||
getSession: async () => ({ user: { id: "" } })
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
singleFileUploadAuthorizationRequest(),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 401);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED");
|
||||
assertNoSingleFileWork(loaded.calls, { noHash: true });
|
||||
});
|
||||
|
||||
test("uploadsinglefile forbids another user's container before HMAC, file processing or Azure write", async () => {
|
||||
const loaded = loadSingleFileUploadAuthorization();
|
||||
const req = singleFileUploadAuthorizationRequest();
|
||||
req.body.containerID = ["other-container"];
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(req, res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 403);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
||||
assertNoSingleFileWork(loaded.calls, { noHash: true });
|
||||
});
|
||||
|
||||
test("uploadsinglefile rejects invalid HMAC for the owning user before file processing or Azure write", async () => {
|
||||
const loaded = loadSingleFileUploadAuthorization({
|
||||
hashAPIPath: () => "?hash=different"
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
singleFileUploadAuthorizationRequest(),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
||||
assertNoSingleFileWork(loaded.calls);
|
||||
});
|
||||
|
||||
test("uploadsinglefile requires HMAC for the authenticated owning user", async () => {
|
||||
const loaded = loadSingleFileUploadAuthorization();
|
||||
const req = singleFileUploadAuthorizationRequest({ query: {} });
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(req, res);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 400);
|
||||
assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
|
||||
assertNoSingleFileWork(loaded.calls);
|
||||
});
|
||||
|
||||
test("uploadsinglefile preserves multipart owner success response and exact Azure helper arguments", async () => {
|
||||
const loaded = loadSingleFileUploadAuthorization();
|
||||
const res = createRes();
|
||||
|
||||
await loaded.mod.default.handler(
|
||||
singleFileUploadAuthorizationRequest(),
|
||||
res
|
||||
);
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 200);
|
||||
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
||||
data: [{ file: "document.pdf" }],
|
||||
invalidFiles: []
|
||||
});
|
||||
assert.strictEqual(loaded.calls.upload.length, 1);
|
||||
assert.strictEqual(loaded.calls.upload[0][1], "user-container");
|
||||
assert.strictEqual(loaded.calls.upload[0][2], "CASE-1");
|
||||
assert.deepStrictEqual(
|
||||
JSON.parse(JSON.stringify(loaded.calls.upload[0][0])),
|
||||
{
|
||||
document: [
|
||||
{
|
||||
fieldName: "document.pdf",
|
||||
originalFilename: "document.pdf",
|
||||
path: "/tmp/document.pdf",
|
||||
size: 1,
|
||||
headers: { "content-type": "application/pdf" },
|
||||
contentType: "application/pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
assert.strictEqual(loaded.calls.fileReads, 1);
|
||||
assert.strictEqual(loaded.calls.typeChecks, 1);
|
||||
});
|
||||
|
||||
const loadOrdinaryRepCompletion = (overrides = {}) => {
|
||||
const calls = { hash: [], queue: [] };
|
||||
const payload = { messageId: "ordinary-message" };
|
||||
@@ -2597,6 +2896,7 @@ test("upload handler success returns data payload with 200", async () => {
|
||||
const mod = loadModule("pages/api/file/upload.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "c1" } }),
|
||||
hashAPIPath: () => "?hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
@@ -2699,6 +2999,7 @@ test("upload handler dependency failure returns UPLOAD_FAILED", async () => {
|
||||
const mod = loadModule("pages/api/file/upload.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getSession: async () => ({ user: { id: "c1" } }),
|
||||
hashAPIPath: () => "?hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
|
||||
Reference in New Issue
Block a user