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.
114 lines
5.0 KiB
TypeScript
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([]);
|
|
});
|
|
});
|