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.
This commit is contained in:
@@ -25,22 +25,28 @@ function files(dir: string): string[] {
|
||||
}
|
||||
const SOURCES = files("").map((f) => ({ f, text: readFileSync(new URL(f, SRC), "utf-8") }));
|
||||
|
||||
// Preferences the Settings page owns (PrefsProvider saves them to the server) whose local copy is
|
||||
// kept by the store that applies them: it paints the first frame, and for the language the
|
||||
// signed-out pages too, before the account is known.
|
||||
// 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\.[\w.]+["'`]/g)]
|
||||
[...text.matchAll(/["'`]siftlode\.[^"'`]*["'`]?/g)]
|
||||
.map((m) => m[0])
|
||||
.filter((lit) => !allowed.has(lit))
|
||||
.map((lit) => `${f}: ${lit}`),
|
||||
@@ -48,6 +54,23 @@ describe("storage kinds", () => {
|
||||
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);
|
||||
@@ -63,6 +86,17 @@ describe("storage kinds", () => {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user