"""Phase 101 E2E (Playwright): the Tokens page overhaul — the owner's sentence, pinned in a real browser. Owner request (chat, 2026-09-12): "The tokens page should move revoked tokens to a separate table below the active ones. Generating a token should not be possible without giving it a name. Both active and revoked token tables should be searchable. I should be able to regenerate active tokens with the click of a button." Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_tokens_page.py -v --no-cov Test → claim mapping (every clause of the owner's sentence is pinned): 1. ``test_revoked_tokens_move_to_their_own_table`` — "move revoked tokens to a separate table below the active ones": two UI-created tokens, one revoked through the two-step — the active table keeps ONLY the live row (its thead is exactly four columns — the Status column is gone, D1), and the revoked section (visible sub-heading + the row's Revoked date cell — non-empty, later than the created date, NO action buttons) appears BELOW it. The section ships hidden while nothing is revoked (an empty table is noise). 2. ``test_both_tables_are_searchable`` — "both … tables should be searchable": each table's own search input live-filters its rows by case-insensitive label substring (no fetch) — the no-match row reads ``No tokens match "zzz".`` and clears with the input — and the query SURVIVES a re-show: leave via the Tuning nav, come back, the re-render re-applies the same filter (the phase-77 contract, D4). 3. ``test_a_token_cannot_be_generated_without_a_name`` — "generating a token should not be possible without giving it a name": a blank or whitespace-only name announces ``Give the token a name first.``, re-focuses the name input, keeps the once-block hidden, and sends NO request (the admin's own ``GET /api/tokens`` shows no new row — D3; the ``|| "token"`` fallback is gone). 4. ``test_regenerate_rotates_the_token`` — "regenerate active tokens with the click of a button": one Regenerate click opens the house two-step confirm (focus on Yes); Yes rotates atomically — the once-block re-appears with a NEW plaintext (``bor_`` + 32 hex, ≠ the original), the live region carries the D2 line, the active table holds exactly ONE ``e2e-rot`` row (the successor — newer created date), and the revoked table holds the ORIGINAL (same label, Revoked date set). The rotation is real: a FRESH context signs in with the NEW token through the real gate, and the ORIGINAL token is REFUSED at the gate in another fresh context (``#auth-gate-error`` role=alert — the rotation killed it immediately, the phase-79 revocation semantics). DB isolation: the shared e2e Postgres keeps ``api_tokens`` rows across suites (``test_api_tokens.py`` is the sibling suite that issues tokens with ``e2e-`` labels — its autouse cleanup deletes them too). An autouse fixture deletes the ``e2e-``-labeled rows before each test (never a TRUNCATE — the shared DB may hold the owner's real tokens). Every scenario runs in its OWN fresh browser context for token users — no cached token (localStorage) or session cookie leaks between tests. """ from __future__ import annotations import re from datetime import datetime import pytest from playwright.sync_api import Browser, BrowserContext, Page, expect from sqlalchemy import text from app.db import SessionLocal from e2e.auth_helpers import login, login_with_token #: The plaintext token's shape (owner-locked A4): prefix + 32 hex. TOKEN_RE = re.compile(r"bor_[0-9a-f]{32}") def _cleanup_e2e_tokens() -> None: """Delete this suite's issued tokens (deterministic re-runs). Label-scoped on ``e2e-`` — never a TRUNCATE: the shared e2e DB is also the dev DB and may hold the owner's real tokens. """ with SessionLocal() as db: db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) db.commit() @pytest.fixture(autouse=True) def _e2e_tokens_clean(db_ready: None) -> None: """Start every test from the same token-empty state (this file and test_api_tokens.py are the suites that issue tokens).""" _cleanup_e2e_tokens() def _go_tokens(page: Page, app_url: str) -> None: """Sign in as the admin (the real form login) straight onto the Tokens view — the create row is revealed for the admin.""" login(page, app_url, next="/tokens.html") expect(page.locator("#view-tokens")).to_be_visible() expect(page.locator("#tokens-gate")).to_be_hidden() # admin: no sign-in gate expect(page.locator("#token-create")).to_be_visible(timeout=15_000) def _create_via_ui(page: Page, label: str) -> str: """Issue one token through the UI create row (name → Generate). Returns the plaintext from the shown-once block (the A4 contract: it exists only in the field's value) and pins the new row in the ACTIVE table. """ page.fill("#token-label", label) page.click("#token-generate") expect(page.locator("#token-once")).to_be_visible(timeout=15_000) token = page.input_value("#token-once-value") assert TOKEN_RE.fullmatch(token), f"bad token shape: {token!r}" row = page.locator("#tokens-tbody tr", has_text=label) expect(row).to_have_count(1) expect(page.locator("#tokens-status")).to_have_text( "Token created — copy it now; it won't be shown again." ) return token def _revoke_via_ui(page: Page, label: str) -> None: """Revoke one active row through the house two-step (Revoke → Yes) and pin the relocation: gone from the active table, present in the revoked table below.""" row = page.locator("#tokens-tbody tr", has_text=label) expect(row).to_have_count(1, timeout=15_000) row.locator("button.token-revoke").click() expect(row.locator(".history-confirm-yes")).to_be_visible() row.locator(".history-confirm-yes").click() expect(page.locator("#tokens-tbody tr", has_text=label)).to_have_count(0) expect(page.locator("#tokens-revoked-tbody tr", has_text=label)).to_have_count(1) expect(page.locator("#tokens-status")).to_have_text(f'Revoked "{label}".') def _whoami(page: Page) -> dict: """The page's own ``/api/whoami`` read (the context's cookies).""" return page.evaluate("() => fetch('/api/whoami').then((r) => r.json())") # --------------------------------------------------------------------------- # 1. Revoked tokens live in their own table below the active ones # (D1: the table's position IS the status) # --------------------------------------------------------------------------- def test_revoked_tokens_move_to_their_own_table(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) _go_tokens(page, app_url) # The revoked section SHIPS hidden while nothing is revoked # (an empty table is noise) — and the active table is four # columns: Label | Created | Last used | Actions (the Status # column is gone — the all-active table needs no status). expect(page.locator("#tokens-revoked-heading")).to_be_hidden() expect(page.locator("#token-search-revoked")).to_be_hidden() expect(page.locator("#tokens-revoked-wrap")).to_be_hidden() expect(page.locator("#tokens-table thead th")).to_have_count(4) expect(page.locator("#tokens-table thead")).not_to_have_text("Status") _create_via_ui(page, "e2e-act") _create_via_ui(page, "e2e-rev") # Revoke e2e-rev through the two-step — the row LEAVES the active # table and lands in the revoked table below. row = page.locator("#tokens-tbody tr", has_text="e2e-rev") expect(row).to_have_count(1, timeout=15_000) row.locator("button.token-revoke").click() expect(row.locator(".history-confirm-yes")).to_be_visible() row.locator(".history-confirm-yes").click() # The active table shows ONLY e2e-act… expect(page.locator("#tokens-tbody tr", has_text="e2e-act")).to_have_count(1) expect(page.locator("#tokens-tbody tr", has_text="e2e-rev")).to_have_count(0) # …and the revoked section is visible BELOW the active table: its # heading, its own search input, and the row with its Revoked # date cell (non-empty, strictly after the created date) and NO # action buttons (a dead token has nothing left to act on). expect(page.locator("#tokens-revoked-heading")).to_be_visible(timeout=15_000) expect(page.locator("#tokens-revoked-heading")).to_have_text("Revoked tokens") expect(page.locator("#token-search-revoked")).to_be_visible() expect(page.locator("#tokens-revoked-wrap")).to_be_visible() revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-rev") expect(revoked).to_have_count(1) # (tokens-date-cell covers Created / Last used / Revoked — the label # cell has its own class — so Created is nth(0), Revoked the last.) created_iso = revoked.locator("td.tokens-date-cell").nth(0).get_attribute("title") revoked_cell = revoked.locator("td.tokens-date-cell").last expect(revoked_cell).not_to_be_empty() revoked_iso = revoked_cell.get_attribute("title") assert created_iso and revoked_iso, ( f"missing full-ISO hover dates: {created_iso} / {revoked_iso}" ) assert datetime.fromisoformat(revoked_iso) > datetime.fromisoformat(created_iso), ( "the Revoked date must be strictly after the created date" ) expect(revoked.locator("button")).to_have_count(0) expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-rev".') # --------------------------------------------------------------------------- # 2. Both tables are searchable (D4: client-side, per-table, the # queries survive re-renders / re-shows) # --------------------------------------------------------------------------- def test_both_tables_are_searchable(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) _go_tokens(page, app_url) _create_via_ui(page, "e2e-a1") _create_via_ui(page, "e2e-a2") _create_via_ui(page, "e2e-srchrev") _revoke_via_ui(page, "e2e-srchrev") # Both search inputs are revealed (the active one with the create # row, the revoked one with the revoked section). expect(page.locator("#token-search-active")).to_be_visible() expect(page.locator("#token-search-revoked")).to_be_visible() # Active table: type e2e-a1 → only that row visible… page.fill("#token-search-active", "e2e-a1") expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible() expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible() expect(page.locator("#tokens-no-match-row")).to_be_hidden() # …type zzz → the no-match row, the distinct-from-empty-state copy… page.fill("#token-search-active", "zzz") expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).not_to_be_visible() expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible() expect(page.locator("#tokens-no-match-row")).to_be_visible() expect(page.locator("#tokens-no-match-row")).to_have_text('No tokens match "zzz".') # …clear the input → both rows back, no-match hidden. page.fill("#token-search-active", "") expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible() expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).to_be_visible() expect(page.locator("#tokens-no-match-row")).to_be_hidden() # The same three-beat on the revoked search. page.fill("#token-search-revoked", "zzz") expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).not_to_be_visible() expect(page.locator("#tokens-revoked-no-match-row")).to_be_visible() expect(page.locator("#tokens-revoked-no-match-row")).to_have_text('No tokens match "zzz".') page.fill("#token-search-revoked", "e2e-srchrev") expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).to_be_visible() expect(page.locator("#tokens-revoked-no-match-row")).to_be_hidden() page.fill("#token-search-revoked", "") expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).to_be_visible() # The query SURVIVES a re-show: type, leave via the Tuning nav, # come back — the phase-77 refresh re-renders the rows, and the # filter is still applied (the visible set is unchanged). page.fill("#token-search-active", "e2e-a1") expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible() page.click("#nav-tuning") expect(page.locator("#view-tuning")).to_be_visible() page.click("#nav-tokens") expect(page.locator("#view-tokens")).to_be_visible(timeout=15_000) expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible() expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible() expect(page.locator("#tokens-no-match-row")).to_be_hidden() # --------------------------------------------------------------------------- # 3. A nameless token cannot be generated (D3: the live-region line, # no request, no row) # --------------------------------------------------------------------------- def test_a_token_cannot_be_generated_without_a_name( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _go_tokens(page, app_url) def _list_count() -> int: """The admin's own GET /api/tokens (page.request shares the context's signed session cookie): the server-side row count.""" r = page.request.get(app_url + "/api/tokens") assert r.status == 200, r.text return len(r.json()["tokens"]) # Deterministic start (the autouse cleanup): the active table is # in its empty state and the server holds no tokens at all. before = _list_count() assert before == 0 expect(page.locator("#tokens-empty-row")).to_be_visible() # BLANK name: the refusal line, the once-block stays hidden, and # the request NEVER happened (the server-side count is # unchanged — the old `|| "token"` fallback is gone). expect(page.locator("#token-label")).to_have_value("") page.click("#token-generate") expect(page.locator("#tokens-status")).to_have_text("Give the token a name first.") expect(page.locator("#token-once")).to_be_hidden() assert _list_count() == before expect(page.locator("#tokens-empty-row")).to_be_visible() # WHITESPACE-only name: the same refusal (trimmed client-side). page.fill("#token-label", " ") page.click("#token-generate") expect(page.locator("#tokens-status")).to_have_text("Give the token a name first.") expect(page.locator("#token-once")).to_be_hidden() assert _list_count() == before expect(page.locator("#tokens-empty-row")).to_be_visible() # --------------------------------------------------------------------------- # 4. Regenerate rotates the credential end to end (D2: the atomic # rotation — the new token signs in, the old one is refused) # --------------------------------------------------------------------------- def test_regenerate_rotates_the_token( page: Page, browser: Browser, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _go_tokens(page, app_url) original = _create_via_ui(page, "e2e-rot") row = page.locator("#tokens-tbody tr", has_text="e2e-rot") expect(row).to_have_count(1) # (Created is the first date cell — the label cell has its own class.) old_created_iso = row.locator("td.tokens-date-cell").nth(0).get_attribute("title") assert old_created_iso, "the created cell must carry the full ISO hover date" # ONE click opens the house two-step confirm (focus on Yes) — # the rotation is a primary lifecycle action, Regenerate sits # before Revoke in the Actions cell. row.locator("button.token-regenerate").first.click() expect(row.locator(".history-confirm-text")).to_have_text( "Regenerate? The current token is revoked." ) yes = row.locator(".history-confirm-yes") expect(yes).to_be_visible() expect(yes).to_be_focused() yes.click() # Wait for the full 201 sequence (load → once-block reveal → # announce) on the live-region line: while the request is in # flight the once-block still holds the ORIGINAL plaintext from # the create — the D2 line lands LAST, so it is the sync point. expect(page.locator("#tokens-status")).to_have_text( "Regenerated \"e2e-rot\" — copy the new token now; it won't be shown again.", timeout=15_000, ) # The once-block re-appeared with a NEW plaintext (≠ the original, # well-formed) — the same shown-once block, the A4 value-only # contract. expect(page.locator("#token-once")).to_be_visible() new_token = page.input_value("#token-once-value") assert TOKEN_RE.fullmatch(new_token), f"bad new token shape: {new_token!r}" assert new_token != original, "the rotation must mint a fresh credential" # The active table holds exactly ONE e2e-rot row — the successor # (its created date is strictly newer than the original's)… new_row = page.locator("#tokens-tbody tr", has_text="e2e-rot") expect(new_row).to_have_count(1) new_created_iso = new_row.locator("td.tokens-date-cell").nth(0).get_attribute("title") assert new_created_iso, "the successor's created cell must carry the full ISO hover date" assert datetime.fromisoformat(new_created_iso) > datetime.fromisoformat(old_created_iso), ( "the successor must be a NEW row (a newer created date)" ) # …and the REVOKED table now holds the ORIGINAL (same label — the # hand-out name persists — its created date is the old one, its # Revoked date is set). expect(page.locator("#tokens-revoked-heading")).to_be_visible() rev_row = page.locator("#tokens-revoked-tbody tr", has_text="e2e-rot") expect(rev_row).to_have_count(1) assert ( rev_row.locator("td.tokens-date-cell").nth(0).get_attribute("title") == old_created_iso ), "the revoked row must be the ORIGINAL (same created date)" rev_iso = rev_row.locator("td.tokens-date-cell").last.get_attribute("title") assert rev_iso, "the original's Revoked date must be set" assert datetime.fromisoformat(rev_iso) >= datetime.fromisoformat(old_created_iso) # A FRESH token-user context signs in with the NEW token through # the real gate (the gate closes, the app is interactive) — the # rotation minted a working credential. ok_ctx: BrowserContext | None = None try: ok_ctx = browser.new_context() ok = ok_ctx.new_page() ok.set_default_timeout(30_000) login_with_token(ok, app_url, new_token) assert _whoami(ok) == {"authenticated": True, "role": "user"} finally: if ok_ctx is not None: ok_ctx.close() # …and the ORIGINAL token is REFUSED in another fresh context # (the rotation killed it immediately — the phase-79 # revocation semantics: the role=alert line, the gate stays, the # visitor is anonymous). bad_ctx: BrowserContext | None = None try: bad_ctx = browser.new_context() bad = bad_ctx.new_page() bad.set_default_timeout(30_000) login_with_token(bad, app_url, original, expect_error=True) expect(bad.locator("#auth-gate-error")).to_have_attribute("role", "alert") assert _whoami(bad) == {"authenticated": False, "role": "anonymous"} finally: if bad_ctx is not None: bad_ctx.close()