Files
siftlode/frontend/src/lib/storageKinds.contract.test.ts
T
peter 94418a087e fix(auth): a tab acts only as the account it was built for, and the review's other findings
A tab names the account it was claimed for on every request, beacon and upload
(X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with
401 account_changed and keep the session, so a tab left showing the account
signed in before reloads instead of writing to the new one.

/auth/login clears a connect marker left by an abandoned connect, and a link
completes only on a session that still holds. The prefs merge reads the row
under a lock. A tab's sessionStorage records its own owner. A page the account
loses is given up, address bar and remembered page included. The panel layouts
are preferences; the storage contract also refuses a key handed to the Storage
API or to usePref outside its kind. A refused sign-in as someone else and a
failed sign-out say so. The chromium-only e2e specs are in the gate register,
the member walk waits for its requests, and devices.spec checks the save itself.
2026-10-21 01:05:12 +02:00

114 lines
5.0 KiB
TypeScript

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<string, string> = {
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([]);
});
});