fix(security): enforce ownership across appeal completion
This commit is contained in:
@@ -1,12 +1,25 @@
|
||||
import { hashAPIPath } from "../../../actions/core/hash";
|
||||
import nextConnect from "next-connect";
|
||||
import { getSession } from "next-auth/react";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
|
||||
const ApiProxy = nextConnect();
|
||||
|
||||
const isValidContainer = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length >= 3 &&
|
||||
value.length <= 63 &&
|
||||
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
||||
|
||||
const isValidTempCaseRef = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length <= 100 &&
|
||||
!value.includes("..") &&
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9/-]*[A-Za-z0-9])?$/.test(value);
|
||||
|
||||
ApiProxy.get(async (req, res) => {
|
||||
const session = await getSession({ req });
|
||||
const session = await getServerSession(req, res, authOptions(req, res));
|
||||
|
||||
if (!session) {
|
||||
return respondError(res, {
|
||||
@@ -52,6 +65,59 @@ ApiProxy.get(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
queryPath.startsWith("/api/file/createappealcompletemessage_api") &&
|
||||
queryPath !== "/api/file/createappealcompletemessage_api"
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "INVALID_HASH_PATH",
|
||||
message: "Invalid path for hash generation"
|
||||
});
|
||||
}
|
||||
|
||||
if (queryPath === "/api/file/createappealcompletemessage_api") {
|
||||
let completionPath;
|
||||
|
||||
try {
|
||||
completionPath = new URL(rawQueryPath, "http://localhost");
|
||||
} catch (_error) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "INVALID_HASH_PATH",
|
||||
message: "Invalid path for hash generation"
|
||||
});
|
||||
}
|
||||
|
||||
const containerName = completionPath.searchParams.get("container");
|
||||
const tempCaseRef = completionPath.searchParams.get("tempcaseref");
|
||||
|
||||
if (
|
||||
!isValidContainer(containerName) ||
|
||||
!isValidTempCaseRef(tempCaseRef) ||
|
||||
completionPath.searchParams.getAll("container").length !== 1 ||
|
||||
completionPath.searchParams.getAll("tempcaseref").length !== 1
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "INVALID_HASH_PATH",
|
||||
message: "Invalid path for hash generation"
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
typeof session?.user?.id !== "string" ||
|
||||
session.user.id.length === 0 ||
|
||||
session.user.id !== containerName
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return respondSuccess(res, { hash: hashAPIPath(rawQueryPath) });
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,34 @@ import _ from "lodash";
|
||||
import nextConnect from "next-connect";
|
||||
import middleware from "../middleware/middleware";
|
||||
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
const ApiProxy = nextConnect();
|
||||
ApiProxy.use(middleware);
|
||||
|
||||
const hasValue = (value) =>
|
||||
typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
const isValidContainer = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length >= 3 &&
|
||||
value.length <= 63 &&
|
||||
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
||||
|
||||
const isValidTempCaseRef = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length <= 100 &&
|
||||
!value.includes("..") &&
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9/-]*[A-Za-z0-9])?$/.test(value);
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
|
||||
ApiProxy.get(async (req, res) => {
|
||||
const containerName = req.query.container;
|
||||
const tempCaseRef = req.query.tempcaseref;
|
||||
@@ -24,12 +48,9 @@ ApiProxy.get(async (req, res) => {
|
||||
const checkHash = req.query.hash;
|
||||
|
||||
if (
|
||||
typeof containerName === "undefined" ||
|
||||
containerName.length === 0 ||
|
||||
typeof tempCaseRef === "undefined" ||
|
||||
tempCaseRef.length === 0 ||
|
||||
typeof checkHash === "undefined" ||
|
||||
checkHash.length === 0
|
||||
!hasValue(containerName) ||
|
||||
!hasValue(tempCaseRef) ||
|
||||
!hasValue(checkHash)
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
@@ -38,6 +59,26 @@ ApiProxy.get(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isValidContainer(containerName) ||
|
||||
!isValidTempCaseRef(tempCaseRef)
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Invalid request"
|
||||
});
|
||||
}
|
||||
|
||||
const session = await getServerSession(req, res, authOptions(req, res));
|
||||
if (
|
||||
typeof session?.user?.id !== "string" ||
|
||||
session.user.id.length === 0 ||
|
||||
session.user.id !== containerName
|
||||
) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
const hashCandidatePaths = [
|
||||
"/api/file/createappealcompletemessage_api?container=" +
|
||||
containerName +
|
||||
|
||||
@@ -4,6 +4,8 @@ import { azureHeaders } from "../../../actions/core/headers";
|
||||
import { consoleLogger } from "../../../actions/core/logger";
|
||||
import axios from "axios";
|
||||
import { respondError, respondSuccess } from "../middleware/apiResponse";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
import nextConnect from "next-connect";
|
||||
import middleware from "../middleware/middleware";
|
||||
@@ -16,6 +18,25 @@ const BASE_URL = process.env.API_ROOT || `http://localhost:${port}`;
|
||||
const hasValue = (value) =>
|
||||
typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
const isValidContainer = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length >= 3 &&
|
||||
value.length <= 63 &&
|
||||
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
||||
|
||||
const isValidTempCaseRef = (value) =>
|
||||
typeof value === "string" &&
|
||||
value.length <= 100 &&
|
||||
!value.includes("..") &&
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9/-]*[A-Za-z0-9])?$/.test(value);
|
||||
|
||||
const forbidden = (res) =>
|
||||
respondError(res, {
|
||||
status: 403,
|
||||
code: "FORBIDDEN",
|
||||
message: "Request not permitted"
|
||||
});
|
||||
|
||||
ApiProxy.get(async (req, res) => {
|
||||
const containerName = req.query.container;
|
||||
const tempCaseRef = req.query.tempcaseref;
|
||||
@@ -28,6 +49,26 @@ ApiProxy.get(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isValidContainer(containerName) ||
|
||||
!isValidTempCaseRef(tempCaseRef)
|
||||
) {
|
||||
return respondError(res, {
|
||||
status: 400,
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Invalid request"
|
||||
});
|
||||
}
|
||||
|
||||
const session = await getServerSession(req, res, authOptions(req, res));
|
||||
if (
|
||||
typeof session?.user?.id !== "string" ||
|
||||
session.user.id.length === 0 ||
|
||||
session.user.id !== containerName
|
||||
) {
|
||||
return forbidden(res);
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
|
||||
const queryUrl =
|
||||
@@ -37,9 +78,17 @@ ApiProxy.get(async (req, res) => {
|
||||
encodeURIComponent(tempCaseRef);
|
||||
|
||||
try {
|
||||
const requestOptions = azureHeaders(token.access_token);
|
||||
if (typeof req.headers?.cookie === "string") {
|
||||
requestOptions.headers = {
|
||||
...requestOptions.headers,
|
||||
cookie: req.headers.cookie
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = await axios.get(
|
||||
BASE_URL + queryUrl + hashAPIPath(queryUrl),
|
||||
azureHeaders(token.access_token)
|
||||
requestOptions
|
||||
);
|
||||
return respondSuccess(res, data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1628,7 +1628,8 @@ test("general hash endpoint does not authorize getrepsblob signing", async () =>
|
||||
let hashCalls = 0;
|
||||
const mod = loadModule("pages/api/endpoint/gethash_api.js", {
|
||||
nextConnect: createNextConnectMock(),
|
||||
getSession: async () => ({ user: { id: "user-container" } }),
|
||||
getServerSession: async () => ({ user: { id: "user-container" } }),
|
||||
authOptions: () => ({}),
|
||||
hashAPIPath: () => {
|
||||
hashCalls += 1;
|
||||
return "&hash=unexpected";
|
||||
@@ -1652,6 +1653,146 @@ test("general hash endpoint does not authorize getrepsblob signing", async () =>
|
||||
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(),
|
||||
@@ -3216,12 +3357,488 @@ test("getawaitingsubmissionfromblobproxy dependency failure returns GET_AWAITING
|
||||
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 {};
|
||||
},
|
||||
updateAccount: 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 },
|
||||
true
|
||||
]
|
||||
]
|
||||
);
|
||||
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" }),
|
||||
@@ -3238,7 +3855,8 @@ test("createappealcompletemessageproxy dependency failure returns CREATE_APPEAL_
|
||||
);
|
||||
|
||||
const req = {
|
||||
query: { container: "c1", tempcaseref: "TMP1" }
|
||||
query: { container: "container-1", tempcaseref: "TMP1" },
|
||||
headers: { cookie: "next-auth.session-token=synthetic" }
|
||||
};
|
||||
const res = createRes();
|
||||
await mod.default.handler(req, res);
|
||||
@@ -3256,6 +3874,8 @@ test("createappealcompletemessage_api accepts encoded hash variant and returns s
|
||||
{
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => ({ user: { id: "container-1" } }),
|
||||
authOptions: () => ({}),
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
hashAPIPath: (queryPath) =>
|
||||
@@ -3293,7 +3913,7 @@ test("createappealcompletemessage_api accepts encoded hash variant and returns s
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
container: "cont/1",
|
||||
container: "container-1",
|
||||
tempcaseref: "TMP/1",
|
||||
inv: "846040001",
|
||||
hash: "expected"
|
||||
@@ -3314,6 +3934,8 @@ test("createappealcompletemessage_api dependency failure returns CREATE_APPEAL_C
|
||||
{
|
||||
nextConnect: createNextConnectMock(),
|
||||
middleware: () => {},
|
||||
getServerSession: async () => ({ user: { id: "owner-container" } }),
|
||||
authOptions: () => ({}),
|
||||
respondError: respondErrorMock,
|
||||
respondSuccess: respondSuccessMock,
|
||||
hashAPIPath: () => "&hash=expected",
|
||||
@@ -3327,7 +3949,7 @@ test("createappealcompletemessage_api dependency failure returns CREATE_APPEAL_C
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
container: "c1",
|
||||
container: "owner-container",
|
||||
tempcaseref: "TMP1",
|
||||
hash: "expected"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user