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:
+19
-1
@@ -263,8 +263,26 @@ def stamp_session(request: Request, user: User) -> None:
|
||||
request.session["epoch"] = user.session_epoch or 0
|
||||
|
||||
|
||||
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
|
||||
# frontend). The cookie alone says who is asking, and one browser holds one account: a sign-in as
|
||||
# someone else in another tab replaces it under every open tab. A tab still showing the previous
|
||||
# account would otherwise act as the new one — its player checkpoints, pref saves, Sign out and
|
||||
# Delete account landing there — until it noticed. It never selects an account; a mismatch is
|
||||
# refused, and the tab reloads as whoever is signed in now.
|
||||
EXPECTED_ACCOUNT_HEADER = "x-siftlode-expect-account"
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
return _session_user(request, db, session_user_id(request))
|
||||
user = _session_user(request, db, session_user_id(request))
|
||||
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
|
||||
if expected is not None and expected != str(user.id):
|
||||
# The session stays: it is the other tab's sign-in, and it holds.
|
||||
log.info(
|
||||
"a request from a tab built for another account was refused",
|
||||
extra=logs.event("auth.session.account_changed", user_id=user.id),
|
||||
)
|
||||
raise refused(401, "account_changed")
|
||||
return user
|
||||
|
||||
|
||||
def _session_user(request: Request, db: Session, user_id: int | None) -> User:
|
||||
|
||||
@@ -25,6 +25,7 @@ from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
BASE_SCOPES,
|
||||
EXPECTED_ACCOUNT_HEADER,
|
||||
PASSWORD_MIN_LEN,
|
||||
READ_SCOPES,
|
||||
WRITE_SCOPES,
|
||||
@@ -36,6 +37,7 @@ from app.auth import (
|
||||
is_allowed,
|
||||
log,
|
||||
operator_contact,
|
||||
optional_current_user,
|
||||
session_user_id,
|
||||
stamp_session,
|
||||
)
|
||||
@@ -330,6 +332,11 @@ async def login(request: Request, db: Session = Depends(get_db)):
|
||||
# Google sign-in isn't configured on this instance — land back on the login page (the UI
|
||||
# hides the button, so this is just a safety net for a stale/direct hit).
|
||||
return RedirectResponse(url="/")
|
||||
# A sign-in, never a link: a connect abandoned at Google (Back, a closed tab) never reached the
|
||||
# callback that pops its markers, and left behind they would attach the identity signing in
|
||||
# here to the account still signed into this browser.
|
||||
request.session.pop("oauth_link_uid", None)
|
||||
request.session.pop("oauth_link_explicit", None)
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
_oauth_redirect_uri(request),
|
||||
@@ -354,7 +361,7 @@ async def callback(
|
||||
# /auth/google/connect; when present we attach to the current user instead of identifying by sub.
|
||||
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
|
||||
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
|
||||
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
|
||||
# attempt. A connect that never comes back here at all is cleared by /auth/login instead.
|
||||
link_uid = request.session.pop("oauth_link_uid", None)
|
||||
link_explicit = request.session.pop("oauth_link_explicit", False)
|
||||
try:
|
||||
@@ -584,12 +591,12 @@ def _complete_link(
|
||||
Google identity owned by another account, or to silently swap a different linked identity.
|
||||
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
||||
a YouTube grant — the caller pops it up front so an aborted flow leaves nothing behind."""
|
||||
# The marker must name the account still signed into THIS browser: a sign-in as someone else
|
||||
# between the start and the callback replaced it, and the identity must not land on the newcomer.
|
||||
if link_uid != session_user_id(request):
|
||||
return RedirectResponse(url="/?link=error")
|
||||
user = db.get(User, link_uid)
|
||||
if user is None:
|
||||
# The marker must name the account still signed into THIS browser, and that session must still
|
||||
# hold: a sign-in as someone else between the start and the callback replaced it (the identity
|
||||
# must not land on the newcomer), and a session revoked meanwhile ("log out everywhere", a
|
||||
# password change, a suspension) must not finish what it started.
|
||||
user = optional_current_user(request, db)
|
||||
if user is None or user.id != link_uid:
|
||||
return RedirectResponse(url="/?link=error")
|
||||
other = db.execute(
|
||||
select(User).where(User.google_sub == sub, User.id != user.id)
|
||||
@@ -616,7 +623,13 @@ def _complete_link(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Sign this browser out: the session holds one account, and it ends."""
|
||||
"""Sign this browser out: the session holds one account, and it ends — unless the tab asking was
|
||||
built for another account than the one signed in now (a sign-in as someone else in another tab):
|
||||
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
|
||||
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
|
||||
signed_in = session_user_id(request)
|
||||
if expected is not None and signed_in is not None and expected != str(signed_in):
|
||||
raise refused(401, "account_changed")
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@@ -136,7 +136,10 @@ def update_preferences(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
# Merge so partial updates don't wipe other keys.
|
||||
# Merge so partial updates don't wipe other keys — over the stored row read again under a row
|
||||
# lock: a client saves keys one at a time (several at once on a boot that moves this browser's
|
||||
# copies to the account), and two merges over the same earlier read would keep only the last.
|
||||
db.refresh(user, with_for_update=True)
|
||||
merged = dict(user.preferences or {})
|
||||
merged.update(preferences.root)
|
||||
user.preferences = merged
|
||||
|
||||
@@ -231,7 +231,7 @@ def _configured(monkeypatch):
|
||||
@pytest.fixture
|
||||
def client(db, override_dep, _configured):
|
||||
"""A TestClient wired to the test DB that goes through the REAL auth/session stack — no
|
||||
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
|
||||
`current_user` override — so login, the one-account session and epoch revoke are exercised end to end.
|
||||
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
|
||||
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
|
||||
override_dep(get_db, db.info["request"])
|
||||
|
||||
@@ -140,7 +140,7 @@ def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user,
|
||||
from app.routes.auth import _complete_link
|
||||
|
||||
starter, other = make_user(), make_user()
|
||||
session = {"user_id": starter.id if signed_in_as == "starter" else other.id}
|
||||
session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0}
|
||||
request = Request({"type": "http", "session": session, "headers": []})
|
||||
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
|
||||
db.refresh(starter)
|
||||
@@ -165,6 +165,47 @@ def test_no_app_code_reads_a_second_account():
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_a_tab_built_for_the_account_signed_in_before_is_refused(client, make_user):
|
||||
# Two tabs of one browser: one signed in as someone else, so the cookie is Bob's under both. The
|
||||
# tab still showing Alice says so with every request; acting as Bob there would put her player
|
||||
# positions, pref saves and Sign out on his account. Refused, and the session stays Bob's.
|
||||
alice = make_user(email="alice5@example.com", password=_PW)
|
||||
bob = make_user(email="bob5@example.com", password=_PW)
|
||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob5@example.com", _PW).status_code == 200
|
||||
|
||||
stale = client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(alice.id)})
|
||||
assert stale.status_code == 401
|
||||
assert stale.json()["detail"]["reason"] == "account_changed"
|
||||
assert (
|
||||
client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(bob.id)}).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/feed").status_code == 200
|
||||
# Nor may its Sign out end Bob's session.
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/logout", headers={"X-Siftlode-Expect-Account": str(alice.id)}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
# The probe answers who is signed in now, so the stale tab can reload as Bob.
|
||||
probe = client.get("/api/me", headers={"X-Siftlode-Expect-Account": str(alice.id)})
|
||||
assert probe.json()["email"] == "bob5@example.com"
|
||||
|
||||
|
||||
def test_setting_a_password_keeps_the_caller_signed_in(client, make_user):
|
||||
# The change ends every OTHER session (the epoch moves); the caller is stamped with the new one.
|
||||
make_user(email="dave@example.com", password=_PW)
|
||||
assert _login(client, "dave@example.com", _PW).status_code == 200
|
||||
r = client.post(
|
||||
"/auth/set-password",
|
||||
json={"password": "a brand new passphrase", "current_password": _PW},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/feed").status_code == 200
|
||||
|
||||
|
||||
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
||||
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
|
||||
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
|
||||
|
||||
@@ -270,6 +270,41 @@ def test_linking_a_free_identity_attaches_it(client, db, google, make_user):
|
||||
assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com")
|
||||
|
||||
|
||||
def test_a_connect_abandoned_at_google_leaves_the_next_sign_in_a_sign_in(
|
||||
client, db, google, make_user, monkeypatch
|
||||
):
|
||||
# A connect left at Google's screen (Back, a closed tab) never reaches the callback that pops
|
||||
# its marker. "Sign in as someone else" afterwards must sign that person in, not link their
|
||||
# Google identity onto the account still signed in here.
|
||||
me = make_user(email="me4@example.com")
|
||||
make_user(email="them4@example.com", google_sub="sub-them4", password=None)
|
||||
monkeypatch.setattr(settings, "allowed_emails", "them4@example.com")
|
||||
_start_link(client, "me4@example.com")
|
||||
assert client.get("/auth/login", follow_redirects=False).headers["location"] == "/fake-google"
|
||||
|
||||
r = _sign_in(client, google, sub="sub-them4", email="them4@example.com")
|
||||
|
||||
assert r.headers["location"] == "/"
|
||||
assert client.get("/api/me").json()["email"] == "them4@example.com"
|
||||
db.refresh(me)
|
||||
assert me.google_sub is None
|
||||
|
||||
|
||||
def test_a_link_whose_session_was_revoked_meanwhile_does_not_finish(client, db, google, make_user):
|
||||
# "Log out everywhere" (or a password change) between the start and the callback: the cookie
|
||||
# still names the account, but its session no longer holds, so it may not attach an identity.
|
||||
me = make_user(email="me5@example.com")
|
||||
_start_link(client, "me5@example.com")
|
||||
me.session_epoch = (me.session_epoch or 0) + 1
|
||||
db.commit()
|
||||
|
||||
r = _sign_in(client, google, sub="sub-late", email="late@example.com")
|
||||
|
||||
assert r.headers["location"] == "/?link=error"
|
||||
db.refresh(me)
|
||||
assert me.google_sub is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("access", "scope", "prompt"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""PUT /api/me/preferences merges the keys it is sent into the stored ones, DB-lane.
|
||||
|
||||
A client saves its preferences a key at a time, and several at once on the boot that moves this
|
||||
browser's copies to the account. Each merge must start from the row as stored when it runs, not as
|
||||
the request first read it — two merges over the same earlier read keep only the last one's keys.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account(
|
||||
client, db, make_user, override_dep
|
||||
):
|
||||
user = make_user(email="prefs@example.com", password=_PW)
|
||||
assert (
|
||||
client.post("/auth/password-login", json={"email": "prefs@example.com", "password": _PW})
|
||||
).status_code == 200
|
||||
|
||||
def read_then_another_save(request: Request, session: Session = Depends(get_db)) -> User:
|
||||
# The account as this request reads it, then a second save committing in the meantime.
|
||||
caller = current_user(request, session)
|
||||
with SessionLocal() as other:
|
||||
other.execute(
|
||||
update(User)
|
||||
.where(User.id == caller.id)
|
||||
.values(preferences={"seenVersion": "1.2.3"})
|
||||
)
|
||||
other.commit()
|
||||
return caller
|
||||
|
||||
override_dep(current_user, read_then_another_save)
|
||||
r = client.put("/api/me/preferences", json={"startOnHome": False})
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False}
|
||||
+2
-1
@@ -107,7 +107,8 @@ project checks needs a row.
|
||||
|
||||
| Scope | Narrowed | Why | Measured |
|
||||
| --- | --- | --- | --- |
|
||||
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 5 specs × 3 engines |
|
||||
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 9 specs: 6 × 3 engines, 3 on chromium only (the row below) |
|
||||
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 9 specs |
|
||||
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
|
||||
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||
|
||||
@@ -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