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:
@@ -18,6 +18,7 @@ vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
|
||||
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
|
||||
|
||||
import { setDebugSession } from "../log";
|
||||
import { claimLocalData } from "../storage";
|
||||
import { downloadsApi } from "./downloads";
|
||||
import { filesApi } from "./files";
|
||||
import {
|
||||
@@ -465,6 +466,30 @@ describe("the debug session a tab records into", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A tab built for one account must not act as another: a sign-in as someone else in another tab
|
||||
// replaces the cookie under this one. Every request path names the account the tab was claimed for,
|
||||
// so the server can refuse it — a beacon or an upload that went out without it would land on the
|
||||
// account signed in now.
|
||||
describe("the account a tab was built for", () => {
|
||||
const sentHeaders = () => {
|
||||
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
|
||||
return new Headers(init.headers as HeadersInit);
|
||||
};
|
||||
|
||||
it("rides every request path once the tab is claimed", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||
Promise.resolve(new Response("{}", { status: 200 })),
|
||||
);
|
||||
claimLocalData(7);
|
||||
await req("/api/x");
|
||||
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
|
||||
beacon("/api/y", {});
|
||||
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
|
||||
void uploadReq("/u", body());
|
||||
expect(FakeXHR.last.headers["X-Siftlode-Expect-Account"]).toBe("7");
|
||||
});
|
||||
});
|
||||
|
||||
// The upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED
|
||||
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
|
||||
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
|
||||
|
||||
@@ -8,6 +8,7 @@ import { formatBytes, formatDecimal } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { currentDebugSession, newRequestId } from "../log";
|
||||
import { claimedAccount } from "../storage";
|
||||
|
||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||||
@@ -270,13 +271,24 @@ export const setupHeaders = (token: string) => ({
|
||||
// The debug session this tab is recording: the server tags its own lines of the request with
|
||||
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
|
||||
const DEBUG_SESSION_HEADER = "X-Debug-Session";
|
||||
// The account this tab was built for. The cookie alone says who is asking, and a sign-in as someone
|
||||
// else in another tab replaces it under this one too: the server refuses (401) a request whose tab
|
||||
// shows another account than the one signed in, and the 401 reloads the tab as the one who is. It
|
||||
// never picks an account — a player checkpoint or a pref save from a tab still showing the previous
|
||||
// account would otherwise land on the new one.
|
||||
const EXPECTED_ACCOUNT_HEADER = "X-Siftlode-Expect-Account";
|
||||
|
||||
/** What this request carries besides the session cookie (which alone says who is asking) — the
|
||||
* debug session it records into — as the headers every request path sends (`req`, `beacon`,
|
||||
* `uploadReq`): one definition, so a path cannot quietly go out without them. */
|
||||
* account the tab was built for, the debug session it records into — as the headers every request
|
||||
* path sends (`req`, `beacon`, `uploadReq`): one definition, so a path cannot quietly go out
|
||||
* without them. */
|
||||
export function identityHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
const account = claimedAccount();
|
||||
if (account) headers[EXPECTED_ACCOUNT_HEADER] = account;
|
||||
const session = currentDebugSession();
|
||||
return session ? { [DEBUG_SESSION_HEADER]: session } : {};
|
||||
if (session) headers[DEBUG_SESSION_HEADER] = session;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
|
||||
@@ -320,8 +332,8 @@ export async function req<T = unknown>(
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
|
||||
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
|
||||
// Who the tab is (only setup calls and raw uploads pass their own headers: setup is
|
||||
// pre-auth, and an upload spreads `identityHeaders()` into its own).
|
||||
...identityHeaders(),
|
||||
},
|
||||
...opts,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// A tab names the account it was built for, and the server refuses a request from a tab showing
|
||||
// another one than the cookie's. Spelled on both sides, so nothing but this test notices a rename:
|
||||
// the server would see no header, and a tab left showing the previous account would act as the new
|
||||
// one without a word.
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf-8");
|
||||
const AUTH = read("../../../../backend/app/auth.py");
|
||||
const CORE = read("./core.ts");
|
||||
|
||||
describe("the account a tab was built for", () => {
|
||||
it("is the header the server checks", () => {
|
||||
const backend = /^EXPECTED_ACCOUNT_HEADER = "([^"]+)"$/m.exec(AUTH)?.[1];
|
||||
const frontend = /^const EXPECTED_ACCOUNT_HEADER = "([^"]+)";$/m.exec(CORE)?.[1];
|
||||
expect(backend).toBeTruthy();
|
||||
expect(backend).toBe(frontend?.toLowerCase());
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@
|
||||
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
|
||||
// with nothing personal in them (login, deleted) ride the query string.
|
||||
|
||||
import type { NotifLevel } from "./notifications";
|
||||
|
||||
export const entrySearch = new URLSearchParams(window.location.search);
|
||||
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
|
||||
@@ -58,3 +60,19 @@ export function clearEntryUrl(): void {
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** What a sign-in as someone else that did not happen says to the account still signed in here. The
|
||||
* callback answers a Google account that is not approved, or is suspended, without replacing the
|
||||
* session, and with the flags the sign-in page reads; the signed-in app read none of them, so the
|
||||
* person came back as before without a word. A request recorded on the way says so. */
|
||||
export function refusedSwitchNotice(
|
||||
search: URLSearchParams = entrySearch,
|
||||
hash: URLSearchParams = entryHash,
|
||||
): { level: NotifLevel; key: string } | null {
|
||||
if (search.get("login") === "suspended")
|
||||
return { level: "warning", key: "welcome.auth.suspended" };
|
||||
const access = hash.get("access");
|
||||
if (access === "requested") return { level: "info", key: "welcome.auth.accessRequested" };
|
||||
if (access === "denied") return { level: "warning", key: "header.account.switchNotApproved" };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -412,6 +412,22 @@ describe("the gate register (docs/gates.md)", () => {
|
||||
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
// A spec left out of the webkit and ipad projects runs on one engine only.
|
||||
it("lists every e2e spec that runs on one engine only", () => {
|
||||
const cfg = read("frontend/playwright.config.ts");
|
||||
const alternatives = /const CHROMIUM_ONLY = \/\(([^)]+)\)\\.spec\\.ts\//.exec(cfg)?.[1];
|
||||
expect(alternatives, "CHROMIUM_ONLY").toBeTruthy();
|
||||
const found = alternatives!
|
||||
.split("|")
|
||||
.map((name): [string, string] => ["e2e", `${name}.spec.ts`]);
|
||||
expect(missingFrom(found)).toEqual([]);
|
||||
// Any other way of leaving a spec out of a project would be a narrowing this does not read.
|
||||
expect([...cfg.matchAll(/testIgnore:\s*([^,\n]+)/g)].map((m) => m[1])).toEqual([
|
||||
"CHROMIUM_ONLY",
|
||||
"CHROMIUM_ONLY",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => {
|
||||
const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
|
||||
expect(pkg.scripts.lint).toBe("eslint . --max-warnings 0");
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("claiming the browser's local data", () => {
|
||||
expect(keys(localStorage)).toEqual(
|
||||
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(),
|
||||
);
|
||||
expect(keys(sessionStorage)).toEqual(["siftlode.settingsTab"]);
|
||||
expect(keys(sessionStorage)).toEqual(["siftlode.owner", "siftlode.settingsTab"]);
|
||||
});
|
||||
|
||||
it("an old per-account copy wins over a plain key already there", async () => {
|
||||
@@ -82,7 +82,23 @@ describe("claiming the browser's local data", () => {
|
||||
["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
|
||||
);
|
||||
expect(localStorage.getItem("siftlode.owner")).toBe("1");
|
||||
expect(keys(sessionStorage)).toEqual([]);
|
||||
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
|
||||
});
|
||||
|
||||
it("a tab reloading for the account another tab claimed the browser for drops its own view state", async () => {
|
||||
// Tab 2 signed in as 7 and claimed the browser; this tab still holds 1's sub-tab and the
|
||||
// onboarding flow 1 was in. Its sessionStorage is its own, so the browser's owner says nothing
|
||||
// about it.
|
||||
localStorage.setItem("siftlode.owner", "7");
|
||||
sessionStorage.setItem("siftlode.owner", "1");
|
||||
sessionStorage.setItem("siftlode.settingsTab", "plex");
|
||||
sessionStorage.setItem("siftlode.onboarding.active", "1");
|
||||
const { claimLocalData } = await storage();
|
||||
|
||||
claimLocalData(7);
|
||||
|
||||
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
|
||||
expect(sessionStorage.getItem("siftlode.owner")).toBe("7");
|
||||
});
|
||||
|
||||
it("the same account starting again keeps everything", async () => {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
// addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the
|
||||
// call site asking `enabled(level)` before it builds the attribute.
|
||||
//
|
||||
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
|
||||
// the error boundary all log through here, so it must not import any of them.
|
||||
// Nearly dependency-free on purpose (the build stamp and the storage key registry, which import
|
||||
// nothing of the app at runtime): the request layer, the notification store and the error boundary
|
||||
// all log through here, so it must not import any of them, not even through another module
|
||||
// (logImports.contract.test.ts follows the runtime imports).
|
||||
import { LS } from "./storage";
|
||||
import { FRONTEND_VERSION } from "./version";
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// The request layer, the notification store and the error boundary all log through lib/log.ts, so
|
||||
// log.ts must not reach any of them at runtime: the import would be a cycle, and the module that
|
||||
// logs a failure would load the one that failed. A type-only import is erased, so it does not count.
|
||||
// The comment in log.ts said this for a long time while nothing checked it.
|
||||
|
||||
const LIB = new URL("./", import.meta.url);
|
||||
const ALLOWED = ["log.ts", "storage.ts", "version.ts"];
|
||||
|
||||
/** The app modules `file` imports at runtime (relative, not `import type`), as lib/ file names. */
|
||||
function runtimeImports(file: string): string[] {
|
||||
const text = readFileSync(new URL(file, LIB), "utf-8");
|
||||
return [...text.matchAll(/^import\s+(?!type\b)[^;]*?from\s+"(\.[^"]+)";/gms)].map(
|
||||
(m) => `${m[1]!.replace(/^\.\//, "")}.ts`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("lib/log.ts", () => {
|
||||
it("reaches only the build stamp and the key registry at runtime", () => {
|
||||
const seen = new Set<string>();
|
||||
const queue = ["log.ts"];
|
||||
while (queue.length) {
|
||||
const file = queue.pop()!;
|
||||
if (seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
if (ALLOWED.includes(file)) queue.push(...runtimeImports(file));
|
||||
}
|
||||
expect([...seen].sort()).toEqual([...ALLOWED].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// "Sign in as someone else" with a Google account the server will not sign in (not approved, or
|
||||
// suspended) comes back to the account still signed in here, with the flags the sign-in page reads.
|
||||
// The signed-in app read none of them, so the person was back as before without a word.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { refusedSwitchNotice } from "./entryUrl";
|
||||
|
||||
const notice = (search: string, hash = "") =>
|
||||
refusedSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
|
||||
|
||||
describe("a sign-in as someone else the server refused", () => {
|
||||
it("says a request was recorded for an address not approved yet", () => {
|
||||
expect(notice("", "access=requested&email=b%40example.com")).toEqual({
|
||||
level: "info",
|
||||
key: "welcome.auth.accessRequested",
|
||||
});
|
||||
});
|
||||
|
||||
it("says the account is not approved, and nothing changed here", () => {
|
||||
expect(notice("", "access=denied")).toEqual({
|
||||
level: "warning",
|
||||
key: "header.account.switchNotApproved",
|
||||
});
|
||||
});
|
||||
|
||||
it("says the account is suspended", () => {
|
||||
expect(notice("?login=suspended")).toEqual({
|
||||
level: "warning",
|
||||
key: "welcome.auth.suspended",
|
||||
});
|
||||
});
|
||||
|
||||
it("says nothing on an ordinary load", () => {
|
||||
expect(notice("?page=stats", "")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,9 @@ describe("removed features stay removed", () => {
|
||||
// One account per browser session (2026-10-20): the per-tab account pin, the header and `?account=`
|
||||
// that carried it, and the account list and switch went. A request naming an account would now
|
||||
// be ignored by the server, so a pin left behind reads as working while it does nothing. Only the
|
||||
// claim that clears the old pin keys (storage.ts) and its test may name them.
|
||||
// claim that clears the old pin keys (storage.ts) and its test may name them. (The tab's
|
||||
// X-Siftlode-Expect-Account is not a pin: it never picks an account, the server refuses a request
|
||||
// from a tab showing another one than the cookie's.)
|
||||
it("leaves no per-tab account pin, header or switch", { timeout: 30_000 }, () => {
|
||||
const PIN =
|
||||
/X-Siftlode-Account|activeAccount|nextAccount|[?&]account=|switchAccount|\/api\/me\/(accounts|switch)/i;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LS } from "./storage";
|
||||
|
||||
const fire = (): Event => {
|
||||
const e = new Event("vite:preloadError", { cancelable: true });
|
||||
@@ -32,7 +33,7 @@ describe("a lazy chunk the server no longer has", () => {
|
||||
});
|
||||
|
||||
it("lets the error through when the reload a moment ago did not help", async () => {
|
||||
sessionStorage.setItem("sf.staleBuildReload", String(Date.now() - 5_000));
|
||||
sessionStorage.setItem(LS.staleBuildReload, String(Date.now() - 5_000));
|
||||
const { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild");
|
||||
const reload = vi.fn();
|
||||
uninstall = installStaleBuildReload(reload);
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
// minute at most: a chunk that is missing from the NEW build too would otherwise reload forever, so
|
||||
// the second failure inside the minute is let through to the error boundary, which says so.
|
||||
import { log } from "./log";
|
||||
import { LS } from "./storage";
|
||||
|
||||
const KEY = "sf.staleBuildReload";
|
||||
const KEY = LS.staleBuildReload;
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
let reloading = false;
|
||||
|
||||
+27
-10
@@ -3,8 +3,11 @@ import { useCallback, useState } from "react";
|
||||
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
|
||||
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
|
||||
// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each
|
||||
// declares its kind: what it is decides where it belongs, and a contract test holds the rule per kind
|
||||
// (storageKinds.contract.test.ts).
|
||||
// declares its kind: what it is decides where it belongs. storageKinds.contract.test.ts holds what a
|
||||
// reading of the source can: no key is spelled out (or handed to the Storage API as a literal)
|
||||
// anywhere else, a `pref` is read and written only through usePref or the one store listed as
|
||||
// keeping it, and usePref is handed a `pref` and nothing else. That a kind fits what the code does
|
||||
// with its key is the reviewer's to check.
|
||||
// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and
|
||||
// follows the account to every device — usePref, whose local copy only carries a value
|
||||
// this browser held before the server did.
|
||||
@@ -17,11 +20,12 @@ export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
|
||||
|
||||
const KEYS = {
|
||||
theme: { key: "siftlode.theme", kind: "pref" },
|
||||
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" },
|
||||
plexLayout: { key: "siftlode.plexLayout", kind: "device" },
|
||||
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" },
|
||||
// A side panel's group cards: their order, collapsed and hidden. Synced by lib/panelLayout.ts.
|
||||
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "pref" },
|
||||
plexLayout: { key: "siftlode.plexLayout", kind: "pref" },
|
||||
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "pref" },
|
||||
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
|
||||
filesLayout: { key: "siftlode.filesLayout", kind: "device" },
|
||||
filesLayout: { key: "siftlode.filesLayout", kind: "pref" },
|
||||
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
|
||||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||||
filesView: { key: "siftlode.filesView", kind: "view" },
|
||||
@@ -107,10 +111,12 @@ const KEYS = {
|
||||
// reason as above: the viewer has no account to keep it in.
|
||||
watchResume: { key: "siftlode.watchResume", kind: "public" },
|
||||
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in
|
||||
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id.
|
||||
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id, and
|
||||
// when the tab last reloaded onto a newer build (`lib/staleBuild.ts`).
|
||||
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
|
||||
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
|
||||
logTab: { key: "siftlode.logTab", kind: "device" },
|
||||
staleBuildReload: { key: "siftlode.staleBuildReload", kind: "device" },
|
||||
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
|
||||
|
||||
/** Every key the app stores, by name. */
|
||||
@@ -178,12 +184,16 @@ function settleStore(store: Storage, id: string, wipe: boolean): void {
|
||||
export function claimLocalData(id: number): void {
|
||||
const me = String(id);
|
||||
try {
|
||||
// Each store records whose it is: a tab's sessionStorage is its own, so a tab reloading for the
|
||||
// account another tab already claimed the browser for still holds the previous account's. A
|
||||
// tab that records no owner (one a build before this opened) goes by the browser's.
|
||||
const owner = localStorage.getItem(OWNER_KEY);
|
||||
const wipe = owner != null && owner !== me;
|
||||
settleStore(localStorage, me, wipe);
|
||||
settleStore(sessionStorage, me, wipe);
|
||||
const tabOwner = sessionStorage.getItem(OWNER_KEY) ?? owner;
|
||||
settleStore(localStorage, me, owner != null && owner !== me);
|
||||
settleStore(sessionStorage, me, tabOwner != null && tabOwner !== me);
|
||||
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
|
||||
localStorage.setItem(OWNER_KEY, me);
|
||||
sessionStorage.setItem(OWNER_KEY, me);
|
||||
} catch {
|
||||
/* storage unavailable: nothing to settle */
|
||||
}
|
||||
@@ -191,6 +201,13 @@ export function claimLocalData(id: number): void {
|
||||
claimListeners.forEach((reread) => reread());
|
||||
}
|
||||
|
||||
/** The account this tab was built for (claimed at boot), as its id; null before the claim. Every
|
||||
* request says so (`identityHeaders`): the server refuses one from a tab whose browser has since
|
||||
* signed in as someone else, and the tab reloads as whoever is signed in now. */
|
||||
export function claimedAccount(): string | null {
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/** `base` while this tab's account owns the browser's local state, else null: before the claim, and
|
||||
* after another tab has claimed it for a different account (a sign-in there replaces this one). */
|
||||
export function ownKey(base: string): string | null {
|
||||
|
||||
@@ -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