import { readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { KIND_OF, LS } from "./storage"; // Every key the app keeps in the browser is declared in storage.ts with its kind (a key without one // does not compile), and the kind decides where it belongs. This holds the two ways that rule drifts: // a key spelled out somewhere else, so the registry never learns of it, and an account preference // read or written straight from localStorage, so it never reaches the server and stays on one device // while every other device shows something else. const SRC = new URL("../", import.meta.url); const SKIP_DIRS = new Set(["node_modules", "design-lab"]); const TEST = /\.test\.tsx?$/; function files(dir: string): string[] { return readdirSync(new URL(dir, SRC), { withFileTypes: true }).flatMap((e) => e.isDirectory() ? SKIP_DIRS.has(e.name) ? [] : files(`${dir}${e.name}/`) : /\.tsx?$/.test(e.name) && !TEST.test(e.name) ? [`${dir}${e.name}`] : [], ); } const SOURCES = files("").map((f) => ({ f, text: readFileSync(new URL(f, SRC), "utf-8") })); // Preferences whose local copy is kept by the store that applies them. The Settings page's // (PrefsProvider saves them to the server): the store paints the first frame, and for the language // the signed-out pages too, before the account is known. The side panels' group cards: App seeds // them from the account and saves every change to it, through lib/panelLayout.ts. const SYNCED_BY_STORE: Record = { theme: "lib/theme.ts", hints: "lib/hints.ts", notifSettings: "lib/notifications.ts", lang: "i18n/index.ts", sidebarLayout: "lib/panelLayout.ts", plexLayout: "lib/panelLayout.ts", playlistsLayout: "lib/panelLayout.ts", filesLayout: "lib/panelLayout.ts", }; describe("storage kinds", () => { it("spells out no key outside the registry", () => { // The debug recorder's BroadcastChannel is a channel name, not a stored key. const allowed = new Set(['"siftlode.debugSession"']); // Any literal that starts the namespace, so a key built by concatenation or a template counts. const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) => [...text.matchAll(/["'`]siftlode\.[^"'`]*["'`]?/g)] .map((m) => m[0]) .filter((lit) => !allowed.has(lit)) .map((lit) => `${f}: ${lit}`), ); expect(hits).toEqual([]); }); it("hands the Storage API no key of its own", () => { // A key outside the namespace (`"sf.x"`) would slip past the scan above and the claim's wipe: // a literal handed to the Storage API, or a constant holding one. const call = /(?:localStorage|sessionStorage)\.(?:getItem|setItem|removeItem)\(\s*([^,)]+)/g; const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) => [...text.matchAll(call)] .map((m) => m[1]!.trim()) .filter( (arg) => /^["'`]/.test(arg) || new RegExp(`\\b(?:const|let)\\s+${arg}\\s*=\\s*["'\`]`).test(text), ) .map((arg) => `${f}: ${arg}`), ); expect(hits).toEqual([]); }); it("keeps an account preference on the server: its local copy only through usePref", () => { const prefs = Object.entries(LS).filter(([, key]) => KIND_OF.get(key) === "pref"); expect(prefs.length).toBeGreaterThan(10); const hits = prefs.flatMap(([name]) => SOURCES.flatMap(({ f, text }) => { if (f === "lib/storage.ts" || SYNCED_BY_STORE[name] === f) return []; const named = text.match(new RegExp(`\\bLS\\.${name}\\b`, "g"))?.length ?? 0; const asLocalCopy = text.match(new RegExp(`\\blocalKey:\\s*LS\\.${name}\\b`, "g"))?.length ?? 0; return named > asLocalCopy ? [`${f}: LS.${name}`] : []; }), ); expect(hits).toEqual([]); }); // usePref saves what it holds to the account: a device, view or cache key handed to it would follow // the account to every device. it("hands usePref a preference and nothing else", () => { const hits = SOURCES.flatMap(({ f, text }) => [...text.matchAll(/\blocalKey:\s*LS\.(\w+)/g)] .filter((m) => KIND_OF.get(LS[m[1] as keyof typeof LS]) !== "pref") .map((m) => `${f}: LS.${m[1]}`), ); expect(hits).toEqual([]); }); it("lists only preferences as kept by their store", () => { for (const name of Object.keys(SYNCED_BY_STORE)) expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref"); }); // The e2e setup seeds a browser's state by hand: a key it writes that the app no longer reads would // keep a spec green while setting up nothing. it("seeds the e2e browsers with registered keys only", () => { const setup = readFileSync(new URL("../e2e/global.setup.ts", SRC), "utf-8"); const keys = [...setup.matchAll(/["'`](siftlode\.[\w.]+)["'`]/g)].map((m) => m[1]!); expect(keys.length).toBeGreaterThan(0); expect(keys.filter((k) => k !== "siftlode.owner" && !KIND_OF.has(k))).toEqual([]); }); });