ITHC: protect administrative representation unlock endpoint
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user