From 6de14341152ed5bb05cc55c5f08d5425def394bc Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 17 Aug 2026 18:25:41 +0200 Subject: [PATCH] test(custom): pin the import gate A file being imported is not necessarily one this reader wrote. Extracts the record validator so both the store read and the import path share it, and covers what it has to guarantee: a partly-corrupt file costs the reader only the broken records, a hue that is not a hex colour never reaches a style attribute, an export written before F13 is a file with nothing of its own rather than an error, and an event whose dates contradict themselves does not land. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- src/client/state/useCustom.ts | 46 +++++++++++++++++------------ test/custom.test.ts | 54 ++++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd4c568..5d21171 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ src/client/ React app, service worker, manifest lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) serve.ts static server + /api/health -test/ 396 tests +test/ 400 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` diff --git a/src/client/state/useCustom.ts b/src/client/state/useCustom.ts index 5171a5f..13e22fc 100644 --- a/src/client/state/useCustom.ts +++ b/src/client/state/useCustom.ts @@ -78,15 +78,38 @@ export function readerInstant( */ function readValid( key: string, - schema: { safeParse: (v: unknown) => { success: boolean; data?: T } }, + schema: Validator, label: string, ): Record { const raw = readJson>(key, {}); + const kept = validRecords(raw, schema); + for (const id of Object.keys(raw)) { + if (kept[id] === undefined) console.warn(`dropped an unreadable ${label}: ${id}`); + } + return kept; +} + +interface Validator { + safeParse: (v: unknown) => { success: boolean; data?: T }; +} + +/** + * Keep the records that parse, drop the ones that do not. + * + * Both the store and an import land here. An import especially: a file is not + * necessarily one this reader wrote, and a hostile or merely stale record must + * not be able to take the rest of their data down with it — or reach a `style` + * attribute unchecked (see `CustomGame.hue`). + */ +export function validRecords( + input: unknown, + schema: Validator, +): Record { + if (typeof input !== "object" || input === null) return {}; const out: Record = {}; - for (const [id, value] of Object.entries(raw)) { + for (const [id, value] of Object.entries(input as Record)) { const parsed = schema.safeParse(value); if (parsed.success && parsed.data !== undefined) out[id] = parsed.data; - else console.warn(`dropped an unreadable ${label}: ${id}`); } return out; } @@ -209,8 +232,8 @@ export function useCustom() { /** Import: union by id, never removing what this device already has. */ const merge = useCallback( (incomingGames: unknown, incomingEvents: unknown) => { - const g = validated(incomingGames, CustomGame); - const e = validated(incomingEvents, CustomEvent); + const g = validRecords(incomingGames, CustomGame); + const e = validRecords(incomingEvents, CustomEvent); if (Object.keys(g).length > 0) setGames((prev) => ({ ...g, ...prev })); if (Object.keys(e).length > 0) setEvents((prev) => ({ ...e, ...prev })); }, @@ -240,16 +263,3 @@ export function useCustom() { merge, }; } - -function validated( - input: unknown, - schema: { safeParse: (v: unknown) => { success: boolean; data?: T } }, -): Record { - if (typeof input !== "object" || input === null) return {}; - const out: Record = {}; - for (const [id, value] of Object.entries(input as Record)) { - const parsed = schema.safeParse(value); - if (parsed.success && parsed.data !== undefined) out[id] = parsed.data; - } - return out; -} diff --git a/test/custom.test.ts b/test/custom.test.ts index 0cedf03..e8dbcf9 100644 --- a/test/custom.test.ts +++ b/test/custom.test.ts @@ -16,7 +16,7 @@ import { metaFor } from "../src/shared/games.ts"; import { dailiesId } from "../src/shared/daily.ts"; import { eventId, GameId } from "../src/shared/schema.ts"; import { clockFor } from "../src/shared/time.ts"; -import { readerInstant } from "../src/client/state/useCustom.ts"; +import { readerInstant, validRecords } from "../src/client/state/useCustom.ts"; const AT = "2026-08-17T12:00:00.000Z"; @@ -284,3 +284,55 @@ describe("readerInstant", () => { expect(readerInstant("2026-02-30", null, "start")).toBeNull(); }); }); + +describe("validRecords — the import gate", () => { + test("keeps the good records and drops only the bad ones", () => { + // A partly-corrupt file must not cost the reader the parts that are fine. + const kept = validRecords( + { + "mygame:a": { id: "mygame:a", name: "A", hue: "#123456", at: AT }, + "mygame:b": { id: "mygame:b", name: "B", hue: "not-a-colour", at: AT }, + "mygame:c": "nonsense", + }, + CustomGame, + ); + expect(Object.keys(kept)).toEqual(["mygame:a"]); + }); + + test("refuses a hue that is not a hex colour", () => { + // It reaches a style attribute, and an import is not necessarily a file + // this reader wrote. + const kept = validRecords( + { + "mygame:x": { + id: "mygame:x", + name: "X", + hue: "red; background:url(javascript:alert(1))", + at: AT, + }, + }, + CustomGame, + ); + expect(kept).toEqual({}); + }); + + test("an export written before F13 simply has none", () => { + // Not an error — a file from a device that had nothing of its own. + expect(validRecords(undefined, CustomEvent)).toEqual({}); + expect(validRecords(null, CustomEvent)).toEqual({}); + }); + + test("drops an event whose dates contradict themselves", () => { + const kept = validRecords( + { + "myevent:aaaaaaaaaa": { + ...ownEvent(), + id: "myevent:aaaaaaaaaa", + endsAt: "2026-08-01T00:00:00.000Z", + }, + }, + CustomEvent, + ); + expect(kept).toEqual({}); + }); +});