diff --git a/tests/integration/routes/kit-contract.test.ts b/tests/integration/routes/kit-contract.test.ts index 7359258c1..d2e28b609 100644 --- a/tests/integration/routes/kit-contract.test.ts +++ b/tests/integration/routes/kit-contract.test.ts @@ -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( diff --git a/tests/kit/call-route.ts b/tests/kit/call-route.ts index d4ac7f0b6..ea2ab2d96 100644 --- a/tests/kit/call-route.ts +++ b/tests/kit/call-route.ts @@ -153,6 +153,10 @@ export async function callRoute( 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( }; } +/** + * 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(); const path = route.replace(/\{([^}]+)\}/g, (_, name: string) => {