5746 lines
193 KiB
JavaScript
5746 lines
193 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const {
|
|
loadModule,
|
|
createRes,
|
|
createNextConnectMock,
|
|
respondSuccessMock,
|
|
respondErrorMock
|
|
} = require("./_shared.cjs");
|
|
|
|
const tests = [];
|
|
const test = (name, fn) => tests.push({ name, fn });
|
|
|
|
test("upload handler returns HASH_REQUIRED for missing hash", async () => {
|
|
const mod = loadModule("pages/api/file/upload.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "?hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createBlob: async () => ({ ok: true }),
|
|
createRepBlob: async () => ({ ok: true })
|
|
});
|
|
|
|
const req = { query: {}, body: {} };
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
|
|
});
|
|
|
|
test("upload handler returns INVALID_HASH for wrong hash", async () => {
|
|
const mod = loadModule("pages/api/file/upload.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "?hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createBlob: async () => ({ ok: true }),
|
|
createRepBlob: async () => ({ ok: true })
|
|
});
|
|
|
|
const req = {
|
|
query: { hash: "wrong" },
|
|
body: { containerID: ["c1"], casefolderID: ["f1"] }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
|
});
|
|
|
|
test("upload handler accepts alternate canonical hash candidate", async () => {
|
|
const mod = loadModule("pages/api/file/upload.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath === "/api/file/upload?"
|
|
? "?hash=expected"
|
|
: "?hash=other",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createBlob: async () => ({ id: "blob-alt" }),
|
|
createRepBlob: async () => ({ id: "rep-alt" })
|
|
});
|
|
|
|
const req = {
|
|
query: { hash: "expected" },
|
|
body: {
|
|
appealData: { key: "value" },
|
|
containerID: ["c1"],
|
|
casefolderID: ["f1"],
|
|
repOrAppeal: false
|
|
}
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
data: { id: "blob-alt" }
|
|
});
|
|
});
|
|
|
|
const loadUploadAuthorization = (overrides = {}) => {
|
|
const calls = {
|
|
session: [],
|
|
options: [],
|
|
createBlob: [],
|
|
createRepBlob: [],
|
|
hash: []
|
|
};
|
|
const options = { id: "pedw-upload-options" };
|
|
const mod = loadModule("pages/api/file/upload.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
authOptions: (req, res) => {
|
|
calls.options.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
calls.session.push(args);
|
|
return { 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, options };
|
|
};
|
|
|
|
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({
|
|
getServerSession: 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({
|
|
getServerSession: 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 passes the request, response and PEDW options to getServerSession", async () => {
|
|
const loaded = loadUploadAuthorization();
|
|
const req = uploadAuthorizationRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default.handler(req, res);
|
|
|
|
assert.deepStrictEqual(loaded.calls.options, [[req, res]]);
|
|
assert.strictEqual(loaded.calls.session.length, 1);
|
|
assert.strictEqual(loaded.calls.session[0][0], req);
|
|
assert.strictEqual(loaded.calls.session[0][1], res);
|
|
assert.strictEqual(loaded.calls.session[0][2], loaded.options);
|
|
});
|
|
|
|
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(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "container-1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
deleteBlob: async () => ({ ok: true })
|
|
});
|
|
|
|
const req = {
|
|
query: {
|
|
container: "container-1",
|
|
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, "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: () => {},
|
|
getServerSession: async () => ({ user: { id: "container-1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath.includes("/api/file/deleteblob?container=container-1")
|
|
? "&hash=expected"
|
|
: "&hash=other",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
deleteBlob: async (_container, blobPath) => {
|
|
deletedPath = blobPath;
|
|
return { deleted: true };
|
|
}
|
|
});
|
|
|
|
const req = {
|
|
query: {
|
|
container: "container-1",
|
|
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: () => {},
|
|
getServerSession: async () => ({ user: { id: "container-1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
deleteBlob: async () => {
|
|
throw new Error("delete failed");
|
|
}
|
|
});
|
|
|
|
const req = {
|
|
query: {
|
|
container: "container-1",
|
|
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: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
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: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
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: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
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: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
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");
|
|
});
|
|
|
|
const ordinaryDeletionCases = [
|
|
{
|
|
name: "deleteblobcase",
|
|
route: "pages/api/file/deleteblobcase.js",
|
|
query: {
|
|
container: "user-container",
|
|
casefolderID: "CASE-1",
|
|
hash: "expected"
|
|
},
|
|
dependency: "deleteBlobCase"
|
|
},
|
|
{
|
|
name: "deleteblobrep",
|
|
route: "pages/api/file/deleteblobrep.js",
|
|
query: {
|
|
container: "user-container",
|
|
casefolderID: "CASE-1",
|
|
repfile: "representation.json",
|
|
hash: "expected"
|
|
},
|
|
dependency: "deleteBlobRep"
|
|
}
|
|
];
|
|
|
|
for (const deletionCase of ordinaryDeletionCases) {
|
|
test(`${deletionCase.name} forbids a missing NextAuth session before deletion`, async () => {
|
|
let deletionCalls = 0;
|
|
const mod = loadModule(deletionCase.route, {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => null,
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
[deletionCase.dependency]: async () => {
|
|
deletionCalls += 1;
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler({ query: deletionCase.query }, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assert.strictEqual(deletionCalls, 0);
|
|
});
|
|
|
|
test(`${deletionCase.name} forbids a session without user id before deletion`, async () => {
|
|
let deletionCalls = 0;
|
|
const mod = loadModule(deletionCase.route, {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: {} }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
[deletionCase.dependency]: async () => {
|
|
deletionCalls += 1;
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler({ query: deletionCase.query }, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(deletionCalls, 0);
|
|
});
|
|
|
|
test(`${deletionCase.name} forbids cross-container deletion before HMAC or Azure deletion`, async () => {
|
|
let hashCalls = 0;
|
|
let deletionCalls = 0;
|
|
const mod = loadModule(deletionCase.route, {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "different-user" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => {
|
|
hashCalls += 1;
|
|
return "&hash=expected";
|
|
},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
[deletionCase.dependency]: async () => {
|
|
deletionCalls += 1;
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler({ query: deletionCase.query }, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(hashCalls, 0);
|
|
assert.strictEqual(deletionCalls, 0);
|
|
});
|
|
|
|
test(`${deletionCase.name} passes the request, response and PEDW options to getServerSession`, async () => {
|
|
const sessionCalls = [];
|
|
const optionCalls = [];
|
|
const options = { id: `${deletionCase.name}-options` };
|
|
const mod = loadModule(deletionCase.route, {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
authOptions: (req, res) => {
|
|
optionCalls.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
sessionCalls.push(args);
|
|
return { user: { id: "user-container" } };
|
|
},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
[deletionCase.dependency]: async () => ({ deleted: true })
|
|
});
|
|
const req = {
|
|
query: deletionCase.query,
|
|
body: { parsed: ["multipart"] }
|
|
};
|
|
const res = createRes();
|
|
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.deepStrictEqual(optionCalls, [[req, res]]);
|
|
assert.strictEqual(sessionCalls.length, 1);
|
|
assert.strictEqual(sessionCalls[0][0], req);
|
|
assert.strictEqual(sessionCalls[0][1], res);
|
|
assert.strictEqual(sessionCalls[0][2], options);
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
});
|
|
|
|
test(`${deletionCase.name} rejects invalid HMAC for the owning user without deletion`, async () => {
|
|
let deletionCalls = 0;
|
|
const mod = loadModule(deletionCase.route, {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "user-container" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=different",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
[deletionCase.dependency]: async () => {
|
|
deletionCalls += 1;
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler({ query: deletionCase.query }, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
|
assert.strictEqual(deletionCalls, 0);
|
|
});
|
|
|
|
test(`${deletionCase.name} preserves valid same-user deletion`, async () => {
|
|
const deletionCalls = [];
|
|
const mod = loadModule(deletionCase.route, {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "user-container" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
[deletionCase.dependency]: async (...args) => {
|
|
deletionCalls.push(args);
|
|
return { deleted: true };
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler({ query: deletionCase.query }, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(deletionCalls.length, 1);
|
|
assert.strictEqual(deletionCalls[0][0], "user-container");
|
|
});
|
|
}
|
|
|
|
const loadAdminDeletion = (overrides = {}) => {
|
|
let deletionCalls = 0;
|
|
const mod = loadModule("pages/api/admin/deleteblobrep.js", {
|
|
deleteBlobRep: async () => {
|
|
deletionCalls += 1;
|
|
return { deleted: true };
|
|
},
|
|
readAdminSessionCookie: () => "admin-token",
|
|
validateAdminSession: () => true,
|
|
hasTrustedSameOrigin: () => true,
|
|
setAdminAuthNoStore: () => {},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
...overrides
|
|
});
|
|
|
|
return { mod, getDeletionCalls: () => deletionCalls };
|
|
};
|
|
|
|
const validAdminDeletionRequest = () => ({
|
|
method: "POST",
|
|
headers: { origin: "https://portal.example.test" },
|
|
body: {
|
|
container: "ck-user-container",
|
|
casefolderID: "CASE-1",
|
|
repfile: "representation.json"
|
|
}
|
|
});
|
|
|
|
test("admin representation deletion forbids a missing administrative session", async () => {
|
|
const loaded = loadAdminDeletion({
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminDeletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assert.strictEqual(loaded.getDeletionCalls(), 0);
|
|
});
|
|
|
|
test("admin representation deletion forbids an invalid or expired administrative session", async () => {
|
|
const loaded = loadAdminDeletion({ validateAdminSession: () => false });
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminDeletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(loaded.getDeletionCalls(), 0);
|
|
});
|
|
|
|
for (const originCase of ["missing", "cross-origin"]) {
|
|
test(`admin representation deletion forbids ${originCase} Origin`, async () => {
|
|
const loaded = loadAdminDeletion({ hasTrustedSameOrigin: () => false });
|
|
const req = validAdminDeletionRequest();
|
|
if (originCase === "missing") delete req.headers.origin;
|
|
if (originCase === "cross-origin") {
|
|
req.headers.origin = "https://attacker.example";
|
|
}
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(loaded.getDeletionCalls(), 0);
|
|
});
|
|
}
|
|
|
|
test("ordinary user HMAC cannot authorize administrative representation deletion", async () => {
|
|
const loaded = loadAdminDeletion({
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false
|
|
});
|
|
const req = validAdminDeletionRequest();
|
|
req.body.hash = "ordinary-user-hmac";
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(loaded.getDeletionCalls(), 0);
|
|
});
|
|
|
|
test("admin representation deletion rejects malformed paths without deletion", async () => {
|
|
for (const malformedBody of [
|
|
{ casefolderID: "../CASE-1" },
|
|
{ repfile: "folder/representation.json" },
|
|
{ repfile: ".." }
|
|
]) {
|
|
const loaded = loadAdminDeletion();
|
|
const req = validAdminDeletionRequest();
|
|
req.body = { ...req.body, ...malformedBody };
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
|
assert.strictEqual(loaded.getDeletionCalls(), 0);
|
|
}
|
|
});
|
|
|
|
test("valid administrative session and origin authorize cross-container representation deletion", async () => {
|
|
const deletionCalls = [];
|
|
const loaded = loadAdminDeletion({
|
|
deleteBlobRep: async (...args) => {
|
|
deletionCalls.push(args);
|
|
return { deleted: args[1] };
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminDeletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(deletionCalls)), [
|
|
["ck-user-container", "CASE-1/representation.json"]
|
|
]);
|
|
});
|
|
|
|
test("admin storage caller uses dedicated POST deletion while ordinary caller remains signed GET", () => {
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
const adminSource = fs.readFileSync(
|
|
path.join(rootDir, "components/admin/tabs/storage.js"),
|
|
"utf8"
|
|
);
|
|
const ordinarySource = fs.readFileSync(
|
|
path.join(rootDir, "actions/services/documentDirectService.js"),
|
|
"utf8"
|
|
);
|
|
|
|
assert.match(adminSource, /fetch\("\/api\/admin\/deleteblobrep"/);
|
|
assert.match(adminSource, /method:\s*"POST"/);
|
|
assert.doesNotMatch(adminSource, /deleteMyRepresentationsFromBlob/);
|
|
assert.match(
|
|
ordinarySource,
|
|
/buildFileQuery\("\/api\/file\/deleteblobrep"[\s\S]*getSignedFileJson\(queryUrl\)/
|
|
);
|
|
});
|
|
|
|
const loadAdminCaseDeletion = (overrides = {}) => {
|
|
const calls = {
|
|
noStore: 0,
|
|
readSession: 0,
|
|
validateSession: 0,
|
|
origin: 0,
|
|
deletion: []
|
|
};
|
|
const payload = { deleted: "TMP-CASE-1" };
|
|
const mod = loadModule("pages/api/admin/deleteblobcase.js", {
|
|
deleteBlobCase: async (...args) => {
|
|
calls.deletion.push(args);
|
|
return payload;
|
|
},
|
|
readAdminSessionCookie: () => {
|
|
calls.readSession += 1;
|
|
return "admin-token";
|
|
},
|
|
validateAdminSession: () => {
|
|
calls.validateSession += 1;
|
|
return true;
|
|
},
|
|
hasTrustedSameOrigin: () => {
|
|
calls.origin += 1;
|
|
return true;
|
|
},
|
|
setAdminAuthNoStore: () => {
|
|
calls.noStore += 1;
|
|
},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, payload };
|
|
};
|
|
|
|
const validAdminCaseDeletionRequest = (overrides = {}) => ({
|
|
method: "POST",
|
|
headers: {
|
|
origin: "https://portal.example.test",
|
|
cookie: "pedw_admin_session=admin-token"
|
|
},
|
|
body: {
|
|
container: "selected-user-container",
|
|
casefolderID: "TMP-CASE-1"
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
for (const method of ["GET", "DELETE"]) {
|
|
test(`admin case deletion rejects ${method} with 405 before authorization or deletion`, async () => {
|
|
const loaded = loadAdminCaseDeletion();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
validAdminCaseDeletionRequest({ method }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.headers.allow, "POST");
|
|
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
|
|
assert.strictEqual(loaded.calls.noStore, 1);
|
|
assert.strictEqual(loaded.calls.readSession, 0);
|
|
assert.strictEqual(loaded.calls.deletion.length, 0);
|
|
});
|
|
}
|
|
|
|
for (const sessionCase of [
|
|
{ name: "missing", token: null },
|
|
{ name: "invalid", token: "invalid-token" },
|
|
{ name: "expired", token: "expired-token" }
|
|
]) {
|
|
test(`admin case deletion rejects ${sessionCase.name} administrative session before deletion`, async () => {
|
|
const loaded = loadAdminCaseDeletion({
|
|
readAdminSessionCookie: () => sessionCase.token,
|
|
validateAdminSession: () => false
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminCaseDeletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assert.strictEqual(loaded.calls.origin, 0);
|
|
assert.strictEqual(loaded.calls.deletion.length, 0);
|
|
});
|
|
}
|
|
|
|
for (const originCase of [
|
|
{ name: "missing", headers: { cookie: "pedw_admin_session=admin-token" } },
|
|
{
|
|
name: "cross-origin",
|
|
headers: {
|
|
origin: "https://attacker.example.test",
|
|
cookie: "pedw_admin_session=admin-token"
|
|
}
|
|
}
|
|
]) {
|
|
test(`admin case deletion rejects ${originCase.name} Origin before deletion`, async () => {
|
|
const loaded = loadAdminCaseDeletion({
|
|
hasTrustedSameOrigin: () => false
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
validAdminCaseDeletionRequest({ headers: originCase.headers }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(loaded.calls.deletion.length, 0);
|
|
});
|
|
}
|
|
|
|
for (const inputCase of [
|
|
{ name: "missing container", body: { container: undefined } },
|
|
{ name: "malformed container", body: { container: "Invalid_Container" } },
|
|
{ name: "missing case identifier", body: { casefolderID: undefined } },
|
|
{
|
|
name: "case identifier path separator",
|
|
body: { casefolderID: "TMP/CASE-1" }
|
|
},
|
|
{ name: "case identifier traversal segment", body: { casefolderID: ".." } },
|
|
{
|
|
name: "case identifier control character",
|
|
body: { casefolderID: "TMP\u0000CASE" }
|
|
}
|
|
]) {
|
|
test(`admin case deletion rejects ${inputCase.name} without deletion`, async () => {
|
|
const loaded = loadAdminCaseDeletion();
|
|
const request = validAdminCaseDeletionRequest();
|
|
const res = createRes();
|
|
|
|
request.body = { ...request.body, ...inputCase.body };
|
|
Object.keys(request.body).forEach((key) => {
|
|
if (typeof request.body[key] === "undefined")
|
|
delete request.body[key];
|
|
});
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
|
assert.strictEqual(loaded.calls.deletion.length, 0);
|
|
});
|
|
}
|
|
|
|
test("valid administrator can delete a cross-container case prefix with the existing helper", async () => {
|
|
const loaded = loadAdminCaseDeletion();
|
|
const request = validAdminCaseDeletionRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(loaded.calls.deletion, [
|
|
[request.body.container, request.body.casefolderID]
|
|
]);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
data: loaded.payload
|
|
});
|
|
});
|
|
|
|
test("admin case deletion dependency failure returns DELETE_BLOB_CASE_FAILED", async () => {
|
|
const loaded = loadAdminCaseDeletion({
|
|
deleteBlobCase: async (...args) => {
|
|
loaded.calls.deletion.push(args);
|
|
throw new Error("delete case failed");
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminCaseDeletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"DELETE_BLOB_CASE_FAILED"
|
|
);
|
|
assert.strictEqual(loaded.calls.deletion.length, 1);
|
|
});
|
|
|
|
test("admin storage case deletion uses dedicated POST route while ordinary callers remain signed GET", () => {
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
const adminSource = fs.readFileSync(
|
|
path.join(rootDir, "components/admin/tabs/storage.js"),
|
|
"utf8"
|
|
);
|
|
const ordinarySource = fs.readFileSync(
|
|
path.join(rootDir, "actions/services/documentDirectService.js"),
|
|
"utf8"
|
|
);
|
|
|
|
assert.match(adminSource, /fetch\(\s*"\/api\/admin\/deleteblobcase"/);
|
|
assert.match(
|
|
adminSource,
|
|
/"\/api\/admin\/deleteblobcase"[\s\S]*method:\s*"POST"[\s\S]*container:\s*c\.container[\s\S]*casefolderID:\s*folder\.folderPath/
|
|
);
|
|
assert.match(adminSource, /if \(!response\.ok\)/);
|
|
assert.doesNotMatch(adminSource, /deleteAwaitingSubmissionsFromBlob/);
|
|
assert.match(
|
|
ordinarySource,
|
|
/buildFileQuery\("\/api\/file\/deleteblobcase"[\s\S]*getSignedFileJson\(queryUrl\)/
|
|
);
|
|
});
|
|
|
|
const loadAdminRepJsonEdit = (overrides = {}) => {
|
|
let azureClientConstructions = 0;
|
|
const storageCalls = [];
|
|
const sourceJson = {
|
|
containerID: "ck-user-container",
|
|
ticketnumber: "CASE-1",
|
|
repComplete: true,
|
|
representationType: "Support",
|
|
nested: { retained: true }
|
|
};
|
|
const uploaded = {};
|
|
|
|
class DefaultAzureCredentialMock {}
|
|
|
|
class BlobServiceClientMock {
|
|
constructor(url, credential) {
|
|
azureClientConstructions += 1;
|
|
storageCalls.push(["construct", url, credential]);
|
|
}
|
|
|
|
getContainerClient(container) {
|
|
storageCalls.push(["container", container]);
|
|
return {
|
|
getBlockBlobClient(blobName) {
|
|
storageCalls.push(["blob", blobName]);
|
|
return {
|
|
async download(offset) {
|
|
storageCalls.push(["download", offset]);
|
|
const { Readable } = require("stream");
|
|
return {
|
|
readableStreamBody: Readable.from([
|
|
JSON.stringify(sourceJson)
|
|
])
|
|
};
|
|
},
|
|
async upload(content, length, options) {
|
|
uploaded.content = content;
|
|
uploaded.length = length;
|
|
uploaded.options = options;
|
|
storageCalls.push(["upload"]);
|
|
},
|
|
async setTags(tags) {
|
|
uploaded.tags = tags;
|
|
storageCalls.push(["tags"]);
|
|
},
|
|
async setMetadata(metadata) {
|
|
uploaded.metadata = metadata;
|
|
storageCalls.push(["metadata"]);
|
|
}
|
|
};
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
const mod = loadModule("pages/api/admin/editRepJson.js", {
|
|
Buffer,
|
|
DefaultAzureCredential: DefaultAzureCredentialMock,
|
|
BlobServiceClient: BlobServiceClientMock,
|
|
consoleLogger: () => {},
|
|
readAdminSessionCookie: () => "admin-token",
|
|
validateAdminSession: () => true,
|
|
hasTrustedSameOrigin: () => true,
|
|
setAdminAuthNoStore: () => {},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
...overrides
|
|
});
|
|
|
|
return {
|
|
mod,
|
|
getAzureClientConstructions: () => azureClientConstructions,
|
|
storageCalls,
|
|
sourceJson,
|
|
uploaded
|
|
};
|
|
};
|
|
|
|
const validAdminRepJsonEditRequest = () => ({
|
|
method: "POST",
|
|
headers: { origin: "https://portal.example.test" },
|
|
body: {
|
|
container: "ck-user-container",
|
|
blobName: "CASE-1/representation-1/representation_rep.json"
|
|
}
|
|
});
|
|
|
|
test("admin representation JSON edit forbids a missing administrative session before Azure access", async () => {
|
|
const loaded = loadAdminRepJsonEdit({
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminRepJsonEditRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
|
|
});
|
|
|
|
test("admin representation JSON edit forbids an invalid administrative session before Azure access", async () => {
|
|
const loaded = loadAdminRepJsonEdit({ validateAdminSession: () => false });
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminRepJsonEditRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
|
|
});
|
|
|
|
test("admin representation JSON edit forbids cross-origin requests before Azure access", async () => {
|
|
const loaded = loadAdminRepJsonEdit({ hasTrustedSameOrigin: () => false });
|
|
const req = validAdminRepJsonEditRequest();
|
|
req.headers.origin = "https://attacker.example";
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
|
|
});
|
|
|
|
test("admin representation JSON edit rejects unsupported methods before Azure access", async () => {
|
|
const loaded = loadAdminRepJsonEdit();
|
|
const req = validAdminRepJsonEditRequest();
|
|
req.method = "GET";
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
|
|
assert.strictEqual(res.state.headers.allow, "POST");
|
|
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
|
|
});
|
|
|
|
test("admin representation JSON edit rejects malformed identifiers before Azure access", async () => {
|
|
for (const malformedBody of [
|
|
{ container: "UPPERCASE" },
|
|
{ container: "ab" },
|
|
{ blobName: "../representation_rep.json" },
|
|
{ blobName: "CASE-1//representation_rep.json" },
|
|
{ blobName: "CASE-1\\representation_rep.json" },
|
|
{ blobName: " representation_rep.json" }
|
|
]) {
|
|
const loaded = loadAdminRepJsonEdit();
|
|
const req = validAdminRepJsonEditRequest();
|
|
req.body = { ...req.body, ...malformedBody };
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
|
assert.strictEqual(loaded.getAzureClientConstructions(), 0);
|
|
}
|
|
});
|
|
|
|
test("valid administrative representation JSON edit preserves the existing mutation contract", async () => {
|
|
const loaded = loadAdminRepJsonEdit();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(validAdminRepJsonEditRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
message: "repComplete removed successfully"
|
|
});
|
|
assert.strictEqual(loaded.getAzureClientConstructions(), 1);
|
|
assert.deepStrictEqual(loaded.storageCalls.slice(1, 4), [
|
|
["container", "ck-user-container"],
|
|
["blob", "CASE-1/representation-1/representation_rep.json"],
|
|
["download", 0]
|
|
]);
|
|
|
|
const updatedJson = JSON.parse(loaded.uploaded.content);
|
|
assert.strictEqual(Object.hasOwn(updatedJson, "repComplete"), false);
|
|
assert.strictEqual(updatedJson.containerID, loaded.sourceJson.containerID);
|
|
assert.strictEqual(
|
|
updatedJson.ticketnumber,
|
|
loaded.sourceJson.ticketnumber
|
|
);
|
|
assert.strictEqual(
|
|
updatedJson.representationType,
|
|
loaded.sourceJson.representationType
|
|
);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(updatedJson.nested)),
|
|
loaded.sourceJson.nested
|
|
);
|
|
assert.strictEqual(
|
|
loaded.uploaded.length,
|
|
Buffer.byteLength(loaded.uploaded.content)
|
|
);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(loaded.uploaded.options)),
|
|
{ blobHTTPHeaders: { blobContentType: "application/json" } }
|
|
);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(loaded.uploaded.tags)), {
|
|
containerid: "ck-user-container",
|
|
caseID: "CASE-1",
|
|
blobType: "Representation"
|
|
});
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(loaded.uploaded.metadata)),
|
|
JSON.parse(JSON.stringify(loaded.uploaded.tags))
|
|
);
|
|
});
|
|
|
|
test("retired file representation JSON endpoint cannot mutate storage", async () => {
|
|
let storageCalls = 0;
|
|
const mod = loadModule("pages/api/file/editRepJson.js", {
|
|
BlobServiceClient: class {
|
|
constructor() {
|
|
storageCalls += 1;
|
|
}
|
|
},
|
|
respondError: respondErrorMock
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default(validAdminRepJsonEditRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 410);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "ENDPOINT_RETIRED");
|
|
assert.strictEqual(storageCalls, 0);
|
|
});
|
|
|
|
test("admin storage caller uses the protected representation JSON edit endpoint", () => {
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
const adminSource = fs.readFileSync(
|
|
path.join(rootDir, "components/admin/tabs/storage.js"),
|
|
"utf8"
|
|
);
|
|
|
|
assert.match(adminSource, /fetch\(\s*"\/api\/admin\/editRepJson"/);
|
|
assert.doesNotMatch(adminSource, /fetch\(\s*"\/api\/file\/editRepJson"/);
|
|
});
|
|
|
|
test("deleteawaitingsubmissionfromblob accepts legacy deleteblob hash variant", async () => {
|
|
let deletedPath = null;
|
|
const mod = loadModule(
|
|
"pages/api/file/deleteawaitingsubmissionfromblob.js",
|
|
{
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "container-1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath.includes("/api/file/deleteblob?container=container-1")
|
|
? "&hash=expected"
|
|
: "&hash=other",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
deleteBlob: async (_container, blobPath) => {
|
|
deletedPath = blobPath;
|
|
return { deleted: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
const req = {
|
|
query: {
|
|
container: "container-1",
|
|
casefolderID: "CASE-1",
|
|
blobname: "CASE-1/files/doc.pdf",
|
|
hash: "expected"
|
|
}
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(deletedPath, "CASE-1/files/doc.pdf");
|
|
});
|
|
|
|
test("deleteawaitingsubmissionfromblob dependency failure returns DELETE_AWAITING_SUBMISSION_BLOB_FAILED", async () => {
|
|
const mod = loadModule(
|
|
"pages/api/file/deleteawaitingsubmissionfromblob.js",
|
|
{
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "container-1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
deleteBlob: async () => {
|
|
throw new Error("delete awaiting failed");
|
|
}
|
|
}
|
|
);
|
|
|
|
const req = {
|
|
query: {
|
|
container: "container-1",
|
|
casefolderID: "CASE-1",
|
|
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_AWAITING_SUBMISSION_BLOB_FAILED"
|
|
);
|
|
});
|
|
|
|
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(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
consoleLogger: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false,
|
|
getRepsBlobs: async () => [
|
|
{ path: "c/ref_rep.json", name: "ref_rep.json" }
|
|
],
|
|
downloadAllRepsFiles: async () => ({
|
|
"@odata.count": 1,
|
|
value: [{ id: "r1" }]
|
|
})
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
"@odata.count": 1,
|
|
value: [{ id: "r1" }]
|
|
});
|
|
});
|
|
|
|
test("getrepsblob handler rejects missing HMAC before Azure access", async () => {
|
|
let azureCalls = 0;
|
|
const mod = loadModule("pages/api/file/getrepsblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
consoleLogger: () => {},
|
|
getRepsBlobs: async () => {
|
|
azureCalls += 1;
|
|
return [];
|
|
},
|
|
downloadAllRepsFiles: async () => {
|
|
azureCalls += 1;
|
|
return {};
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler({ query: { container: "c1" } }, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY");
|
|
assert.strictEqual(azureCalls, 0);
|
|
});
|
|
|
|
test("getrepsblob handler rejects invalid HMAC before Azure access", async () => {
|
|
let azureCalls = 0;
|
|
const mod = loadModule("pages/api/file/getrepsblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
consoleLogger: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false,
|
|
getRepsBlobs: async () => {
|
|
azureCalls += 1;
|
|
return [];
|
|
},
|
|
downloadAllRepsFiles: async () => {
|
|
azureCalls += 1;
|
|
return {};
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler(
|
|
{ query: { container: "c1", hash: "wrong" } },
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
|
assert.strictEqual(azureCalls, 0);
|
|
});
|
|
|
|
test("general hash endpoint does not authorize getrepsblob signing", async () => {
|
|
let hashCalls = 0;
|
|
const mod = loadModule("pages/api/endpoint/gethash_api.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
getServerSession: async () => ({ user: { id: "user-container" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => {
|
|
hashCalls += 1;
|
|
return "&hash=unexpected";
|
|
},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock
|
|
});
|
|
const res = createRes();
|
|
|
|
await mod.default.handler(
|
|
{
|
|
query: {
|
|
path: "/api/file/getrepsblob?container=user-container"
|
|
}
|
|
},
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH_PATH");
|
|
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(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
consoleLogger: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false,
|
|
getRepsBlobs: async () => {
|
|
throw new Error("storage failure");
|
|
},
|
|
downloadAllRepsFiles: async () => ({})
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "GET_REPS_BLOB_FAILED");
|
|
});
|
|
|
|
test("getrepsblob handler accepts encoded container hash variant", async () => {
|
|
const mod = loadModule("pages/api/file/getrepsblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath.includes("container=cont%2F1")
|
|
? "&hash=expected"
|
|
: "&hash=other",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
consoleLogger: () => {},
|
|
getServerSession: async () => ({ user: { id: "cont/1" } }),
|
|
authOptions: () => ({}),
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false,
|
|
getRepsBlobs: async () => [{ path: "cont/rep1.json" }],
|
|
downloadAllRepsFiles: async () => ({ value: [{ id: "r-encoded" }] })
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "cont/1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
value: [{ id: "r-encoded" }]
|
|
});
|
|
});
|
|
|
|
test("uploadsinglefile handler returns HASH_REQUIRED when hash missing", async () => {
|
|
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "?hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
consoleLogger: () => {},
|
|
uploadSingleFile: async () => ({ uploaded: 0 }),
|
|
fileTypeFromBuffer: async () => ({ mime: "application/pdf" }),
|
|
fs: { readFileSync: () => Buffer.from("x") }
|
|
});
|
|
|
|
const req = { query: {}, body: {}, files: {} };
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED");
|
|
});
|
|
|
|
test("uploadsinglefile handler dependency failure returns UPLOAD_SINGLE_FILE_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "?hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
consoleLogger: () => {},
|
|
uploadSingleFile: async () => {
|
|
throw new Error("upload failed");
|
|
},
|
|
fileTypeFromBuffer: async () => ({ mime: "application/pdf" }),
|
|
fs: { readFileSync: () => Buffer.from("x") },
|
|
path: { basename: (value) => value }
|
|
});
|
|
|
|
const req = {
|
|
query: { hash: "expected" },
|
|
body: { containerID: ["c1"], casefolderID: ["f1"] },
|
|
files: {
|
|
document: [
|
|
{
|
|
fieldName: "document.pdf",
|
|
originalFilename: "document.pdf",
|
|
path: "/tmp/document.pdf",
|
|
size: 1,
|
|
headers: { "content-type": "application/pdf" }
|
|
}
|
|
]
|
|
}
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 500);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"UPLOAD_SINGLE_FILE_FAILED"
|
|
);
|
|
});
|
|
|
|
const loadSingleFileUploadAuthorization = (overrides = {}) => {
|
|
const calls = {
|
|
session: [],
|
|
options: [],
|
|
upload: [],
|
|
hash: [],
|
|
fileReads: 0,
|
|
typeChecks: 0
|
|
};
|
|
const options = { id: "pedw-request-options" };
|
|
const mod = loadModule("pages/api/file/uploadsinglefile.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
authOptions: (req, res) => {
|
|
calls.options.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
calls.session.push(args);
|
|
return { 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, options };
|
|
};
|
|
|
|
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({
|
|
getServerSession: 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({
|
|
getServerSession: 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 passes the multipart request, response and PEDW request-scoped options to getServerSession", async () => {
|
|
const loaded = loadSingleFileUploadAuthorization();
|
|
const req = singleFileUploadAuthorizationRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(loaded.calls.options.length, 1);
|
|
assert.strictEqual(loaded.calls.options[0][0], req);
|
|
assert.strictEqual(loaded.calls.options[0][1], res);
|
|
assert.strictEqual(loaded.calls.session.length, 1);
|
|
assert.strictEqual(loaded.calls.session[0][0], req);
|
|
assert.strictEqual(loaded.calls.session[0][1], res);
|
|
assert.strictEqual(loaded.calls.session[0][2], loaded.options);
|
|
});
|
|
|
|
test("installed getServerSession resolves a database session cookie without treating multipart body as a session update", async () => {
|
|
const { getServerSession } = require("next-auth/next");
|
|
const adapterCalls = [];
|
|
const callbackCalls = [];
|
|
const expires = new Date(Date.now() + 30 * 60 * 1000);
|
|
const req = {
|
|
method: "POST",
|
|
body: {
|
|
containerID: ["user-container"],
|
|
casefolderID: ["CASE-1"]
|
|
},
|
|
headers: { host: "localhost:3000" },
|
|
cookies: { "next-auth.session-token": "session-token" }
|
|
};
|
|
const responseHeaders = {};
|
|
const res = {
|
|
getHeader(name) {
|
|
return responseHeaders[name.toLowerCase()];
|
|
},
|
|
setHeader(name, value) {
|
|
responseHeaders[name.toLowerCase()] = value;
|
|
}
|
|
};
|
|
const user = {
|
|
id: "user-container",
|
|
name: "Owner",
|
|
email: "owner@example.test",
|
|
image: null
|
|
};
|
|
const session = {
|
|
sessionToken: "session-token",
|
|
userId: user.id,
|
|
expires
|
|
};
|
|
const options = {
|
|
secret: "test-secret",
|
|
session: {
|
|
strategy: "database",
|
|
maxAge: 30 * 60,
|
|
updateAge: 5 * 60
|
|
},
|
|
cookies: {
|
|
sessionToken: {
|
|
name: "next-auth.session-token",
|
|
options: {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
secure: false
|
|
}
|
|
}
|
|
},
|
|
adapter: {
|
|
async getSessionAndUser(sessionToken) {
|
|
adapterCalls.push(["getSessionAndUser", sessionToken]);
|
|
return { session, user };
|
|
},
|
|
async updateSession(updatedSession) {
|
|
adapterCalls.push(["updateSession", updatedSession]);
|
|
return updatedSession;
|
|
},
|
|
async deleteSession(sessionToken) {
|
|
adapterCalls.push(["deleteSession", sessionToken]);
|
|
}
|
|
},
|
|
callbacks: {
|
|
async session(callbackArguments) {
|
|
callbackCalls.push(callbackArguments);
|
|
return callbackArguments;
|
|
}
|
|
}
|
|
};
|
|
|
|
const resolvedSession = await getServerSession(req, res, options);
|
|
|
|
assert.deepStrictEqual(adapterCalls[0], [
|
|
"getSessionAndUser",
|
|
"session-token"
|
|
]);
|
|
assert.strictEqual(callbackCalls.length, 1);
|
|
assert.strictEqual(callbackCalls[0].user.id, "user-container");
|
|
assert.strictEqual(callbackCalls[0].newSession, undefined);
|
|
assert.strictEqual(resolvedSession.user.id, "user-container");
|
|
assert.deepStrictEqual(req.body, {
|
|
containerID: ["user-container"],
|
|
casefolderID: ["CASE-1"]
|
|
});
|
|
});
|
|
|
|
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" };
|
|
const mod = loadModule("pages/api/file/createrepcompletemessage_api.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getSession: async () => ({ user: { id: "user-container" } }),
|
|
hashAPIPath: (queryPath) => {
|
|
calls.hash.push(queryPath);
|
|
return "&hash=expected";
|
|
},
|
|
createRepCompleteMessage: async (...args) => {
|
|
calls.queue.push(args);
|
|
return payload;
|
|
},
|
|
consoleLogger: () => {},
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, payload };
|
|
};
|
|
|
|
const ordinaryRepCompletionRequest = (overrides = {}) => ({
|
|
method: "GET",
|
|
headers: {},
|
|
query: {
|
|
container: "user-container",
|
|
tempcaseref: "CAS-12345-A1B2C3",
|
|
repid: "2026-03-18-10:20:30_-_APP_-_Statement_-_Jones_Rob",
|
|
hash: "expected"
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
const assertNoCompletionWork = (calls, { noHash = false } = {}) => {
|
|
assert.strictEqual(calls.queue.length, 0);
|
|
if (noHash) assert.strictEqual(calls.hash.length, 0);
|
|
};
|
|
|
|
test("createrepcompletemessage rejects unsupported methods before authorization work", async () => {
|
|
const loaded = loadOrdinaryRepCompletion();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
ordinaryRepCompletionRequest({ method: "POST" }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.headers.allow, "GET");
|
|
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
|
|
assertNoCompletionWork(loaded.calls, { noHash: true });
|
|
});
|
|
|
|
for (const inputCase of [
|
|
{ name: "missing container", query: { container: undefined } },
|
|
{
|
|
name: "invalid container type",
|
|
query: { container: ["user-container"] }
|
|
},
|
|
{ name: "malformed container", query: { container: "Invalid_Container" } },
|
|
{ name: "missing case reference", query: { tempcaseref: undefined } },
|
|
{
|
|
name: "unsafe case-reference path separator",
|
|
query: { tempcaseref: "CAS/123" }
|
|
},
|
|
{
|
|
name: "unsafe case-reference traversal segment",
|
|
query: { tempcaseref: ".." }
|
|
},
|
|
{ name: "missing representation identifier", query: { repid: undefined } },
|
|
{
|
|
name: "unsafe representation path separator",
|
|
query: { repid: "rep\\file" }
|
|
},
|
|
{
|
|
name: "unsafe representation control character",
|
|
query: { repid: "rep\u0000file" }
|
|
}
|
|
]) {
|
|
test(`createrepcompletemessage rejects ${inputCase.name} before authorization work`, async () => {
|
|
const loaded = loadOrdinaryRepCompletion();
|
|
const request = ordinaryRepCompletionRequest();
|
|
const res = createRes();
|
|
|
|
request.query = { ...request.query, ...inputCase.query };
|
|
Object.keys(request.query).forEach((key) => {
|
|
if (typeof request.query[key] === "undefined")
|
|
delete request.query[key];
|
|
});
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoCompletionWork(loaded.calls, { noHash: true });
|
|
});
|
|
}
|
|
|
|
for (const sessionCase of [
|
|
{ name: "missing NextAuth 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(`createrepcompletemessage rejects ${sessionCase.name} before HMAC processing`, async () => {
|
|
const loaded = loadOrdinaryRepCompletion({
|
|
getSession: async () => sessionCase.session
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoCompletionWork(loaded.calls, { noHash: true });
|
|
});
|
|
}
|
|
|
|
test("createrepcompletemessage rejects cross-container ownership before HMAC processing", async () => {
|
|
const loaded = loadOrdinaryRepCompletion({
|
|
getSession: async () => ({ user: { id: "different-container" } })
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoCompletionWork(loaded.calls, { noHash: true });
|
|
});
|
|
|
|
test("administrative cookie alone cannot authorize ordinary representation completion", async () => {
|
|
const loaded = loadOrdinaryRepCompletion({ getSession: async () => null });
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
ordinaryRepCompletionRequest({
|
|
headers: { cookie: "pedw_admin_session=valid-admin-token" }
|
|
}),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoCompletionWork(loaded.calls, { noHash: true });
|
|
});
|
|
|
|
test("previously valid HMAC cannot bypass a missing current ordinary session", async () => {
|
|
const loaded = loadOrdinaryRepCompletion({ getSession: async () => null });
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoCompletionWork(loaded.calls, { noHash: true });
|
|
});
|
|
|
|
for (const hashCase of [
|
|
{ name: "missing HMAC", hash: undefined },
|
|
{ name: "invalid HMAC", hash: "wrong" }
|
|
]) {
|
|
test(`createrepcompletemessage rejects ${hashCase.name} without queue work`, async () => {
|
|
const loaded = loadOrdinaryRepCompletion();
|
|
const request = ordinaryRepCompletionRequest();
|
|
const res = createRes();
|
|
|
|
if (typeof hashCase.hash === "undefined") delete request.query.hash;
|
|
else request.query.hash = hashCase.hash;
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoCompletionWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("createrepcompletemessage accepts punctuation and preserves exact identifiers and response", async () => {
|
|
const loaded = loadOrdinaryRepCompletion();
|
|
const request = ordinaryRepCompletionRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(loaded.calls.queue, [
|
|
[
|
|
request.query.container,
|
|
request.query.tempcaseref,
|
|
request.query.repid
|
|
]
|
|
]);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
|
loaded.payload
|
|
);
|
|
});
|
|
|
|
test("createrepcompletemessage preserves encoded HMAC compatibility", async () => {
|
|
const loaded = loadOrdinaryRepCompletion({
|
|
hashAPIPath: (queryPath) => {
|
|
loaded.calls.hash.push(queryPath);
|
|
return queryPath.includes("repid=rep%20name%20%26%20notes.json")
|
|
? "&hash=expected"
|
|
: "&hash=other";
|
|
}
|
|
});
|
|
const request = ordinaryRepCompletionRequest();
|
|
request.query.repid = "rep name & notes.json";
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(loaded.calls.queue.length, 1);
|
|
assert.strictEqual(loaded.calls.hash.length, 2);
|
|
});
|
|
|
|
test("createrepcompletemessage dependency failure returns CREATE_REP_COMPLETE_MESSAGE_FAILED", async () => {
|
|
const loaded = loadOrdinaryRepCompletion({
|
|
createRepCompleteMessage: async (...args) => {
|
|
loaded.calls.queue.push(args);
|
|
throw new Error("rep message failed");
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepCompletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"CREATE_REP_COMPLETE_MESSAGE_FAILED"
|
|
);
|
|
assert.strictEqual(loaded.calls.queue.length, 1);
|
|
});
|
|
|
|
const loadAdminRepCompletion = (overrides = {}) => {
|
|
const calls = {
|
|
noStore: 0,
|
|
readSession: 0,
|
|
validate: 0,
|
|
origin: 0,
|
|
queue: []
|
|
};
|
|
const payload = { messageId: "admin-message" };
|
|
const mod = loadModule("pages/api/admin/createrepcompletemessage.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
setAdminAuthNoStore: () => {
|
|
calls.noStore += 1;
|
|
},
|
|
readAdminSessionCookie: () => {
|
|
calls.readSession += 1;
|
|
return "admin-token";
|
|
},
|
|
validateAdminSession: () => {
|
|
calls.validate += 1;
|
|
return true;
|
|
},
|
|
hasTrustedSameOrigin: () => {
|
|
calls.origin += 1;
|
|
return true;
|
|
},
|
|
createRepCompleteMessage: async (...args) => {
|
|
calls.queue.push(args);
|
|
return payload;
|
|
},
|
|
consoleLogger: () => {},
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, payload };
|
|
};
|
|
|
|
const adminRepCompletionRequest = (overrides = {}) => ({
|
|
method: "POST",
|
|
headers: {
|
|
origin: "https://portal.example.test",
|
|
cookie: "pedw_admin_session=admin-token"
|
|
},
|
|
body: {
|
|
container: "selected-user-container",
|
|
tempcaseref: "CAS-12345-A1B2C3",
|
|
repid: "2026-03-18-10:20:30_-_IP_-_Statement_-_Smith_Alex_rep.json"
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
test("admin representation completion rejects unsupported methods before authentication", async () => {
|
|
const loaded = loadAdminRepCompletion();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepCompletionRequest({ method: "GET" }), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.headers.allow, "POST");
|
|
assert.strictEqual(loaded.calls.noStore, 1);
|
|
assert.strictEqual(loaded.calls.readSession, 0);
|
|
assertNoCompletionWork(loaded.calls);
|
|
});
|
|
|
|
for (const sessionCase of [
|
|
{ name: "missing administrative session", token: null },
|
|
{ name: "invalid administrative session", token: "invalid-token" },
|
|
{ name: "expired administrative session", token: "expired-token" }
|
|
]) {
|
|
test(`admin representation completion rejects ${sessionCase.name}`, async () => {
|
|
const loaded = loadAdminRepCompletion({
|
|
readAdminSessionCookie: () => sessionCase.token,
|
|
validateAdminSession: () => false
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepCompletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(loaded.calls.origin, 0);
|
|
assertNoCompletionWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
for (const originCase of [
|
|
{
|
|
name: "missing Origin",
|
|
headers: { cookie: "pedw_admin_session=admin-token" }
|
|
},
|
|
{
|
|
name: "cross-origin request",
|
|
headers: {
|
|
origin: "https://attacker.example.test",
|
|
cookie: "pedw_admin_session=admin-token"
|
|
}
|
|
}
|
|
]) {
|
|
test(`admin representation completion rejects ${originCase.name}`, async () => {
|
|
const loaded = loadAdminRepCompletion({
|
|
hasTrustedSameOrigin: () => false
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
adminRepCompletionRequest({ headers: originCase.headers }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoCompletionWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
for (const inputCase of [
|
|
{ name: "missing container", body: { container: undefined } },
|
|
{ name: "malformed container", body: { container: "Invalid_Container" } },
|
|
{ name: "missing case reference", body: { tempcaseref: undefined } },
|
|
{ name: "unsafe case-reference segment", body: { tempcaseref: "CAS/123" } },
|
|
{ name: "missing representation identifier", body: { repid: undefined } },
|
|
{ name: "unsafe representation segment", body: { repid: "../rep.json" } }
|
|
]) {
|
|
test(`admin representation completion rejects ${inputCase.name} without queue work`, async () => {
|
|
const loaded = loadAdminRepCompletion();
|
|
const request = adminRepCompletionRequest();
|
|
const res = createRes();
|
|
|
|
request.body = { ...request.body, ...inputCase.body };
|
|
Object.keys(request.body).forEach((key) => {
|
|
if (typeof request.body[key] === "undefined")
|
|
delete request.body[key];
|
|
});
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoCompletionWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("ordinary NextAuth session alone cannot authorize admin representation completion", async () => {
|
|
const loaded = loadAdminRepCompletion({
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false,
|
|
getSession: async () => ({ user: { id: "selected-user-container" } })
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepCompletionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoCompletionWork(loaded.calls);
|
|
});
|
|
|
|
test("admin representation completion preserves cross-container identifiers and response", async () => {
|
|
const loaded = loadAdminRepCompletion();
|
|
const request = adminRepCompletionRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(loaded.calls.queue, [
|
|
[request.body.container, request.body.tempcaseref, request.body.repid]
|
|
]);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
|
loaded.payload
|
|
);
|
|
});
|
|
|
|
test("admin representation completion accepts legitimate filename punctuation", async () => {
|
|
const loaded = loadAdminRepCompletion();
|
|
const request = adminRepCompletionRequest();
|
|
request.body.repid = "rep name_-_notes.v2 (final)!.json";
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(loaded.calls.queue.length, 1);
|
|
});
|
|
|
|
const loadBlobListProxy = ({ session, ...overrides } = {}) => {
|
|
const calls = {
|
|
session: [],
|
|
options: [],
|
|
token: 0,
|
|
hash: [],
|
|
headers: [],
|
|
axios: []
|
|
};
|
|
const options = { id: "pedw-blob-list-options" };
|
|
const payload = { value: [{ id: "blob-1" }] };
|
|
const resolvedSession =
|
|
typeof session === "undefined"
|
|
? { user: { id: "user-container" } }
|
|
: session;
|
|
const mod = loadModule("pages/api/file/getbloblistproxy.js", {
|
|
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: (accessToken) => {
|
|
calls.headers.push(accessToken);
|
|
return {
|
|
headers: { Authorization: "Bearer synthetic-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 blobListProxyRequest = (overrides = {}) => ({
|
|
method: "GET",
|
|
query: { container: "user-container", casefolderID: "CASE/1" },
|
|
headers: {},
|
|
...overrides
|
|
});
|
|
|
|
const assertNoBlobListProxyWork = (calls) => {
|
|
assert.strictEqual(calls.token, 0);
|
|
assert.strictEqual(calls.hash.length, 0);
|
|
assert.strictEqual(calls.headers.length, 0);
|
|
assert.strictEqual(calls.axios.length, 0);
|
|
};
|
|
|
|
test("getbloblistproxy rejects unsupported methods before authentication or proxy work", async () => {
|
|
const loaded = loadBlobListProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(blobListProxyRequest({ method: "POST" }), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
|
|
assert.strictEqual(loaded.calls.options.length, 0);
|
|
assert.strictEqual(loaded.calls.session.length, 0);
|
|
assertNoBlobListProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("getbloblistproxy rejects a missing container before authentication or proxy work", async () => {
|
|
const loaded = loadBlobListProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
blobListProxyRequest({ query: { casefolderID: "CASE/1" } }),
|
|
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);
|
|
assertNoBlobListProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("getbloblistproxy rejects a malformed container before authentication or proxy work", async () => {
|
|
const loaded = loadBlobListProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
blobListProxyRequest({
|
|
query: { container: "Invalid_Container", casefolderID: "CASE/1" }
|
|
}),
|
|
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);
|
|
assertNoBlobListProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("getbloblistproxy rejects a missing casefolderID before authentication or proxy work", async () => {
|
|
const loaded = loadBlobListProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
blobListProxyRequest({ query: { container: "user-container" } }),
|
|
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);
|
|
assertNoBlobListProxyWork(loaded.calls);
|
|
});
|
|
|
|
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(`getbloblistproxy rejects ${sessionCase.name} before proxy work`, async () => {
|
|
const loaded = loadBlobListProxy({ session: sessionCase.session });
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(blobListProxyRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoBlobListProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("getbloblistproxy rejects cross-container access before proxy work", async () => {
|
|
const loaded = loadBlobListProxy({
|
|
session: { user: { id: "different-container" } }
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(blobListProxyRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoBlobListProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("getbloblistproxy preserves signed path, bearer header and response for owning user", async () => {
|
|
const loaded = loadBlobListProxy();
|
|
const req = blobListProxyRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
const expectedPath =
|
|
"/api/file/getbloblist?container=user-container&casefolderID=CASE%2F1";
|
|
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(loaded.calls.axios[0][1], {
|
|
headers: { Authorization: "Bearer synthetic-token" }
|
|
});
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
|
loaded.payload
|
|
);
|
|
});
|
|
|
|
test("getbloblistproxy dependency failure returns GET_BLOB_LIST_PROXY_FAILED", async () => {
|
|
const loaded = loadBlobListProxy({
|
|
axios: {
|
|
get: async (...args) => {
|
|
loaded.calls.axios.push(args);
|
|
throw new Error("proxy failed");
|
|
}
|
|
}
|
|
});
|
|
|
|
const res = createRes();
|
|
await loaded.mod.default(blobListProxyRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"GET_BLOB_LIST_PROXY_FAILED"
|
|
);
|
|
assert.strictEqual(loaded.calls.token, 1);
|
|
assert.strictEqual(loaded.calls.hash.length, 1);
|
|
assert.strictEqual(loaded.calls.headers.length, 1);
|
|
assert.strictEqual(loaded.calls.axios.length, 1);
|
|
});
|
|
|
|
const loadOrdinaryRepsProxy = (overrides = {}) => {
|
|
const calls = { session: [], options: [], token: 0, hash: [], axios: [] };
|
|
const options = { id: "pedw-representation-options" };
|
|
const payload = { "@odata.count": 1, value: [{ id: "r1" }] };
|
|
const mod = loadModule("pages/api/file/getrepsblobproxy.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
authOptions: (req, res) => {
|
|
calls.options.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
calls.session.push(args);
|
|
return { user: { id: "user-container" } };
|
|
},
|
|
getToken: async () => {
|
|
calls.token += 1;
|
|
return { access_token: "tok" };
|
|
},
|
|
azureHeaders: () => ({ headers: { Authorization: "Bearer tok" } }),
|
|
hashAPIPath: (queryPath) => {
|
|
calls.hash.push(queryPath);
|
|
return "&hash=abc";
|
|
},
|
|
consoleLogger: () => {},
|
|
BASE_URL: "http://example.com",
|
|
process: {
|
|
env: {
|
|
API_ROOT: "http://example.com",
|
|
NEXTAUTH_URL: "http://example.com"
|
|
}
|
|
},
|
|
axios: {
|
|
get: async (...args) => {
|
|
calls.axios.push(args);
|
|
return { data: payload };
|
|
}
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, options, payload };
|
|
};
|
|
|
|
const ordinaryRepsRequest = (overrides = {}) => ({
|
|
method: "GET",
|
|
query: { container: "user-container" },
|
|
headers: {},
|
|
...overrides
|
|
});
|
|
|
|
const assertNoProxyWork = (calls) => {
|
|
assert.strictEqual(calls.token, 0);
|
|
assert.strictEqual(calls.hash.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 } } }
|
|
]) {
|
|
test(`getrepsblobproxy rejects ${sessionCase.name} before proxy work`, async () => {
|
|
const loaded = loadOrdinaryRepsProxy({
|
|
getServerSession: async () => sessionCase.session
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepsRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("getrepsblobproxy rejects cross-container access before proxy work", async () => {
|
|
const loaded = loadOrdinaryRepsProxy({
|
|
getServerSession: async () => ({ user: { id: "different-container" } })
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepsRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("administrative cookie alone cannot authorize getrepsblobproxy", async () => {
|
|
const loaded = loadOrdinaryRepsProxy({
|
|
getServerSession: async () => null
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
ordinaryRepsRequest({
|
|
headers: { cookie: "pedw_admin_session=valid-admin-token" }
|
|
}),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("getrepsblobproxy rejects unsupported methods before proxy work", async () => {
|
|
const loaded = loadOrdinaryRepsProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepsRequest({ method: "POST" }), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
for (const containerCase of [
|
|
{ name: "missing container", query: {} },
|
|
{ name: "empty container", query: { container: "" } },
|
|
{ name: "null container", query: { container: null } },
|
|
{ name: "non-string container", query: { container: ["user-container"] } },
|
|
{ name: "malformed container", query: { container: "Invalid_Container" } }
|
|
]) {
|
|
test(`getrepsblobproxy rejects ${containerCase.name} before proxy work`, async () => {
|
|
const loaded = loadOrdinaryRepsProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
ordinaryRepsRequest({ query: containerCase.query }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("getrepsblobproxy preserves signed path and response for owning user", async () => {
|
|
const loaded = loadOrdinaryRepsProxy();
|
|
const req = ordinaryRepsRequest({
|
|
headers: { cookie: "next-auth.session-token=test-session" }
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(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.token, 1);
|
|
assert.deepStrictEqual(loaded.calls.hash, [
|
|
"/api/file/getrepsblob?container=user-container"
|
|
]);
|
|
assert.strictEqual(
|
|
loaded.calls.axios[0][0],
|
|
"http://example.com/api/file/getrepsblob?container=user-container&hash=abc"
|
|
);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
|
|
{
|
|
headers: {
|
|
Authorization: "Bearer tok",
|
|
Cookie: "next-auth.session-token=test-session"
|
|
},
|
|
maxRedirects: 0
|
|
}
|
|
);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
|
loaded.payload
|
|
);
|
|
});
|
|
|
|
test("getrepsblobproxy dependency failure returns GET_REPS_BLOB_PROXY_FAILED", async () => {
|
|
const loaded = loadOrdinaryRepsProxy({
|
|
axios: {
|
|
get: async (...args) => {
|
|
loaded.calls.axios.push(args);
|
|
throw new Error("representation proxy failed");
|
|
}
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(ordinaryRepsRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"GET_REPS_BLOB_PROXY_FAILED"
|
|
);
|
|
assert.strictEqual(loaded.calls.token, 1);
|
|
assert.strictEqual(loaded.calls.hash.length, 1);
|
|
assert.strictEqual(loaded.calls.axios.length, 1);
|
|
});
|
|
|
|
const loadAdminRepsProxy = (overrides = {}) => {
|
|
const calls = { token: 0, hash: [], axios: [] };
|
|
const payload = { "@odata.count": 1, value: [{ id: "admin-r1" }] };
|
|
const mod = loadModule("pages/api/admin/getrepsblobproxy.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
ADMIN_SESSION_COOKIE: "pedw_admin_session",
|
|
readAdminSessionCookie: () => "admin-token",
|
|
validateAdminSession: () => true,
|
|
hasTrustedSameOrigin: () => true,
|
|
setAdminAuthNoStore: () => {},
|
|
getToken: async () => {
|
|
calls.token += 1;
|
|
return { access_token: "tok" };
|
|
},
|
|
azureHeaders: () => ({ headers: { Authorization: "Bearer tok" } }),
|
|
hashAPIPath: (queryPath) => {
|
|
calls.hash.push(queryPath);
|
|
return "&hash=abc";
|
|
},
|
|
consoleLogger: () => {},
|
|
BASE_URL: "http://example.com",
|
|
process: {
|
|
env: {
|
|
API_ROOT: "http://example.com",
|
|
NEXTAUTH_URL: "http://example.com"
|
|
}
|
|
},
|
|
axios: {
|
|
get: async (...args) => {
|
|
calls.axios.push(args);
|
|
return { data: payload };
|
|
}
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, payload };
|
|
};
|
|
|
|
const adminRepsRequest = (overrides = {}) => ({
|
|
method: "POST",
|
|
headers: { origin: "https://portal.example.test" },
|
|
body: { container: "selected-user-container" },
|
|
...overrides
|
|
});
|
|
|
|
for (const adminSessionCase of [
|
|
{
|
|
name: "missing administrative session",
|
|
overrides: {
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false
|
|
}
|
|
},
|
|
{
|
|
name: "invalid administrative session",
|
|
overrides: { validateAdminSession: () => false }
|
|
},
|
|
{
|
|
name: "expired administrative session",
|
|
overrides: { validateAdminSession: () => false }
|
|
}
|
|
]) {
|
|
test(`admin getrepsblobproxy rejects ${adminSessionCase.name} before proxy work`, async () => {
|
|
const loaded = loadAdminRepsProxy(adminSessionCase.overrides);
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepsRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
for (const originCase of ["missing Origin", "cross-origin request"]) {
|
|
test(`admin getrepsblobproxy rejects ${originCase} before proxy work`, async () => {
|
|
const loaded = loadAdminRepsProxy({
|
|
hasTrustedSameOrigin: () => false
|
|
});
|
|
const req = adminRepsRequest();
|
|
if (originCase === "missing Origin") delete req.headers.origin;
|
|
if (originCase === "cross-origin request") {
|
|
req.headers.origin = "https://attacker.example";
|
|
}
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
for (const containerCase of [
|
|
{ name: "missing container", body: {} },
|
|
{ name: "malformed container", body: { container: "Invalid_Container" } }
|
|
]) {
|
|
test(`admin getrepsblobproxy rejects ${containerCase.name} before proxy work`, async () => {
|
|
const loaded = loadAdminRepsProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
adminRepsRequest({ body: containerCase.body }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("admin getrepsblobproxy rejects unsupported methods before proxy work", async () => {
|
|
const loaded = loadAdminRepsProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepsRequest({ method: "GET" }), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("ordinary NextAuth session alone cannot authorize admin getrepsblobproxy", async () => {
|
|
const loaded = loadAdminRepsProxy({
|
|
readAdminSessionCookie: () => null,
|
|
validateAdminSession: () => false,
|
|
getSession: async () => ({ user: { id: "selected-user-container" } })
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepsRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("admin getrepsblobproxy preserves cross-container signed path and response", async () => {
|
|
const loaded = loadAdminRepsProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepsRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(loaded.calls.token, 1);
|
|
assert.deepStrictEqual(loaded.calls.hash, [
|
|
"/api/file/getrepsblob?container=selected-user-container"
|
|
]);
|
|
assert.strictEqual(
|
|
loaded.calls.axios[0][0],
|
|
"http://example.com/api/file/getrepsblob?container=selected-user-container&hash=abc"
|
|
);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(loaded.calls.axios[0][1])),
|
|
{
|
|
headers: {
|
|
Authorization: "Bearer tok",
|
|
Cookie: "pedw_admin_session=admin-token"
|
|
},
|
|
maxRedirects: 0
|
|
}
|
|
);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
|
loaded.payload
|
|
);
|
|
});
|
|
|
|
test("admin storage refresh uses dedicated POST proxy while ordinary callers remain unchanged", () => {
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
const adminSource = fs.readFileSync(
|
|
path.join(rootDir, "components/admin/tabs/storage.js"),
|
|
"utf8"
|
|
);
|
|
const topThreeSource = fs.readFileSync(
|
|
path.join(rootDir, "components/myportal/topthree_reps.js"),
|
|
"utf8"
|
|
);
|
|
const viewAllSource = fs.readFileSync(
|
|
path.join(rootDir, "components/myportal/viewall.js"),
|
|
"utf8"
|
|
);
|
|
|
|
assert.match(adminSource, /fetch\("\/api\/admin\/getrepsblobproxy"/);
|
|
assert.match(
|
|
adminSource,
|
|
/fetch\("\/api\/admin\/getrepsblobproxy"[\s\S]*method:\s*"POST"[\s\S]*JSON\.stringify\(\{ container: containerID \}\)/
|
|
);
|
|
assert.doesNotMatch(adminSource, /getRepsFromBlobProxy/);
|
|
assert.match(topThreeSource, /getRepsFromBlobProxy\(containerID\)/);
|
|
assert.match(viewAllSource, /getRepsFromBlobProxy\(containerID\)/);
|
|
});
|
|
|
|
test("admin getrepsblobproxy rejects untrusted internal destination before proxy work", async () => {
|
|
const loaded = loadAdminRepsProxy({
|
|
BASE_URL: "https://attacker.example"
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(adminRepsRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("admin storage completion uses dedicated POST route while ordinary completion remains unchanged", () => {
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
const adminSource = fs.readFileSync(
|
|
path.join(rootDir, "components/admin/tabs/storage.js"),
|
|
"utf8"
|
|
);
|
|
const ordinarySource = fs.readFileSync(
|
|
path.join(
|
|
rootDir,
|
|
"components/case/representation/representationComplete.js"
|
|
),
|
|
"utf8"
|
|
);
|
|
|
|
assert.match(
|
|
adminSource,
|
|
/fetch\(\s*"\/api\/admin\/createrepcompletemessage",\s*\{\s*method:\s*"POST"/
|
|
);
|
|
assert.match(adminSource, /container:\s*c\.container/);
|
|
assert.match(
|
|
adminSource,
|
|
/tempcaseref:\s*folder\.repJsonBlob\.name\.split\(\s*"\/"\s*\)\[0\]/
|
|
);
|
|
assert.match(
|
|
adminSource,
|
|
/repid:\s*folder\.repJsonBlob\.name\.split\(\s*"\/"\s*\)\[1\]/
|
|
);
|
|
assert.match(adminSource, /if \(!response\.ok\)/);
|
|
assert.doesNotMatch(adminSource, /sendRepCompleteMessage/);
|
|
assert.match(ordinarySource, /sendRepCompleteMessage\(/);
|
|
});
|
|
|
|
const loadAwaitingSubmissionProxy = (overrides = {}) => {
|
|
const calls = { session: [], options: [], token: 0, hash: [], axios: [] };
|
|
const options = { id: "pedw-awaiting-submission-options" };
|
|
const payload = {
|
|
"@odata.count": 1,
|
|
value: [{ pinswg_name: "TMP-SYNTHETIC" }],
|
|
case: [{ ticketnumber: "TMP-SYNTHETIC" }]
|
|
};
|
|
const mod = loadModule(
|
|
"pages/api/file/getawaitingsubmissionfromblobproxy.js",
|
|
{
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
authOptions: (req, res) => {
|
|
calls.options.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
calls.session.push(args);
|
|
return { user: { id: "user-container" } };
|
|
},
|
|
getToken: async () => {
|
|
calls.token += 1;
|
|
return { access_token: "tok" };
|
|
},
|
|
azureHeaders: () => ({
|
|
headers: { Authorization: "Bearer tok" }
|
|
}),
|
|
hashAPIPath: (queryPath) => {
|
|
calls.hash.push(queryPath);
|
|
return "&hash=abc";
|
|
},
|
|
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 awaitingSubmissionRequest = (overrides = {}) => ({
|
|
method: "GET",
|
|
query: { container: "user-container" },
|
|
headers: {},
|
|
...overrides
|
|
});
|
|
|
|
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(`getawaitingsubmissionfromblobproxy rejects ${sessionCase.name} before proxy work`, async () => {
|
|
const loaded = loadAwaitingSubmissionProxy({
|
|
getServerSession: async () => sessionCase.session
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(awaitingSubmissionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("getawaitingsubmissionfromblobproxy rejects cross-container access before proxy work", async () => {
|
|
const loaded = loadAwaitingSubmissionProxy({
|
|
getServerSession: async () => ({ user: { id: "different-container" } })
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(awaitingSubmissionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
test("getawaitingsubmissionfromblobproxy rejects unsupported methods before proxy work", async () => {
|
|
const loaded = loadAwaitingSubmissionProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
awaitingSubmissionRequest({ method: "POST" }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 405);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
|
|
for (const containerCase of [
|
|
{ name: "missing container", query: {} },
|
|
{ name: "empty container", query: { container: "" } },
|
|
{ name: "null container", query: { container: null } },
|
|
{ name: "non-string container", query: { container: ["user-container"] } },
|
|
{ name: "malformed container", query: { container: "Invalid_Container" } }
|
|
]) {
|
|
test(`getawaitingsubmissionfromblobproxy rejects ${containerCase.name} before proxy work`, async () => {
|
|
const loaded = loadAwaitingSubmissionProxy();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
awaitingSubmissionRequest({ query: containerCase.query }),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoProxyWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("getawaitingsubmissionfromblobproxy preserves signed path and response for owning user", async () => {
|
|
const loaded = loadAwaitingSubmissionProxy();
|
|
const req = awaitingSubmissionRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(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.token, 1);
|
|
assert.deepStrictEqual(loaded.calls.hash, [
|
|
"/api/file/getawaitingsubmissionfromblob?container=user-container"
|
|
]);
|
|
assert.strictEqual(
|
|
loaded.calls.axios[0][0],
|
|
"http://example.com/api/file/getawaitingsubmissionfromblob?container=user-container&hash=abc"
|
|
);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(res.state.jsonBody)),
|
|
loaded.payload
|
|
);
|
|
});
|
|
|
|
test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING_SUBMISSION_PROXY_FAILED", async () => {
|
|
const loaded = loadAwaitingSubmissionProxy({
|
|
axios: {
|
|
get: async (...args) => {
|
|
loaded.calls.axios.push(args);
|
|
throw new Error("awaiting failed");
|
|
}
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(awaitingSubmissionRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"GET_AWAITING_SUBMISSION_PROXY_FAILED"
|
|
);
|
|
assert.strictEqual(loaded.calls.token, 1);
|
|
assert.strictEqual(loaded.calls.hash.length, 1);
|
|
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 {};
|
|
},
|
|
updateCrmContact: 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 }
|
|
]
|
|
]);
|
|
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" }),
|
|
azureHeaders: () => ({ headers: {} }),
|
|
hashAPIPath: () => "&hash=abc",
|
|
consoleLogger: () => {},
|
|
process: { env: { API_ROOT: "http://example.com" } },
|
|
axios: {
|
|
get: async () => {
|
|
throw new Error("create appeal proxy failed");
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
const req = {
|
|
query: { container: "container-1", tempcaseref: "TMP1" },
|
|
headers: { cookie: "next-auth.session-token=synthetic" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"CREATE_APPEAL_COMPLETE_MESSAGE_PROXY_FAILED"
|
|
);
|
|
});
|
|
|
|
test("createappealcompletemessage_api accepts encoded hash variant and returns success", async () => {
|
|
const mod = loadModule(
|
|
"pages/api/file/createappealcompletemessage_api.js",
|
|
{
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "container-1" } }),
|
|
authOptions: () => ({}),
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath.includes("tempcaseref=TMP%2F1")
|
|
? "&hash=expected"
|
|
: "&hash=other",
|
|
getProgressBlobs: async () => ({ path: "TMP/1/progress.json" }),
|
|
downloadProgressFile: async (_container, path) => {
|
|
if (path.includes("_case.json")) {
|
|
return {
|
|
"customerid_contact@odata.bind":
|
|
"/contacts(00000000-0000-0000-0000-000000000001)"
|
|
};
|
|
}
|
|
|
|
return {
|
|
pinswg_siteaddressline1: "Line 1",
|
|
pinswg_siteaddressline2: "",
|
|
pinswg_siteaddresstown: "Town",
|
|
pinswg_siteaddresscounty: "County",
|
|
pinswg_siteaddresspostcode: "CF1 1AA",
|
|
pinswg_lpaapplicationreference: "LPA-1",
|
|
pinswg_developmentdescription: "Desc",
|
|
pinswg_name: "tmp"
|
|
};
|
|
},
|
|
createBlob: async () => ({}),
|
|
getCaseBlob: async () => ({}),
|
|
createCaseCompleteMessage: async () => ({}),
|
|
updateCrmContact: async () => ({}),
|
|
consoleLogger: () => {},
|
|
_: { isEmpty: (v) => v === undefined || v === null || v === "" }
|
|
}
|
|
);
|
|
|
|
const req = {
|
|
query: {
|
|
container: "container-1",
|
|
tempcaseref: "TMP/1",
|
|
inv: "846040001",
|
|
hash: "expected"
|
|
}
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
status: "success"
|
|
});
|
|
});
|
|
|
|
test("createappealcompletemessage_api dependency failure returns CREATE_APPEAL_COMPLETE_MESSAGE_FAILED", async () => {
|
|
const mod = loadModule(
|
|
"pages/api/file/createappealcompletemessage_api.js",
|
|
{
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "owner-container" } }),
|
|
authOptions: () => ({}),
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
hashAPIPath: () => "&hash=expected",
|
|
getProgressBlobs: async () => {
|
|
throw new Error("progress failed");
|
|
},
|
|
consoleLogger: () => {},
|
|
_: { isEmpty: () => true }
|
|
}
|
|
);
|
|
|
|
const req = {
|
|
query: {
|
|
container: "owner-container",
|
|
tempcaseref: "TMP1",
|
|
hash: "expected"
|
|
}
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"CREATE_APPEAL_COMPLETE_MESSAGE_FAILED"
|
|
);
|
|
});
|
|
|
|
test("createcaseinvolvement returns record exists on 412 conflict", async () => {
|
|
const mod = loadModule("pages/api/file/createcaseinvolvement_api.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getToken: async () => ({ access_token: "tok" }),
|
|
hashAPIPath: () => "?hash=abc",
|
|
consoleLogger: () => {},
|
|
process: {
|
|
env: { CRMURL: "crm.example", RELAY_ROOT: "https://relay/" }
|
|
},
|
|
axios: async () => {
|
|
const error = new Error("exists");
|
|
error.status = 412;
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
const req = {
|
|
body: { contactid: "c1", incidentid: "i1" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
record: "exists"
|
|
});
|
|
});
|
|
|
|
test("createrepinvolvement dependency failure returns CREATE_REP_INVOLVEMENT_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/createrepinvolvement_api.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getToken: async () => ({ access_token: "tok" }),
|
|
getPersonalAccount: async () => ({}),
|
|
hashAPIPath: () => "?hash=abc",
|
|
consoleLogger: () => {},
|
|
process: {
|
|
env: {
|
|
CRMURL: "crm.example",
|
|
CRMURL_VERSION: "v9.2",
|
|
RELAY_ROOT: "https://relay/"
|
|
}
|
|
},
|
|
axios: async () => {
|
|
throw new Error("rep involvement failed");
|
|
}
|
|
});
|
|
|
|
const req = {
|
|
body: { contactid: "c1", incidentid: "i1" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"CREATE_REP_INVOLVEMENT_FAILED"
|
|
);
|
|
});
|
|
|
|
test("updatecase dependency failure returns UPDATE_CASE_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/updatecase_api.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getToken: async () => ({ access_token: "tok" }),
|
|
hashAPIPath: () => "?hash=abc",
|
|
consoleLogger: () => {},
|
|
process: { env: { RELAY_ROOT: "https://relay/" } },
|
|
axios: async () => {
|
|
throw new Error("update failed");
|
|
}
|
|
});
|
|
|
|
const req = {
|
|
query: {
|
|
updateFormCollection: "pinswg_forms",
|
|
appealObj: "obj1",
|
|
incident: "inc1"
|
|
},
|
|
body: { field: "value" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "UPDATE_CASE_FAILED");
|
|
});
|
|
|
|
test("getawaitingsubmissionfromblob handler success returns payload", async () => {
|
|
const mod = loadModule("pages/api/file/getawaitingsubmissionfromblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getAllProgressBlobs: async () => [{ path: "c1/case1_appeal.json" }],
|
|
downloadAllProgressFiles: async () => ({ value: [{ id: "a1" }] })
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
value: [{ id: "a1" }]
|
|
});
|
|
});
|
|
|
|
test("getawaitingsubmissionfromblob handler dependency failure returns GET_AWAITING_SUBMISSION_BLOB_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/getawaitingsubmissionfromblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getAllProgressBlobs: async () => {
|
|
throw new Error("progress fetch failed");
|
|
},
|
|
downloadAllProgressFiles: async () => ({})
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"GET_AWAITING_SUBMISSION_BLOB_FAILED"
|
|
);
|
|
});
|
|
|
|
test("getbloblist handler returns INVALID_HASH for mismatch", async () => {
|
|
const mod = loadModule("pages/api/file/getbloblist.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getBlobs: async () => ({ id: "x" }),
|
|
getRepsFilesBlobs: async () => ({ id: "x" })
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", casefolderID: "f1", hash: "wrong" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
|
});
|
|
|
|
test("getbloblist handler accepts encoded casefolder hash variant", async () => {
|
|
const mod = loadModule("pages/api/file/getbloblist.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath.includes("casefolderID=CASE%2FSUB")
|
|
? "&hash=expected"
|
|
: "&hash=other",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getBlobs: async () => ({ file: "a.pdf" }),
|
|
getRepsFilesBlobs: async () => ({ file: "rep.pdf" })
|
|
});
|
|
|
|
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.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
value: [{ file: "rep.pdf" }]
|
|
});
|
|
});
|
|
|
|
test("getbloblist handler dependency failure returns GET_BLOB_LIST_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/getbloblist.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getBlobs: async () => {
|
|
throw new Error("blob lookup failed");
|
|
},
|
|
getRepsFilesBlobs: async () => ({ file: "rep.pdf" })
|
|
});
|
|
|
|
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, "GET_BLOB_LIST_FAILED");
|
|
});
|
|
|
|
test("getprogressobjblob handler success returns payload", async () => {
|
|
const mod = loadModule("pages/api/file/getprogressobjblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getProgressBlobs: async () => ({ path: "CASE1/CASE1_appeal.json" }),
|
|
downloadProgressFile: async () => ({ id: "progress-1" })
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", casefolderID: "CASE1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
id: "progress-1"
|
|
});
|
|
});
|
|
|
|
test("getprogressobjblob handler accepts encoded casefolder hash variant", async () => {
|
|
const mod = loadModule("pages/api/file/getprogressobjblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath.includes("casefolderID=CASE%2FSUB")
|
|
? "&hash=expected"
|
|
: "&hash=other",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getProgressBlobs: async () => ({ path: "CASE/SUB/CASE_appeal.json" }),
|
|
downloadProgressFile: async () => ({ id: "progress-2" })
|
|
});
|
|
|
|
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.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
id: "progress-2"
|
|
});
|
|
});
|
|
|
|
test("getprogressobjblob handler dependency failure returns GET_PROGRESS_OBJ_BLOB_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/getprogressobjblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getProgressBlobs: async () => {
|
|
throw new Error("progress lookup failed");
|
|
},
|
|
downloadProgressFile: async () => ({})
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", casefolderID: "CASE1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"GET_PROGRESS_OBJ_BLOB_FAILED"
|
|
);
|
|
});
|
|
|
|
test("setupcontainer handler returns MISSING_REQUIRED_QUERY when ident missing", async () => {
|
|
const mod = loadModule("pages/api/file/setupcontainer.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
consoleLogger: () => {},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createContainer: async () => ({ ok: true })
|
|
});
|
|
|
|
const req = { query: { hash: "expected" } };
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "MISSING_REQUIRED_QUERY");
|
|
});
|
|
|
|
test("setupcontainer handler accepts encoded ident hash variant", async () => {
|
|
const mod = loadModule("pages/api/file/setupcontainer.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: (queryPath) =>
|
|
queryPath.includes("ident=container%2Fname")
|
|
? "&hash=expected"
|
|
: "&hash=other",
|
|
consoleLogger: () => {},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createContainer: async () => ({ created: true })
|
|
});
|
|
|
|
const req = { query: { ident: "container/name", hash: "expected" } };
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
data: "success",
|
|
output: { created: true }
|
|
});
|
|
});
|
|
|
|
test("upload handler success returns data payload with 200", async () => {
|
|
const mod = loadModule("pages/api/file/upload.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "?hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createBlob: async () => ({ id: "blob-1" }),
|
|
createRepBlob: async () => ({ id: "rep-1" })
|
|
});
|
|
|
|
const req = {
|
|
query: { hash: "expected" },
|
|
body: {
|
|
appealData: { key: "value" },
|
|
containerID: ["c1"],
|
|
casefolderID: ["f1"],
|
|
repOrAppeal: false
|
|
}
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
data: { id: "blob-1" }
|
|
});
|
|
});
|
|
|
|
test("setupcontainer handler success returns expected contract", async () => {
|
|
const mod = loadModule("pages/api/file/setupcontainer.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
consoleLogger: () => {},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createContainer: async () => ({ created: true })
|
|
});
|
|
|
|
const req = { query: { ident: "cont-1", hash: "expected" } };
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
data: "success",
|
|
output: { created: true }
|
|
});
|
|
});
|
|
|
|
test("getbloblist handler success returns value array contract", async () => {
|
|
const mod = loadModule("pages/api/file/getbloblist.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
getBlobs: async () => ({ file: "a.pdf" }),
|
|
getRepsFilesBlobs: async () => ({ file: "rep.pdf" })
|
|
});
|
|
|
|
const req = {
|
|
query: { container: "c1", casefolderID: "f1", hash: "expected" }
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
|
value: [{ file: "a.pdf" }]
|
|
});
|
|
});
|
|
|
|
test("downloadblob handler success sets attachment header and returns body", async () => {
|
|
const mod = loadModule("pages/api/file/downloadblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
downloadFile: async () => Buffer.from("file-content")
|
|
});
|
|
|
|
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, 200);
|
|
assert.strictEqual(
|
|
res.state.headers["content-disposition"],
|
|
"attachment; filename=doc.pdf"
|
|
);
|
|
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(),
|
|
middleware: () => {},
|
|
getServerSession: async () => ({ user: { id: "c1" } }),
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "?hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createBlob: async () => {
|
|
throw new Error("storage down");
|
|
},
|
|
createRepBlob: async () => ({ id: "rep-1" })
|
|
});
|
|
|
|
const req = {
|
|
query: { hash: "expected" },
|
|
body: {
|
|
appealData: { key: "value" },
|
|
containerID: ["c1"],
|
|
casefolderID: ["f1"],
|
|
repOrAppeal: false
|
|
}
|
|
};
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "UPLOAD_FAILED");
|
|
});
|
|
|
|
test("setupcontainer dependency failure returns SETUP_CONTAINER_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/setupcontainer.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
consoleLogger: () => {},
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
createContainer: async () => {
|
|
throw new Error("container failed");
|
|
}
|
|
});
|
|
|
|
const req = { query: { ident: "cont-1", hash: "expected" } };
|
|
const res = createRes();
|
|
await mod.default.handler(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "SETUP_CONTAINER_FAILED");
|
|
});
|
|
|
|
test("downloadblob dependency failure returns DOWNLOAD_BLOB_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/downloadblob.js", {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
downloadFile: async () => {
|
|
throw new Error("download 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, "DOWNLOAD_BLOB_FAILED");
|
|
});
|
|
|
|
test("generateappealpdfcopy returns INCIDENT_ID_REQUIRED when docProps missing", async () => {
|
|
const mod = loadModule("pages/api/file/generateappealpdfcopy.js", {
|
|
respondError: respondErrorMock,
|
|
getCaseByID: async () => [],
|
|
getPortalModuleDetails: async () => ({ value: [{}] }),
|
|
getAppealPDFDocs: async () => [],
|
|
getFormCollectionByID: () => ({ UrlName: "planningappeals78" }),
|
|
getPickLists: async () => ({}),
|
|
planningappeals78_pdf: () => ({}),
|
|
other_pdf: () => ({}),
|
|
pdf: () => ({ toBuffer: async () => Buffer.from("pdf") })
|
|
});
|
|
|
|
const req = { body: {} };
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INCIDENT_ID_REQUIRED");
|
|
});
|
|
|
|
test("generateappealpdfcopy returns CASE_NOT_FOUND when case lookup is empty", async () => {
|
|
const mod = loadModule("pages/api/file/generateappealpdfcopy.js", {
|
|
respondError: respondErrorMock,
|
|
getCaseByID: async () => [],
|
|
getPortalModuleDetails: async () => ({ value: [{}] }),
|
|
getAppealPDFDocs: async () => [],
|
|
getFormCollectionByID: () => ({ UrlName: "planningappeals78" }),
|
|
getPickLists: async () => ({}),
|
|
planningappeals78_pdf: () => ({}),
|
|
other_pdf: () => ({}),
|
|
pdf: () => ({ toBuffer: async () => Buffer.from("pdf") })
|
|
});
|
|
|
|
const req = { body: { docProps: "i1" } };
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 404);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "CASE_NOT_FOUND");
|
|
});
|
|
|
|
test("generateappealpdfcopy returns FORM_COLLECTION_NOT_FOUND when collection is unresolved", async () => {
|
|
const mod = loadModule("pages/api/file/generateappealpdfcopy.js", {
|
|
respondError: respondErrorMock,
|
|
getCaseByID: async () => [
|
|
{
|
|
pinswg_appealcasetype: 846040000,
|
|
ticketnumber: "CAS-1",
|
|
description: "desc"
|
|
}
|
|
],
|
|
getPortalModuleDetails: async () => ({ value: [{}] }),
|
|
getAppealPDFDocs: async () => [],
|
|
getFormCollectionByID: () => null,
|
|
getPickLists: async () => ({}),
|
|
planningappeals78_pdf: () => ({}),
|
|
other_pdf: () => ({}),
|
|
pdf: () => ({ toBuffer: async () => Buffer.from("pdf") })
|
|
});
|
|
|
|
const req = { body: { docProps: "i1" } };
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"FORM_COLLECTION_NOT_FOUND"
|
|
);
|
|
});
|
|
|
|
test("generateappealpdfcopy success returns pdf buffer and headers", async () => {
|
|
const mod = loadModule("pages/api/file/generateappealpdfcopy.js", {
|
|
respondError: respondErrorMock,
|
|
getCaseByID: async () => [
|
|
{
|
|
pinswg_appealcasetype: 846040000,
|
|
ticketnumber: "CAS-1",
|
|
description: "desc"
|
|
}
|
|
],
|
|
getPortalModuleDetails: async () => ({ value: [{}] }),
|
|
getAppealPDFDocs: async () => [{ id: "d1" }],
|
|
getFormCollectionByID: () => ({
|
|
UrlName: "planningappeals78",
|
|
LogicalCollectionName: "pinswg_planningappeals78s"
|
|
}),
|
|
getPickLists: async () => ({ a: 1 }),
|
|
planningappeals78_pdf: () => ({ type: "pdfComponent" }),
|
|
other_pdf: () => ({ type: "otherComponent" }),
|
|
pdf: () => ({ toBuffer: async () => Buffer.from("pdf-content") })
|
|
});
|
|
|
|
const req = { body: { docProps: "i1" } };
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.headers["content-type"], "application/pdf");
|
|
assert.strictEqual(
|
|
typeof res.state.headers["content-disposition"],
|
|
"string"
|
|
);
|
|
assert.strictEqual(res.state.sentBody.toString(), "pdf-content");
|
|
});
|
|
|
|
test("generateappealpdfcopy catch path returns APPEAL_PDF_COPY_GENERATION_FAILED", async () => {
|
|
const mod = loadModule("pages/api/file/generateappealpdfcopy.js", {
|
|
respondError: respondErrorMock,
|
|
getCaseByID: async () => [
|
|
{
|
|
pinswg_appealcasetype: 846040000,
|
|
ticketnumber: "CAS-1",
|
|
description: "desc"
|
|
}
|
|
],
|
|
getPortalModuleDetails: async () => ({ value: [{}] }),
|
|
getAppealPDFDocs: async () => [{ id: "d1" }],
|
|
getFormCollectionByID: () => ({
|
|
UrlName: "planningappeals78",
|
|
LogicalCollectionName: "pinswg_planningappeals78s"
|
|
}),
|
|
getPickLists: async () => ({ a: 1 }),
|
|
planningappeals78_pdf: () => ({ type: "pdfComponent" }),
|
|
other_pdf: () => ({ type: "otherComponent" }),
|
|
pdf: () => ({
|
|
toBuffer: async () => {
|
|
throw new Error("pdf failure");
|
|
}
|
|
})
|
|
});
|
|
|
|
const req = { body: { docProps: "i1" } };
|
|
const res = createRes();
|
|
await mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"APPEAL_PDF_COPY_GENERATION_FAILED"
|
|
);
|
|
});
|
|
|
|
const PDF_OWNER_CONTAINER = "owner-container";
|
|
const PDF_CASE_FOLDER = "CASE-1";
|
|
|
|
const loadGeneratePdf = (overrides = {}) => {
|
|
const { Readable } = require("stream");
|
|
const calls = {
|
|
session: [],
|
|
options: [],
|
|
hash: [],
|
|
render: [],
|
|
create: []
|
|
};
|
|
const options = { id: "generate-pdf-options" };
|
|
const mod = loadModule("pages/api/file/generatepdf.js", {
|
|
Buffer,
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
authOptions: (req, res) => {
|
|
calls.options.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
calls.session.push(args);
|
|
return { user: { id: PDF_OWNER_CONTAINER } };
|
|
},
|
|
hashAPIPath: (queryPath) => {
|
|
calls.hash.push(queryPath);
|
|
return queryPath.includes("?")
|
|
? "&hash=expected"
|
|
: "?hash=expected";
|
|
},
|
|
consoleLogger: () => {},
|
|
ReactPDF: {
|
|
renderToStream: async (...args) => {
|
|
calls.render.push(args);
|
|
return Readable.from(["pdf"]);
|
|
}
|
|
},
|
|
createRepPDFBlob: async (...args) => {
|
|
calls.create.push(args);
|
|
return { id: "representation-pdf" };
|
|
},
|
|
other_pdf: () => ({ type: "other" }),
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, options };
|
|
};
|
|
|
|
const generatePdfRequest = (overrides = {}) => ({
|
|
method: "POST",
|
|
query: { hash: "expected" },
|
|
body: {
|
|
representationType: "Other",
|
|
repfile_name: "rep-file",
|
|
containerID: PDF_OWNER_CONTAINER,
|
|
caseRef: PDF_CASE_FOLDER
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
const loadGenerateAppealPdf = (overrides = {}) => {
|
|
const { Readable } = require("stream");
|
|
const calls = {
|
|
session: [],
|
|
options: [],
|
|
hash: [],
|
|
temp: [],
|
|
progress: [],
|
|
download: [],
|
|
pickLists: [],
|
|
render: [],
|
|
create: []
|
|
};
|
|
const options = { id: "generate-appeal-pdf-options" };
|
|
const mod = loadModule("pages/api/file/generateappealpdf.js", {
|
|
Buffer,
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
authOptions: (req, res) => {
|
|
calls.options.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
calls.session.push(args);
|
|
return { user: { id: PDF_OWNER_CONTAINER } };
|
|
},
|
|
hashAPIPath: (queryPath) => {
|
|
calls.hash.push(queryPath);
|
|
return "&hash=expected";
|
|
},
|
|
getTempCaseBlob: async (...args) => {
|
|
calls.temp.push(args);
|
|
return { ticketnumber: PDF_CASE_FOLDER };
|
|
},
|
|
getProgressBlobs: async (...args) => {
|
|
calls.progress.push(args);
|
|
return { path: `${PDF_CASE_FOLDER}/progress.json` };
|
|
},
|
|
downloadProgressFile: async (...args) => {
|
|
calls.download.push(args);
|
|
return {
|
|
pinswg_name: PDF_CASE_FOLDER,
|
|
pinswg_appealcasetype: "846040000"
|
|
};
|
|
},
|
|
getPickLists: async (...args) => {
|
|
calls.pickLists.push(args);
|
|
return {};
|
|
},
|
|
planningappeals78_pdf: () => ({ type: "planning" }),
|
|
other_pdf: () => ({ type: "other" }),
|
|
ReactPDF: {
|
|
renderToStream: async (...args) => {
|
|
calls.render.push(args);
|
|
return Readable.from(["pdf"]);
|
|
}
|
|
},
|
|
createAppealPDFBlob: async (...args) => {
|
|
calls.create.push(args);
|
|
return { id: "appeal-pdf" };
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, options };
|
|
};
|
|
|
|
const generateAppealPdfRequest = (overrides = {}) => ({
|
|
method: "POST",
|
|
query: { hash: "expected", appealType: "846040000" },
|
|
body: {
|
|
containerID: PDF_OWNER_CONTAINER,
|
|
casefolderID: PDF_CASE_FOLDER,
|
|
filesList: []
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
const assertNoGeneratePdfWork = (calls) => {
|
|
assert.strictEqual(calls.render.length, 0);
|
|
assert.strictEqual(calls.create.length, 0);
|
|
};
|
|
|
|
const assertNoGenerateAppealPdfWork = (calls) => {
|
|
assert.strictEqual(calls.temp.length, 0);
|
|
assert.strictEqual(calls.progress.length, 0);
|
|
assert.strictEqual(calls.download.length, 0);
|
|
assert.strictEqual(calls.pickLists.length, 0);
|
|
assert.strictEqual(calls.render.length, 0);
|
|
assert.strictEqual(calls.create.length, 0);
|
|
};
|
|
|
|
for (const endpoint of [
|
|
{
|
|
name: "generatepdf",
|
|
load: loadGeneratePdf,
|
|
request: generatePdfRequest,
|
|
assertNoWork: assertNoGeneratePdfWork
|
|
},
|
|
{
|
|
name: "generateappealpdf",
|
|
load: loadGenerateAppealPdf,
|
|
request: generateAppealPdfRequest,
|
|
assertNoWork: assertNoGenerateAppealPdfWork
|
|
}
|
|
]) {
|
|
for (const sessionCase of [
|
|
{ name: "missing session", session: null },
|
|
{ name: "missing session user id", session: { user: {} } },
|
|
{ name: "empty session user id", session: { user: { id: "" } } },
|
|
{
|
|
name: "non-string session user id",
|
|
session: { user: { id: 123 } }
|
|
}
|
|
]) {
|
|
test(`${endpoint.name} rejects ${sessionCase.name} before privileged work`, async () => {
|
|
const loaded = endpoint.load({
|
|
getServerSession: async () => sessionCase.session
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(endpoint.request(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
endpoint.assertNoWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test(`${endpoint.name} rejects another user's container before privileged work`, async () => {
|
|
const loaded = endpoint.load({
|
|
getServerSession: async () => ({
|
|
user: { id: "different-container" }
|
|
})
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(endpoint.request(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
endpoint.assertNoWork(loaded.calls);
|
|
});
|
|
|
|
test(`${endpoint.name} ignores a client-provided user identity`, async () => {
|
|
const loaded = endpoint.load({
|
|
getServerSession: async () => ({
|
|
user: { id: "different-container" }
|
|
})
|
|
});
|
|
const req = endpoint.request();
|
|
req.body.userID = PDF_OWNER_CONTAINER;
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
endpoint.assertNoWork(loaded.calls);
|
|
});
|
|
|
|
test(`${endpoint.name} passes req, res and request-scoped auth options to getServerSession`, async () => {
|
|
const loaded = endpoint.load();
|
|
const req = endpoint.request();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.deepStrictEqual(loaded.calls.options, [[req, res]]);
|
|
assert.deepStrictEqual(loaded.calls.session, [
|
|
[req, res, loaded.options]
|
|
]);
|
|
});
|
|
}
|
|
|
|
for (const containerCase of [
|
|
{ name: "missing", value: undefined },
|
|
{ name: "invalid", value: "Invalid_Container" },
|
|
{ name: "ambiguous", value: [PDF_OWNER_CONTAINER] }
|
|
]) {
|
|
test(`generatepdf rejects ${containerCase.name} container before privileged work`, async () => {
|
|
const loaded = loadGeneratePdf();
|
|
const req = generatePdfRequest();
|
|
if (typeof containerCase.value === "undefined") {
|
|
delete req.body.containerID;
|
|
} else {
|
|
req.body.containerID = containerCase.value;
|
|
}
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoGeneratePdfWork(loaded.calls);
|
|
});
|
|
|
|
test(`generateappealpdf rejects ${containerCase.name} container before privileged work`, async () => {
|
|
const loaded = loadGenerateAppealPdf();
|
|
const req = generateAppealPdfRequest();
|
|
if (typeof containerCase.value === "undefined") {
|
|
delete req.body.containerID;
|
|
} else {
|
|
req.body.containerID = containerCase.value;
|
|
}
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoGenerateAppealPdfWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("generatepdf rejects missing draft-folder identifiers", async () => {
|
|
const loaded = loadGeneratePdf();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
generatePdfRequest({
|
|
body: {
|
|
representationType: "Other",
|
|
repfile_name: "rep-file",
|
|
containerID: PDF_OWNER_CONTAINER
|
|
}
|
|
}),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoGeneratePdfWork(loaded.calls);
|
|
});
|
|
|
|
test("generateappealpdf rejects missing or malformed draft-folder identifiers", async () => {
|
|
for (const value of [undefined, "../CASE-1", [PDF_CASE_FOLDER]]) {
|
|
const loaded = loadGenerateAppealPdf();
|
|
const req = generateAppealPdfRequest();
|
|
if (typeof value === "undefined") {
|
|
delete req.body.casefolderID;
|
|
} else {
|
|
req.body.casefolderID = value;
|
|
}
|
|
const res = createRes();
|
|
await loaded.mod.default(req, res);
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoGenerateAppealPdfWork(loaded.calls);
|
|
}
|
|
});
|
|
|
|
for (const endpoint of [
|
|
{
|
|
name: "generatepdf",
|
|
load: loadGeneratePdf,
|
|
request: generatePdfRequest,
|
|
assertNoWork: assertNoGeneratePdfWork
|
|
},
|
|
{
|
|
name: "generateappealpdf",
|
|
load: loadGenerateAppealPdf,
|
|
request: generateAppealPdfRequest,
|
|
assertNoWork: assertNoGenerateAppealPdfWork
|
|
}
|
|
]) {
|
|
test(`${endpoint.name} rejects a query/body container ambiguity`, async () => {
|
|
const loaded = endpoint.load();
|
|
const req = endpoint.request();
|
|
req.query.containerID = PDF_OWNER_CONTAINER;
|
|
const res = createRes();
|
|
await loaded.mod.default(req, res);
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
endpoint.assertNoWork(loaded.calls);
|
|
});
|
|
|
|
test(`${endpoint.name} rejects missing HMAC before privileged work`, async () => {
|
|
const loaded = endpoint.load();
|
|
const req = endpoint.request();
|
|
delete req.query.hash;
|
|
const res = createRes();
|
|
await loaded.mod.default(req, res);
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
endpoint.assertNoWork(loaded.calls);
|
|
});
|
|
|
|
test(`${endpoint.name} rejects invalid HMAC before privileged work`, async () => {
|
|
const loaded = endpoint.load();
|
|
const req = endpoint.request();
|
|
req.query.hash = "wrong";
|
|
const res = createRes();
|
|
await loaded.mod.default(req, res);
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH");
|
|
endpoint.assertNoWork(loaded.calls);
|
|
});
|
|
|
|
test(`${endpoint.name} rejects a valid HMAC for an unauthorised container`, async () => {
|
|
const loaded = endpoint.load({
|
|
getServerSession: async () => ({
|
|
user: { id: "different-container" }
|
|
})
|
|
});
|
|
const res = createRes();
|
|
await loaded.mod.default(endpoint.request(), res);
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
endpoint.assertNoWork(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("generatepdf owner success writes the PDF to the authorised container", async () => {
|
|
const loaded = loadGeneratePdf();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(generatePdfRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(loaded.calls.render.length, 1);
|
|
assert.strictEqual(loaded.calls.create.length, 1);
|
|
assert.strictEqual(loaded.calls.create[0][1], PDF_OWNER_CONTAINER);
|
|
assert.strictEqual(loaded.calls.create[0][2], PDF_CASE_FOLDER);
|
|
assert.strictEqual(loaded.calls.create[0][3], "rep-file");
|
|
assert.strictEqual(res.state.jsonBody.status, "success");
|
|
});
|
|
|
|
test("generatepdf preserves ticketnumber precedence when references differ", async () => {
|
|
const loaded = loadGeneratePdf();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
generatePdfRequest({
|
|
body: {
|
|
representationType: "Other",
|
|
repfile_name: "rep-file",
|
|
containerID: PDF_OWNER_CONTAINER,
|
|
ticketnumber: PDF_CASE_FOLDER,
|
|
caseRef: "CASE-2"
|
|
}
|
|
}),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(loaded.calls.create.length, 1);
|
|
assert.strictEqual(loaded.calls.create[0][1], PDF_OWNER_CONTAINER);
|
|
assert.strictEqual(loaded.calls.create[0][2], PDF_CASE_FOLDER);
|
|
assert.strictEqual(res.state.jsonBody.status, "success");
|
|
});
|
|
|
|
test("generatepdf rejects malformed secondary case references", async () => {
|
|
const loaded = loadGeneratePdf();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(
|
|
generatePdfRequest({
|
|
body: {
|
|
representationType: "Other",
|
|
repfile_name: "rep-file",
|
|
containerID: PDF_OWNER_CONTAINER,
|
|
ticketnumber: PDF_CASE_FOLDER,
|
|
caseRef: "../other-case"
|
|
}
|
|
}),
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoGeneratePdfWork(loaded.calls);
|
|
});
|
|
|
|
test("generateappealpdf owner success reads and writes only the authorised container", async () => {
|
|
const loaded = loadGenerateAppealPdf();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(generateAppealPdfRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(loaded.calls.temp[0][0], PDF_OWNER_CONTAINER);
|
|
assert.strictEqual(loaded.calls.progress[0][0], PDF_OWNER_CONTAINER);
|
|
assert.strictEqual(loaded.calls.download[0][0], PDF_OWNER_CONTAINER);
|
|
assert.strictEqual(loaded.calls.create[0][1], PDF_OWNER_CONTAINER);
|
|
assert.strictEqual(loaded.calls.create[0][2], PDF_CASE_FOLDER);
|
|
assert.strictEqual(res.state.jsonBody.status, "success");
|
|
});
|
|
|
|
test("generatepdf catch path returns GENERATE_PDF_FAILED for an authorised owner", async () => {
|
|
const loaded = loadGeneratePdf({
|
|
createRepPDFBlob: async (...args) => {
|
|
loaded.calls.create.push(args);
|
|
throw new Error("pdf write failed");
|
|
}
|
|
});
|
|
const req = generatePdfRequest({
|
|
query: { hash: "expected", download: "true" },
|
|
body: {
|
|
representationType: "Other",
|
|
repfile_name: "rep-file",
|
|
containerID: PDF_OWNER_CONTAINER,
|
|
caseRef: PDF_CASE_FOLDER
|
|
}
|
|
});
|
|
const res = createRes();
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "GENERATE_PDF_FAILED");
|
|
assert.strictEqual(loaded.calls.render.length, 1);
|
|
assert.strictEqual(loaded.calls.create.length, 1);
|
|
});
|
|
|
|
test("generateappealpdf catch path returns GENERATE_APPEAL_PDF_FAILED for an authorised owner", async () => {
|
|
const loaded = loadGenerateAppealPdf({
|
|
downloadProgressFile: async () => {
|
|
loaded.calls.download.push([
|
|
PDF_OWNER_CONTAINER,
|
|
`${PDF_CASE_FOLDER}/progress.json`,
|
|
PDF_CASE_FOLDER
|
|
]);
|
|
throw new Error("progress failed");
|
|
}
|
|
});
|
|
|
|
const res = createRes();
|
|
await loaded.mod.default(generateAppealPdfRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.error.code,
|
|
"GENERATE_APPEAL_PDF_FAILED"
|
|
);
|
|
assert.strictEqual(loaded.calls.temp.length, 1);
|
|
assert.strictEqual(loaded.calls.progress.length, 1);
|
|
assert.strictEqual(loaded.calls.download.length, 1);
|
|
assert.strictEqual(loaded.calls.render.length, 0);
|
|
assert.strictEqual(loaded.calls.create.length, 0);
|
|
});
|
|
|
|
const CASE_OWNER_CONTAINER = "case-owner-container";
|
|
const GENERATED_CASE_REFERENCE = "TMP-CASE-04E";
|
|
|
|
const loadCreateCase = (overrides = {}) => {
|
|
const calls = {
|
|
session: [],
|
|
options: [],
|
|
token: 0,
|
|
reference: 0,
|
|
caseBlob: [],
|
|
appealBlob: []
|
|
};
|
|
const options = { id: "create-case-options" };
|
|
const mod = loadModule("pages/api/file/createcase_api.js", {
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
authOptions: (req, res) => {
|
|
calls.options.push([req, res]);
|
|
return options;
|
|
},
|
|
getServerSession: async (...args) => {
|
|
calls.session.push(args);
|
|
return { user: { id: CASE_OWNER_CONTAINER } };
|
|
},
|
|
getToken: async () => {
|
|
calls.token += 1;
|
|
return { access_token: "synthetic-token" };
|
|
},
|
|
getTempCaseRef: () => {
|
|
calls.reference += 1;
|
|
return GENERATED_CASE_REFERENCE;
|
|
},
|
|
uuidv4: () => "00000000-0000-4000-8000-00000000004e",
|
|
getCaseBlob: async (...args) => {
|
|
calls.caseBlob.push(args);
|
|
return `${GENERATED_CASE_REFERENCE}/${GENERATED_CASE_REFERENCE}_case.json`;
|
|
},
|
|
createBlob: async (...args) => {
|
|
calls.appealBlob.push(args);
|
|
return GENERATED_CASE_REFERENCE;
|
|
},
|
|
_: { isEmpty: (value) => !value || Object.keys(value).length === 0 },
|
|
...overrides
|
|
});
|
|
|
|
return { mod, calls, options };
|
|
};
|
|
|
|
const createCaseRequest = (overrides = {}) => ({
|
|
method: "POST",
|
|
query: {
|
|
contactid: "00000000-0000-4000-8000-000000000001",
|
|
appealTypeId: "846040000",
|
|
containername: CASE_OWNER_CONTAINER,
|
|
lpaID: "00000000-0000-4000-8000-000000000002"
|
|
},
|
|
body: {
|
|
pinswg_lpaname: "Synthetic LPA",
|
|
createdon: "2026-11-04T00:00:00.000Z"
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
const assertNoCreateCaseSideEffects = (calls) => {
|
|
assert.strictEqual(calls.token, 0);
|
|
assert.strictEqual(calls.reference, 0);
|
|
assert.strictEqual(calls.caseBlob.length, 0);
|
|
assert.strictEqual(calls.appealBlob.length, 0);
|
|
};
|
|
|
|
for (const sessionCase of [
|
|
{ name: "missing session", session: null },
|
|
{ name: "missing session user ID", session: { user: {} } },
|
|
{ name: "empty session user ID", session: { user: { id: "" } } },
|
|
{
|
|
name: "non-string session user ID",
|
|
session: { user: { id: 123 } }
|
|
}
|
|
]) {
|
|
test(`createcase_api rejects ${sessionCase.name} before private draft creation`, async () => {
|
|
const loaded = loadCreateCase({
|
|
getServerSession: async () => sessionCase.session
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(createCaseRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoCreateCaseSideEffects(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("createcase_api rejects a foreign destination container before private draft creation", async () => {
|
|
const loaded = loadCreateCase({
|
|
getServerSession: async () => ({ user: { id: "different-container" } })
|
|
});
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(createCaseRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assertNoCreateCaseSideEffects(loaded.calls);
|
|
});
|
|
|
|
test("createcase_api does not trust a caller-supplied identity", async () => {
|
|
const loaded = loadCreateCase({
|
|
getServerSession: async () => ({ user: { id: "different-container" } })
|
|
});
|
|
const req = createCaseRequest();
|
|
req.body.userID = CASE_OWNER_CONTAINER;
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assertNoCreateCaseSideEffects(loaded.calls);
|
|
});
|
|
|
|
test("createcase_api passes req, res and request-scoped options to getServerSession", async () => {
|
|
const loaded = loadCreateCase();
|
|
const req = createCaseRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.deepStrictEqual(loaded.calls.options, [[req, res]]);
|
|
assert.deepStrictEqual(loaded.calls.session, [[req, res, loaded.options]]);
|
|
});
|
|
|
|
for (const containerCase of [
|
|
{ name: "missing", value: undefined },
|
|
{ name: "invalid", value: "Invalid_Container" },
|
|
{ name: "array-valued", value: [CASE_OWNER_CONTAINER] }
|
|
]) {
|
|
test(`createcase_api rejects a ${containerCase.name} container before private draft creation`, async () => {
|
|
const loaded = loadCreateCase();
|
|
const req = createCaseRequest();
|
|
if (typeof containerCase.value === "undefined") {
|
|
delete req.query.containername;
|
|
} else {
|
|
req.query.containername = containerCase.value;
|
|
}
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assertNoCreateCaseSideEffects(loaded.calls);
|
|
});
|
|
}
|
|
|
|
test("createcase_api rejects malformed JSON before private draft creation", async () => {
|
|
const loaded = loadCreateCase();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(createCaseRequest({ body: "{" }), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
|
assertNoCreateCaseSideEffects(loaded.calls);
|
|
});
|
|
|
|
test("createcase_api rejects ambiguous destination identifiers before private draft creation", async () => {
|
|
for (const request of [
|
|
createCaseRequest({
|
|
query: {
|
|
...createCaseRequest().query,
|
|
containerID: CASE_OWNER_CONTAINER
|
|
}
|
|
}),
|
|
createCaseRequest({
|
|
body: {
|
|
...createCaseRequest().body,
|
|
containername: CASE_OWNER_CONTAINER
|
|
}
|
|
})
|
|
]) {
|
|
const loaded = loadCreateCase();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(request, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
|
assertNoCreateCaseSideEffects(loaded.calls);
|
|
}
|
|
});
|
|
|
|
test("createcase_api rejects an invalid generated folder before token acquisition and Blob writes", async () => {
|
|
const loaded = loadCreateCase({ getTempCaseRef: () => "../foreign-case" });
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(createCaseRequest(), res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 400);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "INVALID_REQUEST");
|
|
assert.strictEqual(loaded.calls.token, 0);
|
|
assert.strictEqual(loaded.calls.caseBlob.length, 0);
|
|
assert.strictEqual(loaded.calls.appealBlob.length, 0);
|
|
});
|
|
|
|
test("createcase_api creates both draft JSON blobs in the authenticated owner's container", async () => {
|
|
const loaded = loadCreateCase();
|
|
const req = createCaseRequest();
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default(req, res);
|
|
|
|
assert.strictEqual(res.state.statusCode, 200);
|
|
assert.strictEqual(loaded.calls.token, 1);
|
|
assert.strictEqual(loaded.calls.reference, 1);
|
|
assert.strictEqual(loaded.calls.caseBlob.length, 1);
|
|
assert.strictEqual(loaded.calls.appealBlob.length, 1);
|
|
assert.strictEqual(loaded.calls.caseBlob[0][0], CASE_OWNER_CONTAINER);
|
|
assert.strictEqual(loaded.calls.caseBlob[0][1], GENERATED_CASE_REFERENCE);
|
|
assert.strictEqual(loaded.calls.appealBlob[0][1], CASE_OWNER_CONTAINER);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(JSON.stringify(loaded.calls.caseBlob[0][2])),
|
|
JSON.parse(JSON.stringify(res.state.jsonBody))
|
|
);
|
|
assert.deepStrictEqual(JSON.parse(loaded.calls.appealBlob[0][0]), {
|
|
pinswg_name: GENERATED_CASE_REFERENCE,
|
|
pinswg_appealcasetype: "846040000",
|
|
_pinswg_associatedlpa_value: "00000000-0000-4000-8000-000000000002"
|
|
});
|
|
assert.strictEqual(
|
|
res.state.jsonBody.ticketnumber,
|
|
GENERATED_CASE_REFERENCE
|
|
);
|
|
assert.strictEqual(res.state.jsonBody.title, GENERATED_CASE_REFERENCE);
|
|
assert.strictEqual(
|
|
res.state.jsonBody.incidentid,
|
|
"00000000-0000-4000-8000-00000000004e"
|
|
);
|
|
});
|
|
|
|
test("createcase_api preserves the existing propagated token dependency failure for an authorised owner", async () => {
|
|
const loaded = loadCreateCase({
|
|
getToken: async () => {
|
|
loaded.calls.token += 1;
|
|
throw new Error("token failure");
|
|
}
|
|
});
|
|
const res = createRes();
|
|
|
|
await assert.rejects(
|
|
loaded.mod.default(createCaseRequest(), res),
|
|
/token failure/
|
|
);
|
|
|
|
assert.strictEqual(loaded.calls.token, 1);
|
|
assert.strictEqual(loaded.calls.caseBlob.length, 0);
|
|
assert.strictEqual(loaded.calls.appealBlob.length, 0);
|
|
});
|
|
|
|
const loadRepresentationDeletion = (route, operationName, session) => {
|
|
const calls = [];
|
|
const mod = loadModule(route, {
|
|
nextConnect: createNextConnectMock(),
|
|
middleware: () => {},
|
|
getServerSession: async () => session,
|
|
authOptions: () => ({}),
|
|
hashAPIPath: () => "&hash=expected",
|
|
respondError: respondErrorMock,
|
|
respondSuccess: respondSuccessMock,
|
|
[operationName]: async (...args) => {
|
|
calls.push(args);
|
|
return { deleted: true };
|
|
}
|
|
});
|
|
|
|
return { mod, calls };
|
|
};
|
|
|
|
test("whole-representation deletion remains cross-user protected with zero Azure operations", async () => {
|
|
const loaded = loadRepresentationDeletion(
|
|
"pages/api/file/deleteblobrep.js",
|
|
"deleteBlobRep",
|
|
{ user: { id: "different-container" } }
|
|
);
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default.handler(
|
|
{
|
|
query: {
|
|
container: "organisation-container",
|
|
casefolderID: "CASE-1",
|
|
repfile: "REP-1",
|
|
hash: "expected"
|
|
}
|
|
},
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assert.strictEqual(loaded.calls.length, 0);
|
|
});
|
|
|
|
test("representation supporting-file deletion remains cross-user protected with zero Azure operations", async () => {
|
|
const loaded = loadRepresentationDeletion(
|
|
"pages/api/file/deleteblob.js",
|
|
"deleteBlob",
|
|
{ user: { id: "different-container" } }
|
|
);
|
|
const res = createRes();
|
|
|
|
await loaded.mod.default.handler(
|
|
{
|
|
query: {
|
|
container: "organisation-container",
|
|
casefolderID: "CASE-1/REP-1",
|
|
blobname: "evidence.pdf",
|
|
hash: "expected"
|
|
}
|
|
},
|
|
res
|
|
);
|
|
|
|
assert.strictEqual(res.state.statusCode, 403);
|
|
assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN");
|
|
assert.strictEqual(loaded.calls.length, 0);
|
|
});
|
|
|
|
const run = async () => {
|
|
let passed = 0;
|
|
for (const currentTest of tests) {
|
|
await currentTest.fn();
|
|
passed += 1;
|
|
}
|
|
console.log(
|
|
`Phase 21 file-handler contract tests passed (${passed}/${tests.length}).`
|
|
);
|
|
};
|
|
|
|
module.exports = run;
|
|
|
|
if (require.main === module) {
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|