Files
siftlode/frontend/src/lib/localData.dom.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

168 lines
6.8 KiB
TypeScript

// @vitest-environment jsdom
//
// A browser holds ONE account's local state: the keys carry no account id, and the browser records
// whose they are. These pin the boot-time claim — the move of the old per-account keys onto the plain
// ones, the wipe when another account starts here — and the guard that keeps a tab from reading or
// writing them before its account is known, or after another tab has claimed them for someone else.
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // the claim lives in module state: each case starts unclaimed
});
const storage = () => import("./storage");
const keys = (s: Storage) => Object.keys(s).sort();
describe("claiming the browser's local data", () => {
it("moves the account's old per-account copies onto the plain keys and drops other accounts' copies", async () => {
localStorage.setItem("siftlode.filters.1", '{"q":"mine"}');
localStorage.setItem("siftlode.filters.7", '{"q":"theirs"}');
localStorage.setItem("siftlode.onboarding.dismissed.1", "1");
localStorage.setItem("siftlode.notifications.7", "[]");
sessionStorage.setItem("siftlode.settingsTab.1", "account");
sessionStorage.setItem("siftlode.settingsTab.7", "plex");
const { claimLocalData, LS } = await storage();
claimLocalData(1);
expect(localStorage.getItem(LS.filters)).toBe('{"q":"mine"}');
expect(localStorage.getItem(LS.onboardingDismissed)).toBe("1");
expect(sessionStorage.getItem(LS.settingsTab)).toBe("account");
expect(keys(localStorage)).toEqual(
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(),
);
expect(keys(sessionStorage)).toEqual(["siftlode.owner", "siftlode.settingsTab"]);
});
it("an old per-account copy wins over a plain key already there", async () => {
localStorage.setItem("siftlode.hints", "1");
localStorage.setItem("siftlode.hints.1", "0");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.hints")).toBe("0");
});
it("leaves a key that only looks like an account copy alone", async () => {
localStorage.setItem("siftlode.somethingElse.3", "x");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.somethingElse.3")).toBe("x");
});
it("drops the per-tab account pin the multi-account build kept", async () => {
sessionStorage.setItem("siftlode.activeAccount", "1");
sessionStorage.setItem("siftlode.nextAccount", "7");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(sessionStorage.getItem("siftlode.activeAccount")).toBeNull();
expect(sessionStorage.getItem("siftlode.nextAccount")).toBeNull();
});
it("another account starting here wipes the previous one's state, but not the browser's own", async () => {
localStorage.setItem("siftlode.owner", "7");
localStorage.setItem("siftlode.filters", '{"q":"theirs"}');
localStorage.setItem("siftlode.theme", "dark");
localStorage.setItem("siftlode.watchResume", '{"tok":{"at":5,"t":1}}');
localStorage.setItem("siftlode.watchRepeat", "false");
localStorage.setItem("unrelated.key", "kept");
sessionStorage.setItem("siftlode.settingsTab", "plex");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(keys(localStorage)).toEqual(
["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
);
expect(localStorage.getItem("siftlode.owner")).toBe("1");
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 () => {
localStorage.setItem("siftlode.owner", "1");
localStorage.setItem("siftlode.filters", '{"q":"mine"}');
sessionStorage.setItem("siftlode.settingsTab", "account");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.filters")).toBe('{"q":"mine"}');
expect(sessionStorage.getItem("siftlode.settingsTab")).toBe("account");
});
it("tells the stores read at import to read again", async () => {
const { claimLocalData, onLocalDataClaimed } = await storage();
const reread = vi.fn();
onLocalDataClaimed(reread);
claimLocalData(1);
expect(reread).toHaveBeenCalledOnce();
});
});
describe("the account's local state", () => {
it("is neither read nor written before the account is known", async () => {
localStorage.setItem("siftlode.hints", "0");
const { getAccountRaw, setAccountRaw, readAccount, writeAccount, ownKey } = await storage();
expect(getAccountRaw("siftlode.hints")).toBeNull();
expect(readAccount("siftlode.filters", "fallback")).toBe("fallback");
expect(ownKey("siftlode.filters")).toBeNull();
setAccountRaw("siftlode.page", "plex");
writeAccount("siftlode.filters", { q: "x" });
expect(localStorage.getItem("siftlode.page")).toBeNull();
expect(localStorage.getItem("siftlode.filters")).toBeNull();
});
it("is read and written under the plain key once claimed", async () => {
const { claimLocalData, getAccountRaw, setAccountRaw, ownKey } = await storage();
claimLocalData(1);
setAccountRaw("siftlode.page", "plex");
expect(localStorage.getItem("siftlode.page")).toBe("plex");
expect(getAccountRaw("siftlode.page")).toBe("plex");
expect(ownKey("siftlode.filters")).toBe("siftlode.filters");
});
it("stops being touched once another tab has claimed the browser for someone else", async () => {
const { claimLocalData, getAccountRaw, setAccountRaw } = await storage();
claimLocalData(1);
setAccountRaw("siftlode.page", "plex");
// Another tab signed in as account 6: its boot wiped the state and recorded the new owner.
localStorage.clear();
localStorage.setItem("siftlode.owner", "6");
localStorage.setItem("siftlode.hints", "0"); // account 6's
setAccountRaw("siftlode.page", "feed");
expect(localStorage.getItem("siftlode.page")).toBeNull();
expect(getAccountRaw("siftlode.hints")).toBeNull();
});
});