fix: review round 3 — a new tab keeps its account's notices, a revoked tab account lands on the default

Deep-review round 3 over main..improvement/watch-background-audio.

Account boot
- The notification history and settings, and the hints toggle, are read
  when their modules load, which in a new tab (bookmark, typed URL) is
  before BootGate pins the account: they read as nobody's, and the tab's
  first notice replaced the account's stored history (up to 100 entries)
  with a one-item list. They now read again when the tab is pinned
  (onAccountPinned, run by setActiveAccount); a notice raised before the
  pin stays, after the history.
- A tab pinned to an account still in this browser's wallet but whose
  session ended (suspended, deleted, or revoked by "log out everywhere")
  got "signed out" from /api/me while the default account was still
  signed in, and signing in again as anyone else left it there. A refused
  per-tab account now leaves the wallet, and the probe answers as the
  default, so the tab reloads onto it.

Share page (/watch)
- The password unlock is limited per viewer (link + client IP), under a
  per-link ceiling that counts only wrong passwords. Keyed on the link
  alone, ten wrong guesses every five minutes locked out every viewer,
  the ones with the password included. client_ip moves to app.ratelimit
  so the public routes can key on it.
- Its failure messages and the load-failure screen are announced
  (role=alert, the password input described by its error). The sign-in,
  passphrase, password-change and setup form errors get role=alert too.

Comments that still described removed machinery are corrected: App's
collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig.

Tests: the account's history and hints across the pin; a revoked and a
suspended pinned account; the per-viewer limit and the ceiling; the
limiter's exhausted(); the share page's announced failures; a share
link's feed filters persisted. Each mutation-checked RED for its own
reason.
This commit is contained in:
2026-09-15 02:25:00 +02:00
parent f569b19c49
commit e7f38396a2
26 changed files with 366 additions and 93 deletions
+26
View File
@@ -12,6 +12,7 @@ Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False
from datetime import timedelta
import pytest
from fastapi.testclient import TestClient
from app.auth import ACTIVE_ACCOUNT_HEADER
@@ -76,6 +77,31 @@ def test_a_tab_pinned_to_an_account_signed_out_elsewhere_acts_as_nobody(client,
assert client.get("/api/me", headers=tab).json()["email"] == "bob3@example.com"
@pytest.mark.parametrize("ended", ["revoked", "suspended"])
def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_default(
client, make_user, db, ended
):
# A tab pinned to Alice, Bob the default. Alice's session then ends while she is still in this
# browser's wallet ("log out everywhere" on another device, or an admin suspends her). The tab's
# bootstrap probe used to answer "signed out": the landing page with Bob still signed in, and no way
# off it but closing the tab (signing in as anyone else there kept the tab pinned to Alice).
alice = make_user(email="alice4@example.com", password=_PW)
make_user(email="bob4@example.com", password=_PW)
assert _login(client, "alice4@example.com", _PW).status_code == 200
assert _login(client, "bob4@example.com", _PW).status_code == 200
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
assert client.get("/api/feed", headers=tab).status_code == 200
if ended == "revoked":
alice.session_epoch = (alice.session_epoch or 0) + 1
else:
alice.is_suspended = True
db.commit()
assert client.get("/api/me", headers=tab).json()["email"] == "bob4@example.com"
assert client.get("/api/feed", headers=tab).status_code == 401 # still nobody, never Bob
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
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