"""Unit: the in-app token gate (phase 79, task 05). Source-level house pattern (read the JS sources, no browser): pins the gate module's wiring — the ``bor.token`` localStorage key, the SILENT-RE-AUTH-BEFORE-WHOAMI order, the failed-re-auth key drop, the cache-invalidation choice — the header's full-whoami plumbing (``fetchWhoami`` exported, ``fetchIsAdmin`` delegating, the SINGLE ``fetch("/api/whoami")`` call site, the sign-out binding dropping the cached token), and the shell/viewer HTML wiring (the gate ships hidden + inert, the form/input/error ids, the admin link, the boot order token-gate.js AFTER router.js). The browser flows (gate → unlock → cached reload → revoked drop → sign-out) are E2E-pinned by ``tests/e2e/test_api_tokens.py`` (phase 79, task 07). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" HEADER_JS = ASSETS / "header.js" APP_JS = ASSETS / "app.js" DOCUMENT_JS = ASSETS / "document.js" TOKEN_GATE_JS = ASSETS / "token-gate.js" INDEX_HTML = FRONTEND / "index.html" DOCUMENT_HTML = FRONTEND / "document.html" def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") # ---------- token-gate.js: the module itself ---------- def test_token_gate_module_exists_and_exports_mount_gate() -> None: """token-gate.js is an ES module exposing mountGate(lockRoot, onAuthed) — the reusable mount point (the shell passes #main, the viewer passes its content wrapper).""" js = _text(TOKEN_GATE_JS) assert "export async function mountGate" in js # Relative import for the single-evaluation design (esbuild inlines # it into the page bundles; the Containerfile parity pin covers the # image build). assert 'from "./header.js"' in js assert '"/assets/header.js"' not in js def test_token_gate_uses_the_bor_token_localstorage_key() -> None: """The owner's sentence: "cache that token in browser storage". The cached key is the LITERAL bor.token — read at mount (silent re-auth), written on a successful login, dropped on a failed re-auth and on sign out (the header binding). Every localStorage access is try/catch (the fail-silence storage contract).""" js = _text(TOKEN_GATE_JS) assert '"bor.token"' in js, "the bor.token localStorage key literal" # The three accesses (read at mount, write on login, drop on a # failed re-auth) all go through the key constant — try/catch each # (the fail-silence storage contract: private mode degrades to # "re-enter the token each visit", never to a broken gate). assert "localStorage.getItem(TOKEN_KEY)" in js assert "localStorage.setItem(TOKEN_KEY" in js assert "localStorage.removeItem(TOKEN_KEY)" in js assert js.count("try {") >= 3 assert js.count("catch") >= 3 def test_silent_reauth_happens_before_the_whoami_check() -> None: """Source order: the cached token is re-sent to POST /api/token-auth BEFORE the whoami role check — the re-auth (re)sets the session cookie before any whoami settles, so the role check sees the post-auth role (no stale anonymous for a returning token user).""" js = _text(TOKEN_GATE_JS) reauth = js.find("(1) SILENT RE-AUTH — before the whoami check") role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()") assert -1 < reauth < role_check, "the silent re-auth must precede the whoami check" assert 'fetch("/api/token-auth"' in js # The re-auth block reads the cached token and posts it, all before # the role check's fetchWhoami. cached_read = js.find("readCachedToken()") assert -1 < cached_read < role_check # The role check goes through header.js's SHARED cached promise # (one /api/whoami per page load in dev). assert "await fetchWhoami()" in js def test_failed_silent_reauth_drops_the_cached_key() -> None: """A failed silent re-auth (revoked / unknown / network) removes the key — the token may have been revoked — before the mount falls through to the role check. The remove call sits in the (1) block, so a dead cached token can never linger in localStorage.""" js = _text(TOKEN_GATE_JS) reauth = js.find("(1) SILENT RE-AUTH — before the whoami check") role_check = js.find("(2) ROLE CHECK — header.js's fetchWhoami()") assert -1 < reauth < role_check block = js[reauth:role_check] assert "removeToken()" in block, "the failure path must drop the key" # removeToken itself hits the real localStorage.removeItem (inside # its own try/catch). fn = js.find("const removeToken") body = js[fn : js.find("\n};", fn)] assert "localStorage.removeItem(TOKEN_KEY)" in body def test_gate_ships_hidden_and_revealed_as_an_inert_pair() -> None: """The gate ships hidden + inert (the phase-16 ship-hidden pattern — an authenticated boot never shows it for a frame) and the JS always toggles hidden AND inert together (the WCAG inert-pair contract): revealing drops BOTH, hiding re-adds BOTH.""" js = _text(TOKEN_GATE_JS) fn = js.find("export async function mountGate") assert fn != -1 body = js[fn:] # Reveal: drop hidden AND inert. assert "gate.hidden = false" in body assert "gate.inert = false" in body # Hide: re-add hidden AND inert. assert "gate.hidden = true" in body assert "gate.inert = true" in body # The lock root is locked (inert) when the gate shows and unlocked # when auth settles — the locked app never receives focus. assert "lockRoot.inert = true" in body assert "lockRoot.inert = false" in body def test_gate_submit_caches_then_invalidates_the_whoami_cache() -> None: """Form submit: 204 → cache the token, THEN invalidate the module whoami cache (resetWhoami) and re-fetch through fetchWhoami — the documented choice (a direct re-fetch would leave header.js's boot-fired anonymous cache stale for the header re-boot). 401 → the role=alert error line, the input cleared + re-focused.""" js = _text(TOKEN_GATE_JS) fn = js.find("form.addEventListener(\"submit\"") assert fn != -1 block = js[fn:] store = block.find("storeToken(token)") reset = block.find("resetWhoami()") refetch = block.find("await fetchWhoami()") assert -1 < store < reset < refetch, ( "cache → invalidate → re-fetch: the order the contract pins" ) # The error path: 401 → showError() — the role=alert line revealed, # the input cleared + re-focused (defined once at mount, called from # the failure branch). assert "showError()" in block fn_show = js.find("const showError") show = js[fn_show : js.find("\n };", fn_show)] assert "error.hidden = false" in show assert "input.value = \"\"" in show assert "input.focus()" in show def test_gate_finds_its_markup_by_class() -> None: """The gate markup differs only in ids across the two pages (#auth-gate / #doc-auth-gate) — the module finds it by the shared .auth-gate CLASS (the ONE section on the page), and the token input by name (the form field, not the id).""" js = _text(TOKEN_GATE_JS) assert 'querySelector(".auth-gate")' in js assert 'input[name="token"]' in js # ---------- header.js: the full-whoami plumbing ---------- def test_header_fetch_whoami_is_the_single_call_site() -> None: """The string fetch("/api/whoami") appears in header.js EXACTLY ONCE (the single-request contract — the file's comments also mention whoami, so the pin is on the fetch call, not the word); fetchWhoami is exported and fetchIsAdmin delegates to it (a token user reads false from fetchIsAdmin — the admin surfaces key off role === "admin").""" js = _text(HEADER_JS) assert 'fetch("/api/whoami")' in js assert js.count('fetch("/api/whoami")') == 1, ( "no second whoami call site may enter header.js" ) assert "export function fetchWhoami" in js fn = js.find("export function fetchIsAdmin") assert fn != -1 body = js[fn : js.find("\n}", fn)] assert "fetchWhoami()" in body, "fetchIsAdmin must delegate to fetchWhoami" assert 'w.role === "admin"' in body def test_sign_out_binding_drops_the_cached_token_before_reload() -> None: """The sign-out binding (header.js, module-owned) removes localStorage["bor.token"] — try/catch, the fail-silence storage contract — AFTER the logout POST and BEFORE the reload: one logout clears the server session AND the cached token, so a signing-out token user meets the gate again on the next load.""" js = _text(HEADER_JS) logout = js.find('fetch("/api/logout", { method: "POST" })') drop = js.find('localStorage.removeItem("bor.token")') reload = js.find("window.location.reload()") assert -1 < logout < drop < reload, ( "sign out: logout → drop bor.token → reload (the order the contract pins)" ) def test_init_shared_header_keys_the_pair_off_authenticated() -> None: """Phase 79: initSharedHeader's auth PAIR (Sign in / Sign out) keys off the authenticated role — a token user (role "user") gets sign-in hidden + sign-out visible; the admin-ONLY surfaces (nav links, steering refresh) still key off role === "admin" (a user gets the anonymous branch: links hidden, the panel REMOVED, /api/steering never fetched). Admin/anonymous stays byte-identical to phase 16/19.""" js = _text(HEADER_JS) fn = js.find("function initSharedHeader") assert fn != -1 body = js[fn : js.find("\n}", fn)] assert 'link.hidden = signedIn' in body assert "btn.hidden = !signedIn" in body assert "navSources.hidden = !admin" in body assert "steeringPanel?.remove();" in body # ---------- index.html: the shell gate + boot order ---------- def _script_srcs(html: str) -> list[str]: return re.findall(r']*src="([^"]+)"', html) def test_shell_loads_token_gate_after_router() -> None: """index.html loads token-gate.js as a module AFTER app.js and router.js (boot order: brand.js classic → app.js module → router.js module → token-gate.js module) — the Containerfile bundles it (the parity pin in tests/integration/test_containerfile_assets.py covers the image). No page loads it BEFORE app.js (the gate's boot call is awaited by app.js's boot IIFE).""" html = _text(INDEX_HTML) srcs = _script_srcs(html) tag = [s for s in srcs if "token-gate.js" in s] assert tag, "the shell must load the token-gate module" order = [ srcs.index("assets/brand.js"), srcs.index("/assets/app.js"), srcs.index("/assets/router.js"), srcs.index(tag[0]), ] assert order == sorted(order), ( f"boot order brand.js → app.js → router.js → token-gate.js broken: {srcs}" ) m = re.search(r']*src="[^"]*token-gate\.js"[^>]*>', html) assert m and 'type="module"' in m.group(0), "token-gate.js is an ES module" def test_shell_gate_markup_ships_hidden_inert() -> None: """The shell's gate (body-level, AFTER #main) ships hidden + inert with the full contract: the #auth-gate section labelled by its h2 ("Enter your access token"), the sub line, the labelled form with the mono token input (autocomplete off — a token must never be offered by the password manager) and the Sign in submit, the role=alert error line (hidden, the owner-locked copy), and the "Sign in as admin" link (the header's ?next= convention, the no-JS fallback).""" html = _text(INDEX_HTML) # The section: body-level, after #main (before the footer). tag = re.search(r']*id="auth-gate"[^>]*>', html) assert tag, "the shell must carry the #auth-gate section" assert "hidden" in tag.group(0) and "inert" in tag.group(0), ( "the gate ships hidden + inert (the ship-hidden pattern)" ) assert 'class="auth-gate"' in tag.group(0) assert 'aria-labelledby="auth-gate-title"' in tag.group(0) main_end = html.find("") assert main_end < html.find('id="auth-gate"'), "the gate sits AFTER #main" # The content (the #sources-gate visual language). assert '

Enter your access token

' in html assert "Shared chats stay open" in html form = re.search(r']*id="auth-gate-form"[^>]*>', html) assert form, "the gate form" assert '' in html inp = re.search(r']*id="auth-gate-input"[^>]*>', html) assert inp, "the token input" for attr in ( 'name="token"', 'type="text"', 'autocomplete="off"', 'autocapitalize="none"', 'spellcheck="false"', "required", ): assert attr in inp.group(0), f"the token input must carry {attr}" assert 'type="submit"' in html and "Sign in" in html err = re.search(r']*class="auth-gate-error"[^>]*id="auth-gate-error"[^>]*>', html) assert err, "the error line" assert 'role="alert"' in err.group(0) and "hidden" in err.group(0) assert "That token isn" in html, "the owner-locked error copy" assert 'href="/login.html?next=/"' in html, "the admin link (?next= convention)" def test_shell_boots_the_gate_from_app_js() -> None: """app.js awaits mountGate(#main, no-op) at boot — BEFORE its initSharedHeader — so a silent re-auth lands before the first whoami fires (the header sees the post-auth role deterministically). In the shell, onAuthed needs no view work: the lazy views mount on first show exactly as today (mount-once, hide-forever untouched).""" js = _text(APP_JS) assert 'from "./token-gate.js"' in js gate_i = js.find('mountGate(document.getElementById("main"), () => {})') assert gate_i != -1, "the shell's boot call (no-op onAuthed)" init_i = js.find("await initSharedHeader();", gate_i) assert -1 < gate_i < init_i, "the gate settles BEFORE the header boots" # ---------- document.html / document.js: the viewer gate ---------- def test_viewer_gate_markup_reuses_the_shell_copy_renamed() -> None: """document.html carries the SAME gate markup as the shell, the ids renamed (#doc-auth-gate / #doc-auth-gate-form / #doc-auth-gate-input / #doc-auth-gate-error / #doc-auth-gate-title) — hidden + inert, the labelled form + input + role=alert error + admin link.""" html = _text(DOCUMENT_HTML) tag = re.search(r']*id="doc-auth-gate"[^>]*>', html) assert tag, "the viewer must carry the #doc-auth-gate section" assert "hidden" in tag.group(0) and "inert" in tag.group(0) assert 'class="auth-gate"' in tag.group(0) assert 'aria-labelledby="doc-auth-gate-title"' in tag.group(0) assert '

Enter your access token

' in html assert re.search(r']*id="doc-auth-gate-form"[^>]*>', html) assert re.search( r']*id="doc-auth-gate-input"[^>]*name="token"[^>]*>', html ) or re.search( r']*name="token"[^>]*id="doc-auth-gate-input"[^>]*>', html ), "the viewer's token input" assert re.search(r'id="doc-auth-gate-error"[^>]*role="alert"', html) or re.search( r'role="alert"[^>]*id="doc-auth-gate-error"', html ) def test_viewer_wires_the_gate_around_the_existing_boot() -> None: """document.js wires mountGate(#main, onAuthed) — onAuthed runs the content load for a SIGNED-IN role only (anonymous never fetches the gated content; the inline gate is the surface) — and the shared header boots in the .then AFTER the gate settles, for EVERY role (the gate locks #main, not the header — the anonymous contract is byte-identical to the shell). Awaiting the gate first is what makes the header race-free: the settled whoami is the single request both the gate and the header reuse (no second whoami, no stale bar).""" js = _text(DOCUMENT_JS) assert 'from "./token-gate.js"' in js gate_i = js.find('mountGate(document.getElementById("main")') assert gate_i != -1 boot = js[gate_i : gate_i + 400] assert "load()" in boot, "onAuthed runs the content load (signed-in role only)" # The header boots AFTER the gate settles (the .then) — not before # it, not inside onAuthed: one settled whoami for gate + header. load_i = boot.find("load()") then_i = boot.find(".then(") assert -1 < load_i < then_i, ("onAuthed (load) comes before the header .then") assert "initSharedHeader()" in boot[then_i:], ( "the header must boot on the settled whoami, after the gate" ) # The bare boot call is gone — the ONLY load(); statement in the # file is the one inside the gate's onAuthed callback. assert js.count("load();") == 1, ( "the un-gated load() call must be gone (onAuthed is the only caller)" ) # The whoami single-request contract survives: no direct whoami # fetch in the viewer script (header.js's cached promise). assert 'fetch("/api/whoami")' not in js