ITHC: protect administrative representation unlock endpoint

This commit is contained in:
2026-10-26 15:27:10 +00:00
parent 0daeb783b9
commit 37aeb03509
5 changed files with 378 additions and 98 deletions
@@ -573,6 +573,243 @@ test("admin storage caller uses dedicated POST deletion while ordinary caller re
);
});
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(