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.
168 lines
6.8 KiB
TypeScript
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();
|
|
});
|
|
});
|