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:
@@ -59,8 +59,10 @@ user), reset to a deterministic baseline every run by
|
||||
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
|
||||
one in-progress + one watched + one saved video, prefs pinned to `language: en`. A second account,
|
||||
the plain **member** `e2e-member@siftlode.local`, gets the same baseline and its own session
|
||||
(`e2e/.auth/member.json`); only `member.spec.ts` runs as it, holding what a non-admin sees (no admin
|
||||
entry, no admin call, an admin link lands on the feed). Credentials come from `E2E_EMAIL` (default
|
||||
(`e2e/.auth/member.json`). Two specs run as it: `member.spec.ts`, holding what a non-admin sees (no
|
||||
admin entry, no admin call, no failed request, an admin link lands on the feed), and `devices.spec.ts`,
|
||||
which signs it in on a desktop and an iPad at once and changes its channel layout on the server (put
|
||||
back in a `finally`) — a spec that reads the member's server prefs must not run beside it. Credentials come from `E2E_EMAIL` (default
|
||||
`e2e@siftlode.local`), `E2E_MEMBER_EMAIL` (default `e2e-member@siftlode.local`) and `E2E_PASSWORD`
|
||||
(both accounts) — **required, no default**: run the suite through the ops wrapper, which exports it
|
||||
from the secret store. Override the container name with
|
||||
|
||||
@@ -39,6 +39,12 @@ test("a preference follows the member to another device, a device setting stays"
|
||||
await gotoPage(desktop, "settings");
|
||||
await desktop.getByRole("switch", { name: "Performance mode" }).click();
|
||||
await expect(desktop.locator("html")).toHaveAttribute("data-perf", "1");
|
||||
// The Settings prefs save as one payload: the next one (a hints change) would carry performance
|
||||
// mode if it went to the account. Waited for, so the iPad below cannot simply load first.
|
||||
const hints = desktop.getByRole("switch", { name: "Show hints" });
|
||||
const [save] = await Promise.all([savedPrefs(desktop), hints.click()]);
|
||||
expect(Object.keys(save.request().postDataJSON() as object)).not.toContain("performanceMode");
|
||||
await Promise.all([savedPrefs(desktop), hints.click()]);
|
||||
|
||||
await openApp(ipad);
|
||||
await expect(ipad.locator("html")).not.toHaveAttribute("data-perf", "1");
|
||||
|
||||
@@ -12,7 +12,8 @@ import { retryConnect } from "./retryConnect";
|
||||
* 2. seed — reset the dedicated E2E accounts to a known baseline, in-container (idempotent);
|
||||
* 3. authenticate each ONCE via password-login and persist its session + `en` locale to its own
|
||||
* storageState, so the real specs never re-auth (auth endpoints are rate-limited). The suite
|
||||
* runs as the admin; `member.spec.ts` runs as the plain member, from MEMBER_STORAGE.
|
||||
* runs as the admin; `member.spec.ts` and `devices.spec.ts` run as the plain member, from
|
||||
* MEMBER_STORAGE.
|
||||
*/
|
||||
|
||||
const STORAGE = "e2e/.auth/state.json";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` runs from it. */
|
||||
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
|
||||
* `devices.spec.ts` run from it. */
|
||||
export const MEMBER_STORAGE = "e2e/.auth/member.json";
|
||||
|
||||
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
|
||||
|
||||
@@ -19,21 +19,39 @@ const CORE = [
|
||||
"settings",
|
||||
];
|
||||
|
||||
/** Collect every admin call and every refused response the page makes while `run` goes. */
|
||||
async function watchRequests(page: Page, run: () => Promise<void>): Promise<string[]> {
|
||||
const isApi = (url: string) => new URL(url).pathname.startsWith("/api/");
|
||||
|
||||
/** Collect every admin call and every failed API answer the page gets while `run` goes. `run` is
|
||||
* handed `settled`, which waits until no API request of the page is in flight: an error state
|
||||
* renders only once its request has answered, and a check made before that passes on a page that
|
||||
* is still loading. */
|
||||
async function watchRequests(
|
||||
page: Page,
|
||||
run: (settled: () => Promise<void>) => Promise<void>,
|
||||
): Promise<string[]> {
|
||||
const bad: string[] = [];
|
||||
const pending = new Set<Request>();
|
||||
const onRequest = (r: Request) => {
|
||||
if (!isApi(r.url())) return;
|
||||
pending.add(r);
|
||||
if (new URL(r.url()).pathname.startsWith("/api/admin")) bad.push(`admin call ${r.url()}`);
|
||||
};
|
||||
const onDone = (r: Request) => pending.delete(r);
|
||||
const onResponse = (r: Response) => {
|
||||
if (r.status() === 401 || r.status() === 403) bad.push(`${r.status()} ${r.url()}`);
|
||||
if (isApi(r.url()) && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`);
|
||||
};
|
||||
const settled = () => expect.poll(() => pending.size, { timeout: 15_000 }).toBe(0);
|
||||
page.on("request", onRequest);
|
||||
page.on("requestfinished", onDone);
|
||||
page.on("requestfailed", onDone);
|
||||
page.on("response", onResponse);
|
||||
try {
|
||||
await run();
|
||||
await run(settled);
|
||||
await settled();
|
||||
} finally {
|
||||
page.off("request", onRequest);
|
||||
page.off("requestfinished", onDone);
|
||||
page.off("requestfailed", onDone);
|
||||
page.off("response", onResponse);
|
||||
}
|
||||
return bad;
|
||||
@@ -43,7 +61,7 @@ test.describe("a member", () => {
|
||||
test("gets no admin entry, and every page of the rail opens without an error", async ({
|
||||
page,
|
||||
}) => {
|
||||
const bad = await watchRequests(page, async () => {
|
||||
const bad = await watchRequests(page, async (settled) => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
|
||||
|
||||
@@ -59,6 +77,7 @@ test.describe("a member", () => {
|
||||
for (const id of ids) {
|
||||
await page.getByTestId(`nav-${id}`).click();
|
||||
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
|
||||
await settled();
|
||||
// An error state is a `role=alert` StateMessage; a page that failed to load shows one.
|
||||
await expect(page.getByRole("alert"), `error state on ${id}`).toHaveCount(0);
|
||||
}
|
||||
@@ -71,8 +90,8 @@ test.describe("a member", () => {
|
||||
const bad = await watchRequests(page, () => openApp(page, `/?page=${id}`));
|
||||
expect(bad, id).toEqual([]);
|
||||
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
|
||||
// The member lands on one of their own pages instead.
|
||||
await expect(page.locator('[data-testid^="nav-"][aria-current="page"]')).toHaveCount(1);
|
||||
// The member lands on the feed instead, and the rail says so.
|
||||
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
||||
import { notifyActionError } from "./lib/actionErrors";
|
||||
import { setLanguage, type LangCode } from "./i18n";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import { refusedSwitchNotice } from "./lib/entryUrl";
|
||||
import {
|
||||
PREF_KEY,
|
||||
saveLayoutLocal,
|
||||
@@ -230,6 +231,16 @@ export default function App() {
|
||||
if (signedIn) clearParams("access", "verify", "reset");
|
||||
}, [signedIn]);
|
||||
|
||||
// A sign-in as someone else that the server refused (an account not approved, or suspended) came
|
||||
// back here as the account still signed in — say so, once.
|
||||
const switchAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!signedIn || switchAnswered.current) return;
|
||||
switchAnswered.current = true;
|
||||
const notice = refusedSwitchNotice();
|
||||
if (notice) notify({ level: notice.level, message: t(notice.key) });
|
||||
}, [signedIn, t]);
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
|
||||
|
||||
@@ -81,7 +81,7 @@ function readStoredFilters(): FilterMap {
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
|
||||
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
|
||||
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import i18n from "../i18n";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { api, HttpError, type Me } from "../lib/api";
|
||||
import * as actionErrors from "../lib/actionErrors";
|
||||
import NavSidebar from "./NavSidebar";
|
||||
|
||||
vi.mock("./navigationContext", () => ({
|
||||
@@ -108,4 +109,17 @@ describe("the account panel", () => {
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
expect(logout).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// Reloading after a sign-out that failed only brought the session back, with no word of why.
|
||||
it("a sign-out that failed says so and keeps the page", async () => {
|
||||
vi.spyOn(api, "logout").mockRejectedValue(new HttpError(500, "boom"));
|
||||
const report = vi.spyOn(actionErrors, "notifyActionError").mockImplementation(() => {});
|
||||
const panel = await mount(me());
|
||||
|
||||
fireEvent.click(row(panel, "Sign out"));
|
||||
|
||||
await vi.waitFor(() => expect(report).toHaveBeenCalledOnce());
|
||||
expect(report.mock.calls[0]![1]).toBe("Couldn't sign you out");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "./ui/NavRail";
|
||||
import { usePrefs } from "./prefsContext";
|
||||
import { pageHref } from "../lib/nav";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||
@@ -74,13 +75,16 @@ export default function NavSidebar({
|
||||
const { setReference: setAcctAnchor } = acctPop.refs;
|
||||
|
||||
async function logout() {
|
||||
// Ends this browser's session, then reloads onto the sign-in page.
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
/* reload regardless: the page then shows whatever the server says */
|
||||
}
|
||||
location.reload();
|
||||
// Ends this browser's session, then reloads onto the sign-in page. A sign-out that failed says
|
||||
// so and stays: the reload would only bring back the session that is still there.
|
||||
const signedOut = await api.logout().then(
|
||||
() => true,
|
||||
(err: unknown) => {
|
||||
notifyActionError(err, t("errors.writeFailed.signOut"));
|
||||
return false;
|
||||
},
|
||||
);
|
||||
if (signedOut) location.reload();
|
||||
}
|
||||
|
||||
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useBackToClose } from "../lib/history";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { claimLocalData, LS, setAccountRaw } from "../lib/storage";
|
||||
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { api } from "../lib/api";
|
||||
import { PAGE_KEY } from "./navigationInit";
|
||||
|
||||
@@ -350,6 +350,26 @@ describe("NavigationProvider · a page the account cannot open", () => {
|
||||
await settle();
|
||||
expect(result.current.page).toBe("feed");
|
||||
});
|
||||
|
||||
// Kept behind the feed, the lost page stayed in the address bar, a click on Feed did nothing (the
|
||||
// feed was already on screen), and a later grant switched the page unasked.
|
||||
it("gives the lost page up: the address bar, the remembered page and a later grant agree", async () => {
|
||||
window.history.replaceState({ sfPage: "users" }, "", "/?page=users");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("users");
|
||||
act(() => {
|
||||
qc.setQueryData(qk.me(), member);
|
||||
});
|
||||
await settle();
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
|
||||
act(() => {
|
||||
qc.setQueryData(qk.me(), { ...member, role: "admin" });
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.page).toBe("feed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
|
||||
|
||||
@@ -38,6 +38,24 @@ import {
|
||||
//
|
||||
// The contexts, their value types and the hooks live in navigationContext.ts.
|
||||
|
||||
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
|
||||
function pageRootParams(page: Page): Record<string, string | null> {
|
||||
return {
|
||||
page: page === "feed" ? null : page,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||
};
|
||||
}
|
||||
|
||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
|
||||
@@ -49,6 +67,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// and the address bar (stamped from it below) agree.
|
||||
const me = useMe();
|
||||
const page = reachablePage(chosenPage, me);
|
||||
// A page this account can no longer open (a role taken away, a module switched off mid-session)
|
||||
// is given up, not kept behind the feed: kept, the address bar would go on naming it, a click on
|
||||
// Feed would be a no-op (setPage's same-page guard sees the feed already on screen), and a later
|
||||
// grant would switch the page by itself. Adjusted during render; the history entry and the
|
||||
// remembered page follow in the effect below.
|
||||
const [givenUp, setGivenUp] = useState(0);
|
||||
if (page !== chosenPage) {
|
||||
setPageState(page);
|
||||
setGivenUp((n) => n + 1);
|
||||
}
|
||||
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
|
||||
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
|
||||
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
||||
@@ -97,20 +125,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({
|
||||
page: next === "feed" ? null : next,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||
}),
|
||||
urlWithParams(pageRootParams(next)),
|
||||
);
|
||||
notifyNav();
|
||||
then?.();
|
||||
@@ -195,6 +210,18 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
|
||||
}, [signedIn, page, objectTitle, i18n.language, t]);
|
||||
|
||||
// The page given up above is the feed, and the current entry says so (replaced, not pushed: no
|
||||
// step was taken). Its own keys go with it, as on any page switch.
|
||||
useEffect(() => {
|
||||
if (!givenUp) return;
|
||||
setAccountRaw(PAGE_KEY, "feed");
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, sfPage: "feed" },
|
||||
"",
|
||||
urlWithParams(pageRootParams("feed")),
|
||||
);
|
||||
}, [givenUp]);
|
||||
|
||||
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
|
||||
// entry: every later page change writes its own entry through setPage.
|
||||
const [mountPage] = useState(page);
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
|
||||
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
|
||||
// (BootGate claims the local state for the account first; a different account reloads the tab). `playlistOpen` is an ephemeral
|
||||
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
|
||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||
// into (and is never greeted by a stale value from) the feed.
|
||||
@@ -84,7 +84,7 @@ function readPlexFilters(): PlexFilters {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
|
||||
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so a
|
||||
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
|
||||
// `present` records which dimensions the link actually carried: only those are persisted (below).
|
||||
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
||||
|
||||
@@ -146,8 +146,8 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
||||
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
|
||||
// before this provider mounts.
|
||||
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
|
||||
// state to the account (claimLocalData) before this provider mounts.
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
||||
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
},
|
||||
"writeFailed": {
|
||||
"hideChannel": "Couldn't change whether the channel is hidden",
|
||||
"signOut": "Couldn't sign you out",
|
||||
"blockChannel": "Couldn't change the channel's block",
|
||||
"unblockChannel": "Couldn't unblock the channel",
|
||||
"channelSettings": "Couldn't save the channel's settings",
|
||||
@@ -134,6 +135,7 @@
|
||||
"gone": "This playlist no longer exists on YouTube"
|
||||
},
|
||||
"refused": {
|
||||
"account_changed": "This tab was showing another account than the one signed in now, so it reloads as that one",
|
||||
"address_has_account": "That address already has an account — delete the account instead",
|
||||
"admin_first": "Create the admin account first",
|
||||
"admin_only": "Only an admin can do that",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"about": "About",
|
||||
"signOut": "Sign out",
|
||||
"signInAsOther": "Sign in as someone else",
|
||||
"switchNotApproved": "That Google account isn't approved for this instance yet, so you're still signed in as before.",
|
||||
"googleAddress": "Google account: {{email}}",
|
||||
"activity": "Activity"
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
},
|
||||
"writeFailed": {
|
||||
"hideChannel": "Nem sikerült módosítani a csatorna elrejtését",
|
||||
"signOut": "Nem sikerült kijelentkeztetni",
|
||||
"blockChannel": "Nem sikerült módosítani a csatorna tiltását",
|
||||
"unblockChannel": "Nem sikerült feloldani a csatorna tiltását",
|
||||
"channelSettings": "Nem sikerült menteni a csatorna beállításait",
|
||||
@@ -134,6 +135,7 @@
|
||||
"gone": "Ez a lejátszási lista már nem létezik a YouTube-on"
|
||||
},
|
||||
"refused": {
|
||||
"account_changed": "Ez a fül más fiókot mutatott, mint ami most be van lépve, ezért újratölt azzal",
|
||||
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette",
|
||||
"admin_first": "Előbb hozd létre az admin fiókot",
|
||||
"admin_only": "Ezt csak admin teheti meg",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"about": "Névjegy",
|
||||
"signOut": "Kijelentkezés",
|
||||
"signInAsOther": "Belépés más fiókkal",
|
||||
"switchNotApproved": "Ez a Google-fiók még nincs jóváhagyva ezen a példányon, ezért továbbra is az eddigi fiókkal vagy belépve.",
|
||||
"googleAddress": "Google-fiók: {{email}}",
|
||||
"activity": "Napló"
|
||||
},
|
||||
|
||||
@@ -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