ITHC: enforce representation completion ownership and admin access
This commit is contained in:
@@ -3,7 +3,6 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { bytesToSize } from "../../../components/utils";
|
||||
import { deleteAwaitingSubmissionsFromBlob } from "../../../actions/services/documentService";
|
||||
import { sendRepCompleteMessage } from "../../../actions/services/portalService";
|
||||
import { consoleLogger } from "../../../actions/core/logger";
|
||||
|
||||
import { formatBlobDates } from "../utils/adminHelper";
|
||||
@@ -237,7 +236,8 @@ export default function StorageTab({ data, repCompleteCount }) {
|
||||
{folder.repComplete && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
confirm(
|
||||
if (
|
||||
!confirm(
|
||||
"Do you want resend the rep queue message " +
|
||||
folder.repJsonBlob.name.split(
|
||||
"/"
|
||||
@@ -246,20 +246,53 @@ export default function StorageTab({ data, repCompleteCount }) {
|
||||
folder.repJsonBlob.name.split(
|
||||
"/"
|
||||
)[1]
|
||||
) &&
|
||||
(sendRepCompleteMessage(
|
||||
c.container,
|
||||
folder.repJsonBlob.name.split(
|
||||
"/"
|
||||
)[0],
|
||||
folder.repJsonBlob.name.split(
|
||||
"/"
|
||||
)[1]
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response =
|
||||
await fetch(
|
||||
"/api/admin/createrepcompletemessage",
|
||||
{
|
||||
method: "POST",
|
||||
headers:
|
||||
{
|
||||
"Content-Type":
|
||||
"application/json"
|
||||
},
|
||||
body: JSON.stringify(
|
||||
{
|
||||
container:
|
||||
c.container,
|
||||
tempcaseref:
|
||||
folder.repJsonBlob.name.split(
|
||||
"/"
|
||||
)[0],
|
||||
repid: folder.repJsonBlob.name.split(
|
||||
"/"
|
||||
)[1]
|
||||
}
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"Unable to recreate representation queue message"
|
||||
);
|
||||
}
|
||||
|
||||
alert(
|
||||
"rep queue message sent!"
|
||||
),
|
||||
window.location.reload());
|
||||
);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
consoleLogger(
|
||||
error
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Recreate Queue Message
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createRepCompleteMessage } from "../../../actions/azurestorage";
|
||||
import { consoleLogger } from "../../../actions/core/logger";
|
||||
import {
|
||||
hasTrustedSameOrigin,
|
||||
readAdminSessionCookie,
|
||||
setAdminAuthNoStore,
|
||||
validateAdminSession
|
||||
} from "../../../lib/admin/adminAuth";
|
||||
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
|
||||
const isValidContainer = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length >= 3 &&
|
||||
value.length <= 63 &&
|
||||
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
||||
|
||||
const isValidPathSegment = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= 255 &&
|
||||
value === value.trim() &&
|
||||
value !== "." &&
|
||||
value !== ".." &&
|
||||
!/[\\/\u0000-\u001f\u007f]/.test(value);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
setAdminAuthNoStore(res);
|
||||
res.setHeader("Allow", "POST");
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return respondError(res, {
|
||||
status: 405,
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
message: "Method not allowed"
|
||||
});
|
||||
}
|
||||
|
||||
const adminSession = readAdminSessionCookie(req);
|
||||
if (!validateAdminSession(adminSession)) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
if (!hasTrustedSameOrigin(req)) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
const { container, tempcaseref, repid } = req.body ?? {};
|
||||
if (
|
||||
!isValidContainer(container) ||
|
||||
!isValidPathSegment(tempcaseref) ||
|
||||
!isValidPathSegment(repid)
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Invalid request"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await createRepCompleteMessage(
|
||||
container,
|
||||
tempcaseref,
|
||||
repid
|
||||
);
|
||||
return respondSuccess(res, data);
|
||||
} catch (error) {
|
||||
consoleLogger(error);
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "CREATE_REP_COMPLETE_MESSAGE_FAILED",
|
||||
message: "Failed to create representation complete message"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,42 @@
|
||||
import {
|
||||
getBlobs,
|
||||
createRepCompleteMessage
|
||||
} from "../../../actions/azurestorage";
|
||||
import { createRepCompleteMessage } from "../../../actions/azurestorage";
|
||||
import { hashAPIPath } from "../../../actions/core/hash";
|
||||
import { consoleLogger } from "../../../actions/core/logger";
|
||||
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import nextConnect from "next-connect";
|
||||
import middleware from "../middleware/middleware";
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
|
||||
const ApiProxy = nextConnect();
|
||||
ApiProxy.use(middleware);
|
||||
const isValidContainer = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length >= 3 &&
|
||||
value.length <= 63 &&
|
||||
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
||||
|
||||
const isValidPathSegment = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= 255 &&
|
||||
value === value.trim() &&
|
||||
value !== "." &&
|
||||
value !== ".." &&
|
||||
!/[\\/\u0000-\u001f\u007f]/.test(value);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.setHeader("Allow", "GET");
|
||||
|
||||
if (req.method !== "GET") {
|
||||
return respondError(res, {
|
||||
status: 405,
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
message: "Method not allowed"
|
||||
});
|
||||
}
|
||||
|
||||
ApiProxy.get(async (req, res) => {
|
||||
const containerName = req.query.container;
|
||||
const tempCaseRef = req.query.tempcaseref;
|
||||
const filename = req.query.repid;
|
||||
@@ -20,13 +44,13 @@ ApiProxy.get(async (req, res) => {
|
||||
|
||||
if (
|
||||
typeof containerName === "undefined" ||
|
||||
containerName.length === 0 ||
|
||||
containerName === "" ||
|
||||
typeof tempCaseRef === "undefined" ||
|
||||
tempCaseRef.length === 0 ||
|
||||
tempCaseRef === "" ||
|
||||
typeof filename === "undefined" ||
|
||||
filename.length === 0 ||
|
||||
filename === "" ||
|
||||
typeof checkHash === "undefined" ||
|
||||
checkHash.length === 0
|
||||
checkHash === ""
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
@@ -35,6 +59,28 @@ ApiProxy.get(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isValidContainer(containerName) ||
|
||||
!isValidPathSegment(tempCaseRef) ||
|
||||
!isValidPathSegment(filename) ||
|
||||
typeof checkHash !== "string"
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Invalid request"
|
||||
});
|
||||
}
|
||||
|
||||
const session = await getSession({ req });
|
||||
if (
|
||||
typeof session?.user?.id !== "string" ||
|
||||
session.user.id.length === 0 ||
|
||||
session.user.id !== containerName
|
||||
) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
const hashCandidatePaths = [
|
||||
"/api/file/createrepcompletemessage_api?container=" +
|
||||
containerName +
|
||||
@@ -77,12 +123,10 @@ ApiProxy.get(async (req, res) => {
|
||||
message: "Failed to create representation complete message"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false
|
||||
}
|
||||
};
|
||||
|
||||
export default ApiProxy;
|
||||
|
||||
@@ -1106,66 +1106,389 @@ test("uploadsinglefile handler dependency failure returns UPLOAD_SINGLE_FILE_FAI
|
||||
);
|
||||
});
|
||||
|
||||
test("createrepcompletemessage handler accepts encoded hash variant", async () => {
|
||||
const loadOrdinaryRepCompletion = (overrides = {}) => {
|
||||
const calls = { hash: [], queue: [] };
|
||||
const payload = { messageId: "ordinary-message" };
|
||||
const mod = loadModule("pages/api/file/createrepcompletemessage_api.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
hashAPIPath: (queryPath) =>
|
||||
queryPath.includes("repid=rep%201")
|
||||
? "&hash=expected"
|
||||
: "&hash=other",
|
||||
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: () => {},
|
||||
createRepCompleteMessage: async () => ({ ok: true })
|
||||
...overrides
|
||||
});
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
container: "c1",
|
||||
tempcaseref: "TMP/1",
|
||||
repid: "rep 1",
|
||||
hash: "expected"
|
||||
}
|
||||
};
|
||||
const res = createRes();
|
||||
await mod.default.handler(req, res);
|
||||
return { mod, calls, payload };
|
||||
};
|
||||
|
||||
assert.strictEqual(res.state.statusCode, 200);
|
||||
assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), {
|
||||
ok: true
|
||||
});
|
||||
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
|
||||
});
|
||||
|
||||
test("createrepcompletemessage handler dependency failure returns CREATE_REP_COMPLETE_MESSAGE_FAILED", async () => {
|
||||
const mod = loadModule("pages/api/file/createrepcompletemessage_api.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
hashAPIPath: () => "&hash=expected",
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
consoleLogger: () => {},
|
||||
createRepCompleteMessage: async () => {
|
||||
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 req = {
|
||||
query: {
|
||||
container: "c1",
|
||||
tempcaseref: "TMP1",
|
||||
repid: "rep1",
|
||||
hash: "expected"
|
||||
}
|
||||
};
|
||||
const res = createRes();
|
||||
await mod.default.handler(req, res);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("getbloblistproxy success returns proxied payload", async () => {
|
||||
@@ -1543,6 +1866,38 @@ test("admin storage refresh uses dedicated POST proxy while ordinary callers rem
|
||||
assert.match(viewAllSource, /getRepsFromBlobProxy\(containerID\)/);
|
||||
});
|
||||
|
||||
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\(/);
|
||||
});
|
||||
|
||||
test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING_SUBMISSION_PROXY_FAILED", async () => {
|
||||
const mod = loadModule(
|
||||
"pages/api/file/getawaitingsubmissionfromblobproxy.js",
|
||||
|
||||
Reference in New Issue
Block a user