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:
@@ -902,6 +902,452 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
|
||||
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
|
||||
|
||||
|
||||
# ---------- phase 101 task 02: the tokens split (active/revoked
|
||||
# tables, the per-table search, the required name) ----------
|
||||
|
||||
|
||||
def test_tokens_view_split_scaffold_in_the_shell() -> None:
|
||||
"""Phase 101 task 02 (D1/D3/D4): the #view-tokens skeleton is the
|
||||
SPLIT — the active table is FOUR columns (the Status ``<th>`` is
|
||||
gone, the Actions header stays visually-hidden), its empty row is
|
||||
``colspan=4``, and a NEW ship-hidden #tokens-no-match-row
|
||||
(``colspan=4``, empty ``<td>`` — the text is JS-filled) ships in
|
||||
the same tbody; the per-table search inputs (#token-search-active
|
||||
between the once-block and the active table wrap, #token-search-
|
||||
revoked in the revoked section) ship hidden with ``type=search``
|
||||
+ aria-label + the .token-search class; the NEW revoked section
|
||||
(heading + search + the four-column table with the Revoked ``<th>``
|
||||
+ the ship-hidden no-match row) ships hidden and sits AFTER the
|
||||
active table's wrap; and the create row's input is the REQUIRED
|
||||
name (aria-label "Token name", placeholder
|
||||
"e.g. alice — required")."""
|
||||
html = _html()
|
||||
view = html.find('<section class="view" id="view-tokens"')
|
||||
assert view != -1, "the #view-tokens section must be in the shell"
|
||||
main_end = html.find("</main>", view)
|
||||
body = html[view:main_end]
|
||||
# The active table: EXACTLY four columns — and no Status header
|
||||
# anywhere in the view (D1: the table IS the status).
|
||||
table_i = body.find('<table class="tokens-table" id="tokens-table">')
|
||||
assert table_i != -1, "the active #tokens-table must exist"
|
||||
thead = body[table_i:body.find("</thead>", table_i)]
|
||||
assert thead.count('<th scope="col">') == 4, (
|
||||
"the active table is four columns (Label | Created | Last used | Actions)"
|
||||
)
|
||||
assert '<th scope="col">Status</th>' not in body, (
|
||||
"the Status column is gone from BOTH tables (D1)"
|
||||
)
|
||||
assert '<th scope="col">Revoked</th>' in body, (
|
||||
"the revoked table carries the Revoked column"
|
||||
)
|
||||
# The active tbody: the empty row (colspan=4) + the NEW no-match
|
||||
# row (ship-hidden, colspan=4, its <td> text JS-filled).
|
||||
empty = re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>.*?</tr>', body, re.S)
|
||||
assert empty and 'colspan="4"' in empty.group(0), (
|
||||
"the active empty row is colspan=4"
|
||||
)
|
||||
no_match = re.search(r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>', body)
|
||||
assert no_match, "the active no-match row ships hidden"
|
||||
no_match_td = re.search(
|
||||
r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>\s*<td colspan="4">\s*</td>\s*</tr>',
|
||||
body,
|
||||
)
|
||||
assert no_match_td, "the no-match row is colspan=4 with an EMPTY <td>"
|
||||
# The per-table search inputs (D4): type=search, aria-labeled, the
|
||||
# house .token-search class, BOTH ship hidden.
|
||||
for sid, label in (
|
||||
("token-search-active", "Search active tokens"),
|
||||
("token-search-revoked", "Search revoked tokens"),
|
||||
):
|
||||
m = re.search(rf'<input[^>]*id="{sid}"[^>]*>', body, re.S)
|
||||
assert m, f"missing the #{sid} search input"
|
||||
tag = m.group(0)
|
||||
assert 'type="search"' in tag, f"#{sid} must be a search input"
|
||||
assert f'aria-label="{label}"' in tag, f"#{sid} must be labeled"
|
||||
assert 'class="token-search"' in tag, f"#{sid} must carry the house class"
|
||||
assert "hidden" in tag, f"#{sid} ships hidden (anonymous-safe)"
|
||||
# The create row: the name is REQUIRED (D3) — the new aria-label +
|
||||
# placeholder.
|
||||
label_in = re.search(r'<input[^>]*id="token-label"[^>]*>', body, re.S)
|
||||
assert label_in, "the create row's name input must exist"
|
||||
assert 'aria-label="Token name"' in label_in.group(0)
|
||||
assert 'placeholder="e.g. alice — required"' in label_in.group(0)
|
||||
# The active search sits BETWEEN the once-block and the active
|
||||
# table's wrap.
|
||||
assert (
|
||||
body.find('id="token-once"')
|
||||
< body.find('id="token-search-active"')
|
||||
< body.find('id="tokens-table-wrap"')
|
||||
), "the active search is between the once-block and the table wrap"
|
||||
# The revoked section (D1): heading + search + wrap — ALL ship
|
||||
# hidden — and it sits BELOW the active table's wrap.
|
||||
heading = re.search(
|
||||
r'<h2[^>]*id="tokens-revoked-heading"[^>]*>Revoked tokens</h2>', body
|
||||
)
|
||||
assert heading and "hidden" in heading.group(0), (
|
||||
"the revoked sub-heading ships hidden with its visible text"
|
||||
)
|
||||
assert 'class="tokens-revoked-heading"' in heading.group(0)
|
||||
wrap = re.search(r'<div[^>]*id="tokens-revoked-wrap"[^>]*>', body)
|
||||
assert wrap and "hidden" in wrap.group(0), ("the revoked wrap ships hidden")
|
||||
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
|
||||
assert 'aria-label="Revoked tokens"' in wrap.group(0)
|
||||
assert 'id="tokens-revoked-tbody"' in body
|
||||
assert "Revoked tokens — newest first" in body, "the revoked table caption"
|
||||
assert re.search(
|
||||
r'<tr[^>]*id="tokens-revoked-no-match-row"[^>]*hidden>', body
|
||||
), "the revoked no-match row ships hidden"
|
||||
assert body.find('id="tokens-table-wrap"') < body.find(
|
||||
'id="tokens-revoked-heading"'
|
||||
), "the revoked section sits BELOW the active table's wrap"
|
||||
|
||||
|
||||
def test_tokens_js_split_search_and_required_name() -> None:
|
||||
"""Phase 101 task 02 (D1/D3/D4) in tokens.js: the module state
|
||||
carries the persistent per-table queries (initialized ""), the
|
||||
scoped lookups cover the new ids, ``loadTokens`` SPLITS the
|
||||
fetched list by ``tok.revoked`` (the server order kept per table),
|
||||
shows the active empty row iff zero ACTIVE rows, shows the revoked
|
||||
section (heading + search + wrap) iff ≥1 revoked row via the
|
||||
``setRevokedSectionVisible`` helper, and RE-APPLIES both filters
|
||||
after every render (a load never loses the queries); ``makeRow``
|
||||
takes the table (the revoked variant renders the revoked_at date,
|
||||
no actions); ``applyFilter`` is pure DOM (case-insensitive label
|
||||
substring over the data rows — the state rows excluded — the
|
||||
no-match copy quotes the ORIGINAL query in textContent); the input
|
||||
listeners are armed in the ADMIN branch (after the whoami gate) and
|
||||
set the module query + applyFilter with NO fetch; and a blank name
|
||||
is refused client-side — the exact announce line, the re-focus, the
|
||||
early return BEFORE any fetch, and the deleted "token" fallback."""
|
||||
js = _asset("tokens.js")
|
||||
# Module state: the persistent queries, initialized once.
|
||||
assert re.search(r"let activeQuery = \"\";", js), (
|
||||
"the active search query is module state (initialized '')"
|
||||
)
|
||||
assert re.search(r"let revokedQuery = \"\";", js), (
|
||||
"the revoked search query is module state (initialized '')"
|
||||
)
|
||||
# The new scoped lookups (the phase-76 root-scoping contract).
|
||||
for sid in (
|
||||
"tokens-no-match-row",
|
||||
"token-search-active",
|
||||
"tokens-revoked-heading",
|
||||
"token-search-revoked",
|
||||
"tokens-revoked-wrap",
|
||||
"tokens-revoked-tbody",
|
||||
"tokens-revoked-no-match-row",
|
||||
):
|
||||
assert f'querySelector("#{sid}")' in js, f"missing the #{sid} lookup"
|
||||
# makeRow: the table parameter + the revoked variant (the
|
||||
# revoked_at date cell, no actions).
|
||||
assert "function makeRow(tok, table)" in js, "makeRow takes the table"
|
||||
assert 'table === "revoked"' in js, "the revoked variant branches on the table"
|
||||
assert "revoked_at" in js, "the Revoked cell renders tok.revoked_at"
|
||||
# loadTokens: the split (per-table appends, server order kept),
|
||||
# the section helper call, the BOTH filters re-applied after the
|
||||
# render.
|
||||
load = js.find("async function loadTokens()")
|
||||
assert load != -1, "loadTokens must exist"
|
||||
load_body = js[load:js.find("\n }", load)]
|
||||
assert 'makeRow(tok, "active")' in load_body, "active rows render into the active table"
|
||||
assert 'makeRow(tok, "revoked")' in load_body, "revoked rows render into the revoked table"
|
||||
split_i = load_body.find(".revoked")
|
||||
assert split_i != -1, "the split keys off tok.revoked (the D5 bool)"
|
||||
assert "setRevokedSectionVisible(revoked.length)" in load_body, (
|
||||
"the revoked section shows iff ≥1 revoked row"
|
||||
)
|
||||
fetch_i = load_body.find('fetch("/api/tokens")')
|
||||
active_apply = load_body.find("applyFilter(tbody, noMatchRow, activeQuery)")
|
||||
revoked_apply = load_body.find(
|
||||
"applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery)"
|
||||
)
|
||||
assert 0 <= fetch_i < active_apply < revoked_apply, (
|
||||
"BOTH filters re-apply after the fetch + render (D4: a re-render "
|
||||
"never loses the queries)"
|
||||
)
|
||||
# The section show/hide helper: heading + search + wrap together.
|
||||
helper = js.find("function setRevokedSectionVisible")
|
||||
assert helper != -1, "the setRevokedSectionVisible(n) helper must exist"
|
||||
helper_body = js[helper:js.find("\n }", helper)]
|
||||
for name in ("revokedHeading.hidden", "searchRevoked.hidden", "revokedWrap.hidden"):
|
||||
assert name in helper_body, f"the section helper must toggle {name}"
|
||||
# applyFilter: pure DOM — case-insensitive label substring over
|
||||
# the data rows (the no-match/empty state rows excluded), the
|
||||
# no-match row visible ⟺ non-empty query + zero visible rows, its
|
||||
# <td> textContent carries the ORIGINAL query in quotes.
|
||||
f = js.find("function applyFilter(")
|
||||
assert f != -1, "applyFilter must exist"
|
||||
f_body = js[f:js.find("\n }", f)]
|
||||
assert ".toLowerCase()" in f_body, "the match is case-insensitive"
|
||||
assert "tr === targetNoMatchRow" in f_body, (
|
||||
"the no-match row is never treated as a data row"
|
||||
)
|
||||
assert "emptyRow" in f_body, "the empty-state row is not a data row"
|
||||
assert ".tokens-label-cell" in f_body, (
|
||||
"the label cell is the filter's data source"
|
||||
)
|
||||
assert 'No tokens match "${' in f_body, (
|
||||
"the no-match copy (the user's original query in quotes, textContent)"
|
||||
)
|
||||
assert "innerHTML" not in f_body, "applyFilter is textContent-only"
|
||||
# The input listeners: armed in the ADMIN branch only (after the
|
||||
# whoami gate), they set the module query + applyFilter — NO
|
||||
# fetch (D4 is client-side).
|
||||
gate_i = js.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i != -1
|
||||
for i, var in (
|
||||
(js.find('searchActive.addEventListener("input"'), "activeQuery"),
|
||||
(js.find('searchRevoked.addEventListener("input"'), "revokedQuery"),
|
||||
):
|
||||
assert i != -1, f"the {var} input listener must be armed"
|
||||
assert gate_i < i, f"the {var} listener is armed in the ADMIN branch (after the gate)"
|
||||
seg = js[i:js.find("});", i)]
|
||||
assert f"{var} =" in seg, f"the listener writes the {var} module state"
|
||||
assert "fetch(" not in seg, "the search is client-side (no fetch)"
|
||||
branch = js[gate_i:js.find("return;", gate_i)]
|
||||
assert "searchActive.hidden = true" in branch, (
|
||||
"the anonymous branch keeps the active search hidden"
|
||||
)
|
||||
assert "searchActive.hidden = false" in js, (
|
||||
"the admin branch reveals the active search (with the create row)"
|
||||
)
|
||||
# The required name (D3): the exact line + re-focus + the early
|
||||
# return BEFORE any fetch; the old fallback is GONE from the file.
|
||||
gen = js.find("async function generateToken()")
|
||||
assert gen != -1, "generateToken must exist"
|
||||
gen_body = js[gen:]
|
||||
check_i = gen_body.find("if (!label)")
|
||||
fetch_i = gen_body.find('fetch("/api/tokens"')
|
||||
assert 0 <= check_i < fetch_i, (
|
||||
"the blank-name check runs BEFORE any request (D3: the request "
|
||||
"simply doesn't happen)"
|
||||
)
|
||||
assert 'announce("Give the token a name first.")' in gen_body, (
|
||||
"the exact D3 live-region line"
|
||||
)
|
||||
assert "labelInput.focus()" in gen_body, "the name input re-focuses"
|
||||
assert '|| "token"' not in js, (
|
||||
"the old blank-label 'token' fallback is DELETED (D3)"
|
||||
)
|
||||
|
||||
|
||||
def test_tokens_split_css_pins() -> None:
|
||||
"""Phase 101 task 02: styles.css carries the .token-search surface
|
||||
(full width, the ≥44px target, the house input family — --line
|
||||
hairline, --surface fill, ink text — no new hue) and the
|
||||
.tokens-revoked-heading sub-heading (the phase-97 .kb-level h2
|
||||
voice: mono, 1rem, brand-ink); the empty/no-match rows' styling
|
||||
stays CLASS-based (.tokens-empty-row — both tables covered)."""
|
||||
css = _asset("styles.css")
|
||||
block = re.search(r"\.token-search \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .token-search"
|
||||
body = block.group(1)
|
||||
assert "width: 100%" in body, "the search input is full width"
|
||||
assert "min-height: 44px" in body, "the ≥44px touch target"
|
||||
assert "border: 1px solid var(--line)" in body, "the house input hairline"
|
||||
assert "background: var(--surface)" in body, "the house input surface"
|
||||
heading = re.search(r"\.tokens-revoked-heading \{([\s\S]*?)\n\}", css)
|
||||
assert heading, "styles.css must style .tokens-revoked-heading"
|
||||
hbody = heading.group(1)
|
||||
assert "font-family: var(--mono)" in hbody, "the .kb-level h2 voice (mono)"
|
||||
assert "font-size: 1rem" in hbody
|
||||
assert "color: var(--brand-ink)" in hbody, "brand-ink — AA on the page background"
|
||||
assert re.search(r"\.tokens-empty-row td \{", css), (
|
||||
"the empty/no-match rows' styling is CLASS-based (both tables)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- phase 101 task 03: the Regenerate control (rotation) ----------
|
||||
|
||||
|
||||
def test_tokens_js_regenerate_control() -> None:
|
||||
"""Phase 101 task 03 (D2) in tokens.js: the active row's Actions
|
||||
cell appends the Regenerate control BEFORE the Revoke control, and
|
||||
EACH control owns its OWN .tokens-actions wrapper span (a confirm
|
||||
in one never clobbers the other — the shared cell hosts two
|
||||
independent confirm scopes); makeRegenerateControl is a structural
|
||||
mirror of makeRevokeControl — the .token-regenerate button (label
|
||||
"Regenerate", aria-label "Regenerate token: <label>"), the first
|
||||
click swaps the cell to the confirm pair (the EXACT text
|
||||
"Regenerate? The current token is revoked.", the
|
||||
history-confirm-yes/no classes, focus to Yes), No / a failure
|
||||
restore via restoreRegenerate (the button back, focus restored);
|
||||
confirmRegenerate disables the Yes button while in flight, POSTs
|
||||
/api/tokens/<id>/regenerate (no body), and on 201 runs
|
||||
loadTokens() FIRST, THEN reveals the once-block (the value-only
|
||||
contract), THEN announces the D2 line; a 404 removes the row +
|
||||
re-fetches + the house 404 line; a 409 re-fetches + the same
|
||||
line; any other failure / network error announces the neutral
|
||||
copy and restores (retryable)."""
|
||||
js = _asset("tokens.js")
|
||||
# The Actions cell order: Regenerate FIRST (the primary lifecycle
|
||||
# action), both controls appended in the active variant.
|
||||
row_i = js.find("function makeRow(tok, table)")
|
||||
assert row_i != -1, "makeRow must exist"
|
||||
row_body = js[row_i:js.find("\n }", row_i)]
|
||||
assert "actionsTd.append(" in row_body, (
|
||||
"the Actions cell hosts BOTH controls"
|
||||
)
|
||||
regen_i = row_body.find("makeRegenerateControl(tok, tr)")
|
||||
revoke_i = row_body.find("makeRevokeControl(tok, tr)")
|
||||
assert 0 <= regen_i < revoke_i, (
|
||||
"the active Actions cell appends Regenerate BEFORE Revoke (D2)"
|
||||
)
|
||||
# The per-control wrapper spans: EACH control owns its OWN
|
||||
# .tokens-actions span (the two swap-scopes are independent).
|
||||
mk_i = js.find("function makeRegenerateControl(")
|
||||
assert mk_i != -1, "makeRegenerateControl must exist"
|
||||
mk_body = js[mk_i:js.find("\n }", mk_i)]
|
||||
assert 'cell.className = "tokens-actions"' in mk_body, (
|
||||
"the Regenerate control owns its OWN .tokens-actions wrapper span"
|
||||
)
|
||||
mkr_i = js.find("function makeRevokeControl(")
|
||||
assert mkr_i != -1, "makeRevokeControl must exist"
|
||||
mkr_body = js[mkr_i:js.find("\n }", mkr_i)]
|
||||
assert 'cell.className = "tokens-actions"' in mkr_body, (
|
||||
"the Revoke control keeps its OWN .tokens-actions wrapper span"
|
||||
)
|
||||
# The button: the .token-regenerate class, the label, the
|
||||
# aria-label (the row buttons carry their own aria-labels — the
|
||||
# house convention).
|
||||
assert 'regenBtn.className = "token-regenerate"' in mk_body, (
|
||||
"the button carries the .token-regenerate class"
|
||||
)
|
||||
assert 'regenBtn.textContent = "Regenerate"' in mk_body, (
|
||||
"the button is labeled Regenerate"
|
||||
)
|
||||
assert "Regenerate token: ${tok.label}" in mk_body, (
|
||||
"the aria-label names the token (Regenerate token: <label>)"
|
||||
)
|
||||
# The two-step swap: the EXACT confirm text, the history-confirm-*
|
||||
# pair, focus to Yes.
|
||||
assert (
|
||||
'label.textContent = "Regenerate? The current token is revoked."'
|
||||
in mk_body
|
||||
), "the confirm text is EXACT (the D2 copy)"
|
||||
assert 'label.className = "history-confirm-text"' in mk_body
|
||||
assert 'yes.className = "history-confirm-yes"' in mk_body, (
|
||||
"the Yes button reuses the house confirm class"
|
||||
)
|
||||
assert 'no.className = "history-confirm-no"' in mk_body, (
|
||||
"the No button reuses the house confirm class"
|
||||
)
|
||||
assert "yes.focus()" in mk_body, "focus moves to Yes (keyboard confirm)"
|
||||
# The restore path: No and a failure bring the button back, focus
|
||||
# restored (the revoke control's restore pattern, copied).
|
||||
assert 'no.addEventListener("click", restoreRegenerate)' in mk_body, (
|
||||
"No restores the Regenerate button"
|
||||
)
|
||||
assert "cell.replaceChildren(regenBtn)" in mk_body, (
|
||||
"the restore swaps the cell back to the button"
|
||||
)
|
||||
assert "regenBtn.focus()" in mk_body, "the restore returns the focus"
|
||||
# confirmRegenerate: the disabled-while-in-flight Yes, the POST
|
||||
# path (JSON, NO body — the revoke control's request shape).
|
||||
cr_i = js.find("async function confirmRegenerate(")
|
||||
assert cr_i != -1, "confirmRegenerate must exist"
|
||||
body = js[cr_i:js.find("\n }", cr_i)]
|
||||
dis_i = body.find("yesBtn.disabled = true")
|
||||
fetch_i = body.find("/regenerate")
|
||||
assert 0 <= dis_i < fetch_i, (
|
||||
"the Yes button disables BEFORE the request (no double-fire)"
|
||||
)
|
||||
assert (
|
||||
'fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" })'
|
||||
in body
|
||||
), "the POST path is /api/tokens/<id>/regenerate with NO body"
|
||||
# The 201 sequence (pinned order): the JSON parse, the re-entrant
|
||||
# loadTokens() FIRST (the relocation), the once-block reveal
|
||||
# (value only — the A4 contract), THEN the D2 live-region line.
|
||||
json_i = body.find("const data = await r.json()")
|
||||
load_i = body.find("await loadTokens()", json_i)
|
||||
reveal_i = body.find("onceValue.value = data.token", json_i)
|
||||
show_i = body.find("onceBlock.hidden = false", json_i)
|
||||
ann_i = body.find('Regenerated "${tok.label}"', json_i)
|
||||
assert 0 <= json_i < load_i < reveal_i < show_i < ann_i, (
|
||||
"on 201 the load runs FIRST, then the once-block reveal, then "
|
||||
"the D2 line (D2's pinned sequence)"
|
||||
)
|
||||
assert (
|
||||
'Regenerated "${tok.label}" — copy the new token now; '
|
||||
"it won't be shown again."
|
||||
) in body, "the D2 live-region line is EXACT"
|
||||
# The 404: the row vanished — row.remove() + the re-fetch
|
||||
# (reconciliation) + the house 404 line (the revoke control's
|
||||
# existing copy — one house message for the one common case).
|
||||
i404 = body.find("r.status === 404")
|
||||
i409 = body.find("r.status === 409")
|
||||
iok = body.find("if (!r.ok)")
|
||||
assert 0 <= i404 < i409 < iok, (
|
||||
"the 404/409 branches precede the generic failure"
|
||||
)
|
||||
seg404 = body[i404:i409]
|
||||
assert "row.remove()" in seg404, "the 404 removes the vanished row"
|
||||
assert "await loadTokens()" in seg404, (
|
||||
"the 404 re-fetches (the reconciliation)"
|
||||
)
|
||||
assert 'announce("That token was already revoked.")' in seg404, (
|
||||
"the 404 reuses the house line"
|
||||
)
|
||||
seg409 = body[i409:iok]
|
||||
assert "await loadTokens()" in seg409, (
|
||||
"the 409 re-fetches (the row was revoked between render and click)"
|
||||
)
|
||||
assert 'announce("That token was already revoked.")' in seg409, (
|
||||
"the 409 lands the SAME house line"
|
||||
)
|
||||
# The retryable failures: the neutral two-line house copy + the
|
||||
# restore (the button back) — network and non-2xx alike.
|
||||
assert (
|
||||
'announce(`Couldn\'t regenerate "${tok.label}" — is the app reachable?`)' in body
|
||||
), "the network line is the house two-line convention"
|
||||
assert (
|
||||
'announce(`Couldn\'t regenerate "${tok.label}" — try again.`)' in body
|
||||
), "the non-2xx line is the neutral retry copy"
|
||||
assert body.count("restoreRegenerate();") == 2, (
|
||||
"BOTH the network and the non-2xx failures restore the button (retryable)"
|
||||
)
|
||||
assert "innerHTML" not in body, "textContent only (XSS-safe by construction)"
|
||||
|
||||
|
||||
def test_tokens_regenerate_css_pins() -> None:
|
||||
"""Phase 101 task 03: styles.css carries the .token-regenerate
|
||||
button — the .token-revoke's structural twin (the same ≥44px
|
||||
target, --line hairline, radius, transparent fill, ink-soft text)
|
||||
with the NEUTRAL action's hover (brand-soft / brand-ink — NOT the
|
||||
revoke's error hover: a rotation is a hand-out, not a deletion) and
|
||||
a :disabled rule consistent with the revoke button's (the dimmed
|
||||
in-flight state) — no new hue (the phase-92 monochrome invariant);
|
||||
the confirm pair reuses the existing .history-confirm-* rules
|
||||
unchanged."""
|
||||
css = _asset("styles.css")
|
||||
block = re.search(r"\.token-regenerate \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .token-regenerate"
|
||||
body = block.group(1)
|
||||
assert "min-height: 44px" in body, (
|
||||
"the ≥44px touch target (the .token-revoke twin)"
|
||||
)
|
||||
assert "border: 1px solid var(--line)" in body, "the house ghost hairline"
|
||||
assert "border-radius: var(--radius-sm)" in body, "the house radius"
|
||||
assert "background: transparent" in body, "the transparent fill"
|
||||
assert "color: var(--ink-soft)" in body, "the ghost text (ink-soft)"
|
||||
assert "var(--err-" not in body, "no new hue — the monochrome invariant"
|
||||
hover = re.search(r"\.token-regenerate:hover:not\(:disabled\) \{([\s\S]*?)\}", css)
|
||||
assert hover, "the neutral hover rule (gated like the revoke's)"
|
||||
hbody = hover.group(1)
|
||||
assert "var(--brand-soft)" in hbody, "the hover fill is the NEUTRAL brand-soft"
|
||||
assert "var(--brand-ink)" in hbody, "the hover text is brand-ink"
|
||||
assert "var(--err-" not in hbody, (
|
||||
"the hover is NOT the revoke's error hover (rotation ≠ deletion)"
|
||||
)
|
||||
disabled = re.search(r"\.token-regenerate:disabled \{([\s\S]*?)\}", css)
|
||||
assert disabled, "the :disabled state (the in-flight look)"
|
||||
dbody = disabled.group(1)
|
||||
assert "opacity: 0.5" in dbody and "cursor: wait" in dbody, (
|
||||
"consistent with the revoke button's disabled rule"
|
||||
)
|
||||
|
||||
|
||||
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
|
||||
|
||||
|
||||
|
||||
+106
-1
@@ -14,7 +14,14 @@ admin API (task 02's endpoints) and the future token-auth login (task
|
||||
which is what makes the task-03 one-generic-401 safe);
|
||||
* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the
|
||||
original stamp; False only for a missing id);
|
||||
* ``mark_used`` — bumps ``last_used_at``.
|
||||
* ``mark_used`` — bumps ``last_used_at``;
|
||||
* ``regenerate_token`` (phase 101) — the atomic rotation matrix: an
|
||||
active row is revoked (first stamp) and its successor (same label,
|
||||
fresh well-formed plaintext, hash-only row) takes its place — the new
|
||||
plaintext round-trips, the old one no longer authenticates; a missing
|
||||
id returns ``None``; an already-revoked id raises
|
||||
``TokenAlreadyRevoked``; and the service NEVER commits — the caller's
|
||||
rollback undoes both writes.
|
||||
|
||||
House DB-test pattern (the ``test_sources_meta`` precedent): the service
|
||||
is a thin session wrapper whose contract (server-default ``created_at``,
|
||||
@@ -172,3 +179,101 @@ def test_mark_used_stamps_last_used_at(db: Session) -> None:
|
||||
seconds=1
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------- phase 101, task 01: the atomic rotation ----------
|
||||
|
||||
|
||||
def test_regenerate_token_rotates_an_active_row(db: Session) -> None:
|
||||
"""Active row → a NEW row (different id) with the SAME label + a
|
||||
well-formed new plaintext (``bor_`` + 32 hex) whose sha256 IS the
|
||||
stored hash; the OLD row is stamped with its first ``revoked_at``;
|
||||
the new plaintext round-trips through ``find_active_by_token`` and
|
||||
the old plaintext no longer authenticates (dead is dead)."""
|
||||
old_row, old_plain = _create_and_commit(db, "alice")
|
||||
|
||||
before = datetime.now(UTC)
|
||||
rotated = tok.regenerate_token(db, old_row.id)
|
||||
assert rotated is not None
|
||||
new_row, new_plain = rotated
|
||||
db.commit() # the caller owns the commit (the endpoint's job)
|
||||
db.refresh(old_row)
|
||||
db.refresh(new_row)
|
||||
|
||||
# The successor: a different id, the SAME hand-out label, a fresh
|
||||
# well-formed plaintext (never a reuse of the old one).
|
||||
assert new_row.id != old_row.id
|
||||
assert new_row.label == old_row.label == "alice"
|
||||
assert TOKEN_SHAPE.fullmatch(new_plain), new_plain
|
||||
assert new_plain != old_plain
|
||||
|
||||
# The successor row stores ONLY the sha256 of the NEW plaintext.
|
||||
assert new_row.token_hash == tok.hash_token(new_plain)
|
||||
assert new_row.token_hash != tok.hash_token(old_plain)
|
||||
assert new_row.last_used_at is None
|
||||
assert new_row.revoked_at is None
|
||||
|
||||
# The old row carries its first (and only) revocation stamp.
|
||||
assert old_row.revoked_at is not None
|
||||
assert old_row.revoked_at.tzinfo is not None
|
||||
assert before - timedelta(seconds=1) <= old_row.revoked_at <= (
|
||||
datetime.now(UTC) + timedelta(seconds=1)
|
||||
)
|
||||
|
||||
# The rotation takes effect immediately, both directions.
|
||||
hit = tok.find_active_by_token(db, new_plain)
|
||||
assert hit is not None
|
||||
assert hit.id == new_row.id
|
||||
assert tok.find_active_by_token(db, old_plain) is None
|
||||
|
||||
|
||||
def test_regenerate_token_missing_id_returns_none(db: Session) -> None:
|
||||
"""Unknown id → ``None`` (the endpoint maps it to 404) — nothing is
|
||||
created, no stamp anywhere."""
|
||||
_create_and_commit(db) # the table is NOT empty — the miss is by id
|
||||
assert tok.regenerate_token(db, uuid.uuid4()) is None
|
||||
db.rollback()
|
||||
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
|
||||
|
||||
|
||||
def test_regenerate_token_already_revoked_raises(db: Session) -> None:
|
||||
"""A dead token cannot be rotated: an already-revoked id raises
|
||||
``TokenAlreadyRevoked`` (the endpoint's 409) and creates NOTHING —
|
||||
no successor row, the original stamp untouched."""
|
||||
row, _ = _create_and_commit(db)
|
||||
assert tok.revoke(db, row.id) is True
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
original_stamp = row.revoked_at
|
||||
assert original_stamp is not None
|
||||
|
||||
with pytest.raises(tok.TokenAlreadyRevoked):
|
||||
tok.regenerate_token(db, row.id)
|
||||
db.rollback()
|
||||
|
||||
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
|
||||
db.refresh(row)
|
||||
assert row.revoked_at == original_stamp
|
||||
|
||||
|
||||
def test_regenerate_token_service_never_commits(db: Session) -> None:
|
||||
"""Atomicity + the house commit split: the service flushes, never
|
||||
commits — the caller's rollback AFTER the rotation undoes BOTH
|
||||
writes (the stamp AND the successor), so the old token stays live."""
|
||||
old_row, old_plain = _create_and_commit(db, "carol")
|
||||
|
||||
rotated = tok.regenerate_token(db, old_row.id)
|
||||
assert rotated is not None
|
||||
new_row, _ = rotated
|
||||
db.rollback() # the caller refuses the rotation
|
||||
db.expire_all()
|
||||
|
||||
survivor = db.get(ApiToken, old_row.id)
|
||||
assert survivor is not None
|
||||
assert survivor.revoked_at is None # the stamp rolled back
|
||||
assert db.get(ApiToken, new_row.id) is None # the successor rolled back
|
||||
# The old credential is alive again — the rotation never happened.
|
||||
hit = tok.find_active_by_token(db, old_plain)
|
||||
assert hit is not None
|
||||
assert hit.id == old_row.id
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user