feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user