phase: 101_tokens_page_overhaul
All gates verified green — no defects found, nothing to fix. Final report:
## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)
**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact
**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)
**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)
**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
This commit is contained in:
@@ -367,12 +367,12 @@ def test_admin_generates_token_in_ui(page: Page, app_url: str, db_ready: None) -
|
||||
"Token created — copy it now; it won't be shown again."
|
||||
)
|
||||
|
||||
# The table shows the Active row: the em-dash status marker, no
|
||||
# The table shows the Active row (phase 101 D1: the Status column
|
||||
# is gone — the four-column active table IS the status): no
|
||||
# Revoked pill, a Revoke action.
|
||||
row = page.locator("#tokens-tbody tr", has_text="e2e-alice")
|
||||
expect(row).to_have_count(1)
|
||||
expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice")
|
||||
expect(row.locator("td").nth(3)).to_have_text("—") # Active = the plain em-dash
|
||||
expect(row.locator(".stale-pill")).to_have_count(0)
|
||||
expect(row.locator("button.token-revoke")).to_have_count(1)
|
||||
|
||||
@@ -603,8 +603,14 @@ def test_revocation_closes_the_door(
|
||||
row.locator("button.token-revoke").click()
|
||||
expect(row.locator(".history-confirm-yes")).to_be_visible()
|
||||
row.locator(".history-confirm-yes").click()
|
||||
revoked = page.locator("#tokens-tbody tr", has_text="e2e-revoke")
|
||||
expect(revoked.locator(".stale-pill")).to_have_text("Revoked", timeout=15_000)
|
||||
# Phase 101 (D1): the row LEAVES the active table and lands in
|
||||
# the revoked table below with its Revoked date cell (the
|
||||
# em-dash / .stale-pill column is gone from both tables — the
|
||||
# table's position IS the status).
|
||||
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-revoke")
|
||||
expect(revoked).to_have_count(1, timeout=15_000)
|
||||
expect(revoked.locator(".stale-pill")).to_have_count(0)
|
||||
expect(revoked.locator("td.tokens-date-cell").last).not_to_be_empty()
|
||||
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".')
|
||||
|
||||
# Enforcement is IMMEDIATE on the holder's next request (the
|
||||
|
||||
@@ -60,9 +60,12 @@ computed-style assertion):
|
||||
out-of-generation saved chat (the phase-53 seed pattern) renders
|
||||
the "Stale" pill in History — gray err-ink on gray err-bg with the
|
||||
gray err-line border, text intact.
|
||||
5. ``test_revoked_pill_gray_labeled`` — screenshot 6: a token
|
||||
generated + revoked THROUGH the Tokens UI renders the "Revoked"
|
||||
pill — the same gray err-family computed colors, text intact.
|
||||
5. ``test_revoked_table_state_labeled`` — screenshot 6: a token
|
||||
generated + revoked THROUGH the Tokens UI lands in the SEPARATE
|
||||
revoked table (phase 101 D1 re-scoped this pin: the "Revoked"
|
||||
pill is gone — the table's position IS the status) with its
|
||||
Revoked date cell, text intact (B5: text + position, never color
|
||||
alone).
|
||||
6. ``test_local_badge_gray_labeled`` — screenshot 3: a registered
|
||||
local-directory source renders the "Local" badge on Git sources —
|
||||
gray ok-ink on gray ok-bg, text intact.
|
||||
@@ -695,12 +698,14 @@ def test_stale_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> No
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Tokens: the "Revoked" pill (screenshot 6) — gray err family, text
|
||||
# intact (generated + revoked THROUGH the UI two-step)
|
||||
# 5. Tokens: the revoked-table state (screenshot 6) — phase 101 D1
|
||||
# replaced the "Revoked" pill: the row's very presence in the
|
||||
# revoked table below + its Revoked date cell (text, never color
|
||||
# alone — B5; generated + revoked THROUGH the UI two-step)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> None:
|
||||
def test_revoked_table_state_labeled(page: Page, app_url: str, db_ready: None) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
_seed_theme(app_url, _cookies(page))
|
||||
@@ -714,7 +719,7 @@ def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) ->
|
||||
page.click("#token-generate")
|
||||
row = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
|
||||
expect(row).to_have_count(1, timeout=15_000)
|
||||
expect(row.locator(".stale-pill")).to_have_count(0) # Active: the em-dash
|
||||
expect(row.locator(".stale-pill")).to_have_count(0) # Phase 101: no status column
|
||||
|
||||
# Revoke through the UI two-step (Revoke → Yes — the inline
|
||||
# confirm, no native dialog).
|
||||
@@ -722,17 +727,16 @@ def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) ->
|
||||
expect(row.locator(".history-confirm-yes")).to_be_visible()
|
||||
row.locator(".history-confirm-yes").click()
|
||||
|
||||
# Screenshot 6: the red "Revoked" pill — now gray err-ink on gray
|
||||
# err-bg with the gray err-line border, text intact.
|
||||
revoked = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
|
||||
pill = revoked.locator(".stale-pill")
|
||||
expect(pill).to_have_count(1)
|
||||
expect(pill).to_have_text("Revoked", timeout=15_000)
|
||||
_assert_gray(pill, "color", GRAY["err_ink"], label="revoked pill text")
|
||||
_assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="revoked pill bg")
|
||||
_assert_gray(
|
||||
pill, "borderTopColor", GRAY["err_line"], label="revoked pill border"
|
||||
)
|
||||
# Screenshot 6, re-scoped by phase 101 (D1): the "Revoked" pill is
|
||||
# gone — the table IS the status. The row LEAVES the active table
|
||||
# and lands in the revoked table below with its Revoked date cell
|
||||
# (text + the table's position, never color alone — B5).
|
||||
active = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
|
||||
expect(active).to_have_count(0)
|
||||
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-theme93")
|
||||
expect(revoked).to_have_count(1, timeout=15_000)
|
||||
expect(revoked.locator(".stale-pill")).to_have_count(0)
|
||||
expect(revoked.locator("td.tokens-date-cell").last).not_to_be_empty()
|
||||
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-theme93".')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user