mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(kit): refuse a sealed field in any response the route kit drives
callRoute now fails a call whose JSON body carries a key ending in Encrypted, or bytes serialised as an index-keyed object or a Buffer, on every route it drives and regardless of the contract exemption. A sealed column is decrypted or dropped before a response is built; one that leaks is a defect, whatever the contract says. The kit's own test proves the check finds both shapes and leaves an hour map alone.
This commit is contained in:
1 parent
36fe53bbaa
commit
7b9737c7b1
2 files changed
+80
-1
No files matched your search
@@ -12,7 +12,12 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
|
||||
import { ContractViolation, anonymous, callRoute } from "../../kit/call-route";
|
||||
import {
|
||||
ContractViolation,
|
||||
anonymous,
|
||||
callRoute,
|
||||
sealedFieldPaths,
|
||||
} from "../../kit/call-route";
|
||||
|
||||
const token = {
|
||||
id: "tok-1",
|
||||
@@ -28,6 +33,31 @@ function answering(status: number, body: unknown) {
|
||||
return apiHandler(async () => NextResponse.json(body, { status }));
|
||||
}
|
||||
|
||||
describe("callRoute's sealed-field check", () => {
|
||||
it("finds a sealed key and serialised bytes at any depth, and nothing else", () => {
|
||||
const bytes = Object.fromEntries(
|
||||
Array.from({ length: 32 }, (_, i) => [String(i), (i * 7) % 256]),
|
||||
);
|
||||
expect(
|
||||
sealedFieldPaths({
|
||||
data: {
|
||||
rows: [{ notes: "a", notesEncrypted: null }],
|
||||
track: { geometry: bytes },
|
||||
raw: { type: "Buffer", data: [1, 2, 3] },
|
||||
hours: { "0": 3, "1": 4, "2": 5 },
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
"data.rows[0].notesEncrypted",
|
||||
"data.track.geometry",
|
||||
"data.raw",
|
||||
]);
|
||||
expect(sealedFieldPaths({ data: { notes: "plain", count: 3 } })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callRoute's contract check", () => {
|
||||
it("accepts a body the registered schema describes", async () => {
|
||||
const res = await callRoute(
|
||||
|
||||
@@ -153,6 +153,10 @@ export async function callRoute<T = unknown>(
|
||||
const isJson = contentType.includes("json") && text.length > 0;
|
||||
const body: unknown = isJson ? JSON.parse(text) : text;
|
||||
|
||||
// Not skippable with `contract`: a sealed column leaving the server is a
|
||||
// defect whatever the contract says, so no test can opt a route out of it.
|
||||
if (isJson) assertNoSealedFields(options.method, options.route, body);
|
||||
|
||||
if (!options.contract?.skip) {
|
||||
checkContract(
|
||||
options.method,
|
||||
@@ -182,6 +186,51 @@ export async function callRoute<T = unknown>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A column the server stores sealed (`notesEncrypted`, `geometryEncrypted`,
|
||||
* every `*Encrypted` field) is decrypted or dropped before a response is
|
||||
* built. One that reaches a response anyway arrives as its key or as the
|
||||
* serialised bytes (`{"0":…,"1":…}`, or `{ type: "Buffer", data: […] }`).
|
||||
* Either fails the call, on every route the kit drives.
|
||||
*/
|
||||
export function sealedFieldPaths(value: unknown, at = ""): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item, i) => sealedFieldPaths(item, `${at}[${i}]`));
|
||||
}
|
||||
if (!isRecord(value)) return [];
|
||||
const keys = Object.keys(value);
|
||||
// A sealed value is at least a nonce and a tag (28 bytes), so a short
|
||||
// index-keyed object (an hour map, a weekday tally) is not mistaken for one.
|
||||
const bufferShape =
|
||||
(value.type === "Buffer" && Array.isArray(value.data)) ||
|
||||
(keys.length >= 16 &&
|
||||
keys.every((k, i) => k === String(i)) &&
|
||||
Object.values(value).every(
|
||||
(v) =>
|
||||
Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 255,
|
||||
));
|
||||
if (bufferShape) return [at || "(body)"];
|
||||
return keys.flatMap((key) => {
|
||||
const path = at ? `${at}.${key}` : key;
|
||||
return /encrypted$/i.test(key)
|
||||
? [path]
|
||||
: sealedFieldPaths(value[key], path);
|
||||
});
|
||||
}
|
||||
|
||||
function assertNoSealedFields(
|
||||
method: HttpMethod,
|
||||
route: string,
|
||||
body: unknown,
|
||||
): void {
|
||||
const found = sealedFieldPaths(body);
|
||||
if (found.length > 0) {
|
||||
throw new ContractViolation(
|
||||
`${method} ${route} sent sealed or raw byte fields to the client: ${found.join(", ")}. Decrypt onto the readable field or drop the column before the response is built.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function concretePath(route: string, params: RouteParams): string {
|
||||
const used = new Set<string>();
|
||||
const path = route.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
||||
|
||||
Reference in new issue
Block a user