Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
370 lines
17 KiB
Python
370 lines
17 KiB
Python
"""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'<script[^>]*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'<script[^>]*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'<section[^>]*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("</main>")
|
|
assert main_end < html.find('id="auth-gate"'), "the gate sits AFTER #main"
|
|
# The content (the #sources-gate visual language).
|
|
assert '<h2 id="auth-gate-title">Enter your access token</h2>' in html
|
|
assert "Shared chats stay open" in html
|
|
form = re.search(r'<form[^>]*id="auth-gate-form"[^>]*>', html)
|
|
assert form, "the gate form"
|
|
assert '<label class="visually-hidden" for="auth-gate-input">Access token</label>' in html
|
|
inp = re.search(r'<input[^>]*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'<p[^>]*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'<section[^>]*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 '<h2 id="doc-auth-gate-title">Enter your access token</h2>' in html
|
|
assert re.search(r'<form[^>]*id="doc-auth-gate-form"[^>]*>', html)
|
|
assert re.search(
|
|
r'<input[^>]*id="doc-auth-gate-input"[^>]*name="token"[^>]*>', html
|
|
) or re.search(
|
|
r'<input[^>]*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
|