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({}); + }); +});