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:
@@ -0,0 +1,20 @@
|
|||||||
|
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`.
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
........................................................................ [ 3%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
................................................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 181 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 127 1 99%
|
||||||
|
app/api/git_sources.py 229 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 115 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 66 0 100%
|
||||||
|
app/models.py 125 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 316 1 99%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/folder_summaries.py 121 0 100%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 190 3 98%
|
||||||
|
app/rag/llm.py 243 1 99%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 307 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3848 15 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
Task 01 complete — everything green.
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `app/core/tokens.py`: `TokenAlreadyRevoked` exception + `regenerate_token(db, token_id)` — atomic rotation (revoke-stamp + same-label successor create in one flush-only unit; caller owns the single commit), `None` for unknown id; module docstring carries the rotation/A4 contract.
|
||||||
|
- `app/api/tokens.py`: `POST /api/tokens/{id}/regenerate` → 201 `TokenCreated` (new row's one plaintext moment), 404 `token not found`, 409 `token already revoked`; router-wide `require_admin` covers it; `list_tokens` now passes `revoked_at`.
|
||||||
|
- `app/schemas.py`: `TokenListItem.revoked_at: datetime | None = None` (D5, wire-additive, docstring pinned).
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
- Unit (`tests/unit/test_tokens.py`): rotation matrix — new row/same label/well-formed plaintext/hash round-trip, old row stamped, old plaintext dead, missing → `None`, revoked → raises, rollback undoes BOTH writes (service never commits).
|
||||||
|
- Integration (`tests/integration/test_tokens_api.py`): 201 rotation (old row revoked w/ ISO `revoked_at`, new row active + newer `created_at` via deterministic gap, A4 list stays secret-free), 404, 409 (row untouched), 403 anonymous on all routes + 403 token-user; phase-79 list pins updated in place for the new field.
|
||||||
|
|
||||||
|
**Results**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 2060 passed, TOTAL 99% (>90%; tokens modules 100%)
|
||||||
|
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||||
|
- Regression: `tests/integration/test_auth_api.py` (17) and `uv run pytest tests/e2e/test_api_tokens.py --no-cov` (9) green in isolation
|
||||||
|
|
||||||
|
**Decisions**: none beyond spec; kept house conventions (service flushes/endpoint commits, one message per 404/409, docstring-carried contracts).
|
||||||
|
|
||||||
|
**Next pending task**: `.agents/phases/todo/101_tokens_page_overhaul/02_two_tables_search_required_name.md`
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
........................................................................ [ 3%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
............................................ [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 181 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 127 1 99%
|
||||||
|
app/api/git_sources.py 229 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 115 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 66 0 100%
|
||||||
|
app/models.py 125 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 316 1 99%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/folder_summaries.py 121 0 100%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 190 3 98%
|
||||||
|
app/rag/llm.py 243 1 99%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 307 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3848 15 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
All green. Task 02 is complete.
|
||||||
|
|
||||||
|
**Implemented (phase 101, task 02 — two tables, per-table search, required name):**
|
||||||
|
- `frontend/index.html` `#view-tokens`: 4-column active table (Status `<th>` gone, empty row `colspan=4`), new ship-hidden `#tokens-no-match-row`, both `type=search` inputs (`#token-search-active/-revoked`, `.token-search`), and the new ship-hidden revoked section (heading + search + 4-column table with `Revoked` column) below the active wrap; create row now `aria-label="Token name"` / `e.g. alice — required`
|
||||||
|
- `frontend/assets/tokens.js`: `loadTokens` splits by `tok.revoked` (server order kept), `setRevokedSectionVisible(n)` (heading+search+wrap shown iff ≥1 revoked), `applyFilter` (case-insensitive label substring, state rows excluded, no-match copy `No tokens match "<query>".`), persistent `activeQuery`/`revokedQuery` re-applied after every render, input listeners armed in the admin branch (no fetch), D3 required-name block (`Give the token a name first.` + re-focus, no request); `confirmRevoke` now relocates the row to the revoked table via the re-entrant load (its `revoked_at` cell needs the server stamp); `|| "token"` fallback deleted
|
||||||
|
- `styles.css`: `.token-search` (house input surface, full-width, ≥44px), `.tokens-revoked-heading` (phase-97 mono voice); empty/no-match styling already class-based (no-op)
|
||||||
|
- Source pins: 3 new tests in `tests/unit/test_frontend_router.py` (scaffold, JS split/search/name, CSS) — all pre-existing phase-79 pins still pass
|
||||||
|
- Defect fixes (in place): `tests/e2e/test_api_tokens.py` revoked-row assertions moved to `#tokens-revoked-tbody` (per phase plan task 04 item 5); `test_theme_semantic_completion.py::test_revoked_pill_gray_labeled` → `test_revoked_table_state_labeled` (pill intentionally gone — D1)
|
||||||
|
|
||||||
|
**Gates:** `uv run pytest --cov=app` → 2063 passed, app 99% (>90%) · `ruff check .` clean · `pyright` 0/0/0 · E2E in isolation: `test_api_tokens.py` 9 ✓, `test_theme_semantic_completion.py` 8 ✓, `test_history_page_width`+`test_admin_auth`+`test_shared_header` 17 ✓ (34 combined); temp in-browser verification of search/re-show/revoke split passed (file deleted)
|
||||||
|
|
||||||
|
**Decisions:** state rows (empty + no-match) excluded from the filter per 00_phase "(no-match/empty rows excluded)"; no-match text quotes the original untrimmed query; no mobile CSS change needed (class-based).
|
||||||
|
|
||||||
|
**Next pending task:** `03_regenerate_ui.md` (Regenerate control + CSS + source pins)
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
........................................................................ [ 3%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
............................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 181 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 127 1 99%
|
||||||
|
app/api/git_sources.py 229 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 115 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 66 0 100%
|
||||||
|
app/models.py 125 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 316 1 99%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/folder_summaries.py 121 0 100%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 190 3 98%
|
||||||
|
app/rag/llm.py 243 1 99%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 307 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3848 15 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
## Task 03 complete — Regenerate control (rotation UI)
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `frontend/assets/tokens.js`: `makeRegenerateControl` (structural mirror of the revoke control: `.token-regenerate` button, `aria-label: "Regenerate token: <label>"`, exact confirm text `Regenerate? The current token is revoked.`, focus-to-Yes, No/failure → `restoreRegenerate()` with focus restored) + `confirmRegenerate` (disabled-while-in-flight, `POST /api/tokens/<id>/regenerate` no body; 201 → `loadTokens()` FIRST → once-block value-only reveal → exact D2 announce line; 404 → `row.remove()` + re-fetch + house 404 line; 409 → re-fetch + same line; network/non-2xx → neutral two-line copy + restore)
|
||||||
|
- Active Actions cell now `actionsTd.append(makeRegenerateControl, makeRevokeControl)` — Regenerate first, each control owns its OWN `.tokens-actions` wrapper span (independent confirm scopes)
|
||||||
|
- Module docstring: rotation semantics + 404/409 copy sections
|
||||||
|
- `frontend/assets/styles.css`: `.token-regenerate` — the `.token-revoke` twin (44px/`--line`/radius/transparent/`--ink-soft`) with the NEUTRAL hover (`--brand-soft`/`--brand-ink`, no err hue) + revoke-consistent `:disabled`; comment notes the phase-92 monochrome invariant
|
||||||
|
- Source pins in `tests/unit/test_frontend_router.py` (the task-02 pin file): `test_tokens_js_regenerate_control` (button/aria/confirm text/focus/restore, POST path, pinned 201 sequence load→reveal→announce, 404/409 copy, retryable restore ×2, cell order, per-control wrappers) + `test_tokens_regenerate_css_pins`
|
||||||
|
|
||||||
|
**Gates**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term` → 2065 passed, exit 0, TOTAL **99%** (>90%)
|
||||||
|
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||||
|
- `node --check` (ESM) on tokens.js → OK; revoke control untouched (its pins green)
|
||||||
|
|
||||||
|
**Decisions**: 404 branch follows the task's pinned decision (`row.remove()` + re-fetch + the revoke control's existing 404 line, not a revoked re-render); no E2E file added (task 04 owns the browser proof); no git commit per harness rules.
|
||||||
|
|
||||||
|
**Next pending task**: `.agents/phases/todo/101_tokens_page_overhaul/04_e2e_tokens_page.md`
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
........................................................................ [ 3%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
................................................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 181 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 127 1 99%
|
||||||
|
app/api/git_sources.py 229 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 115 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 66 0 100%
|
||||||
|
app/models.py 125 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 316 1 99%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/folder_summaries.py 121 0 100%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 190 3 98%
|
||||||
|
app/rag/llm.py 243 1 99%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 307 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3848 15 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
All gates green. Report:
|
||||||
|
|
||||||
|
**Task 04 complete — `101_tokens_page_overhaul` E2E + regressions**
|
||||||
|
|
||||||
|
- Implemented `tests/e2e/test_tokens_page.py` (4 tests, all per spec): revoked tokens in their own table below (4-col active thead, hidden-while-empty section, Revoked date cell ≠ created, no actions), both tables searchable (no-match row copy, queries survive a Tuning-nav re-show), nameless/whitespace generation refused (live-region line, `page.request` GET proves no row), full Regenerate rotation (two-step confirm + focus, new `bor_…` plaintext in the once-block, D2 line, successor newer + original in revoked table, NEW token signs in via fresh context, ORIGINAL refused at the gate).
|
||||||
|
- Verified `tests/e2e/test_api_tokens.py` was already updated in place (revoked-row pins relocated to `#tokens-revoked-tbody`; no stale Status/pill/colspan refs) — green; `test_theme_semantic_completion.py` re-scope (this phase's) also green.
|
||||||
|
- E2E (isolated, `--no-cov`, DB up): `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed**; `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**.
|
||||||
|
- Full gate: `uv run pytest --cov=app --cov-report=term-missing` → **2065 passed, app/ coverage 99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
|
||||||
|
- Notable: one test-side race fixed (read the once-block after the D2 live-region line — the 201 sequence's last step); date cells indexed via `tokens-date-cell` class (Created = nth(0)). No app-code defects found.
|
||||||
|
- Per harness rules: no `git add`/`commit`, no phase-dir move — all changes left in the working tree.
|
||||||
|
- Next pending task: none in `101_tokens_page_overhaul` (this was the last task); queue continues with `98_sync_summary_visibility` etc. in `todo/`.
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
........................................................................ [ 3%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 38%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 80%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
........................................................................ [ 97%]
|
||||||
|
................................................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 181 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 127 1 99%
|
||||||
|
app/api/git_sources.py 229 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 115 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 140 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 66 0 100%
|
||||||
|
app/models.py 125 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 316 1 99%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/folder_summaries.py 121 0 100%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 190 3 98%
|
||||||
|
app/rag/llm.py 243 1 99%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 307 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3848 15 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+37
-1
@@ -15,7 +15,10 @@ Routes (all under ``/api`` via the ``main`` registration):
|
|||||||
that carries the plaintext ``token``), ``GET /api/tokens``
|
that carries the plaintext ``token``), ``GET /api/tokens``
|
||||||
(``TokenList`` — newest first, secret-free), ``POST
|
(``TokenList`` — newest first, secret-free), ``POST
|
||||||
/api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404
|
/api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404
|
||||||
``token not found``).
|
``token not found``), and ``POST /api/tokens/{token_id}/regenerate``
|
||||||
|
(201 ``TokenCreated`` — the atomic rotation: the old row is revoked
|
||||||
|
and its successor, same label, is created in ONE transaction; already
|
||||||
|
revoked → 409, unknown id → 404).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -87,6 +90,7 @@ def list_tokens(
|
|||||||
created_at=row.created_at,
|
created_at=row.created_at,
|
||||||
last_used_at=row.last_used_at,
|
last_used_at=row.last_used_at,
|
||||||
revoked=row.revoked_at is not None,
|
revoked=row.revoked_at is not None,
|
||||||
|
revoked_at=row.revoked_at, # phase 101 D5: null while active
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -110,3 +114,35 @@ def revoke_token(
|
|||||||
raise HTTPException(status_code=404, detail="token not found")
|
raise HTTPException(status_code=404, detail="token not found")
|
||||||
db.commit()
|
db.commit()
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{token_id}/regenerate", response_model=TokenCreated, status_code=201)
|
||||||
|
def regenerate_token(
|
||||||
|
token_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db), # noqa: B008
|
||||||
|
) -> TokenCreated:
|
||||||
|
"""Rotate one active token (201) — the rotation is ONE transaction.
|
||||||
|
|
||||||
|
The old row is stamped ``revoked_at`` and the successor (SAME label,
|
||||||
|
fresh hash) is created in the same commit, so the holder's old token
|
||||||
|
is dead the instant the new one is live. This 201 is the NEW token's
|
||||||
|
ONE plaintext moment (A4 — the same contract as the create endpoint;
|
||||||
|
the old plaintext was already one-shot and is never re-shown).
|
||||||
|
A dead token cannot be rotated: an already-revoked id is a 409
|
||||||
|
``token already revoked``, and an unknown id a 404 ``token not
|
||||||
|
found`` (the revoke endpoint's exact message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = token_service.regenerate_token(db, token_id)
|
||||||
|
except token_service.TokenAlreadyRevoked:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="token already revoked"
|
||||||
|
) from None
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="token not found")
|
||||||
|
new_row, plaintext = result
|
||||||
|
db.commit() # ONE commit: the stamp + the create are atomic
|
||||||
|
db.refresh(new_row) # pulls the server-default created_at
|
||||||
|
return TokenCreated(
|
||||||
|
id=new_row.id, label=new_row.label, token=plaintext, created_at=new_row.created_at
|
||||||
|
)
|
||||||
|
|||||||
+43
-1
@@ -19,7 +19,16 @@ compare would be theatre, so the contrast is documented, not replicated.
|
|||||||
|
|
||||||
House commit convention (the ``app.rag.sources_meta`` pattern): the service
|
House commit convention (the ``app.rag.sources_meta`` pattern): the service
|
||||||
functions flush but never commit — the calling endpoint owns the commit, so
|
functions flush but never commit — the calling endpoint owns the commit, so
|
||||||
a failed request can never leave a half-applied token mutation.
|
a failed request can never leave a half-applied token mutation. This holds
|
||||||
|
for the composite :func:`regenerate_token` rotation too: the revocation
|
||||||
|
stamp and the successor's insert are flushed by ONE call and covered by
|
||||||
|
ONE commit — a create failure rolls the revoke back with it.
|
||||||
|
|
||||||
|
Rotation (phase 101): :func:`regenerate_token` revokes the old row and
|
||||||
|
creates its successor (same label) atomically. A4 never weakens — the old
|
||||||
|
plaintext was already one-shot (its 201 body was the only wire moment),
|
||||||
|
and the new plaintext is returned by the service exactly once, for the
|
||||||
|
rotation's 201 body to ship.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -116,3 +125,36 @@ def revoke(db: Session, token_id: uuid.UUID) -> bool:
|
|||||||
if row.revoked_at is None:
|
if row.revoked_at is None:
|
||||||
row.revoked_at = datetime.now(UTC)
|
row.revoked_at = datetime.now(UTC)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TokenAlreadyRevoked(Exception):
|
||||||
|
"""A revoked (dead) token cannot be rotated — the regenerate endpoint's 409."""
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate_token(
|
||||||
|
db: Session, token_id: uuid.UUID
|
||||||
|
) -> tuple[ApiToken, str] | None:
|
||||||
|
"""Rotate one ACTIVE token; return the (NEW row, new plaintext) ONCE.
|
||||||
|
|
||||||
|
The rotation is ONE atomic unit: ``revoked_at`` is stamped on the old
|
||||||
|
row (the :func:`revoke` primitive — its first stamp IS the
|
||||||
|
revocation time) and the successor row is created with the SAME
|
||||||
|
label (the hand-out name persists, phase 101 D2) — both writes are
|
||||||
|
flushed here and covered by the caller's SINGLE commit, so a create
|
||||||
|
failure rolls the revoke back with it (the service flushes, never
|
||||||
|
commits — the house convention). A missing id returns ``None`` (the
|
||||||
|
endpoint's 404); an already-revoked row raises
|
||||||
|
:class:`TokenAlreadyRevoked` (a dead token cannot be rotated — the
|
||||||
|
endpoint's 409). A4 never weakens: the old plaintext was already
|
||||||
|
one-shot, and the new plaintext exists outside this function only in
|
||||||
|
this return value — the rotation's 201 body is its one wire moment.
|
||||||
|
"""
|
||||||
|
row = db.get(ApiToken, token_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if row.revoked_at is not None:
|
||||||
|
raise TokenAlreadyRevoked()
|
||||||
|
revoke(db, row.id) # stamps the first (and only) revocation time
|
||||||
|
new_row, plaintext = create_token(db, row.label)
|
||||||
|
db.flush() # one unit for the caller's commit: stamp + successor
|
||||||
|
return new_row, plaintext
|
||||||
|
|||||||
@@ -930,6 +930,11 @@ class TokenListItem(BaseModel):
|
|||||||
either form (A4). ``revoked`` is derived server-side from
|
either form (A4). ``revoked`` is derived server-side from
|
||||||
``revoked_at is not None`` (the UI renders the Active/Revoked state
|
``revoked_at is not None`` (the UI renders the Active/Revoked state
|
||||||
from the flag, not the timestamp).
|
from the flag, not the timestamp).
|
||||||
|
|
||||||
|
``revoked_at`` (phase 101, D5 — wire-additive, defaults null): null
|
||||||
|
while the token is active; once revoked, the ISO-8601 timestamp the
|
||||||
|
revoked table renders (the revocation date). The flag stays the
|
||||||
|
client's table-split key; this field is display data only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
@@ -937,6 +942,7 @@ class TokenListItem(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_used_at: datetime | None
|
last_used_at: datetime | None
|
||||||
revoked: bool
|
revoked: bool
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenList(BaseModel):
|
class TokenList(BaseModel):
|
||||||
|
|||||||
@@ -3167,14 +3167,66 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
}
|
}
|
||||||
.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||||
.token-revoke:disabled { opacity: 0.5; cursor: wait; }
|
.token-revoke:disabled { opacity: 0.5; cursor: wait; }
|
||||||
/* Empty-state row: the muted centered message at full table width
|
/* Regenerate (phase 101 task 03, D2): the .token-revoke's structural
|
||||||
(the .history-empty-row language, inline in the table). */
|
twin — the SAME size / border / radius / focus, the transparent
|
||||||
|
fill + ink-soft text — but the NEUTRAL action's hover
|
||||||
|
(brand-soft / brand-ink), NOT the revoke's error hover: a
|
||||||
|
rotation is a hand-out of a successor, not a deletion (no new
|
||||||
|
hue — the phase-92 monochrome invariant). The confirm pair
|
||||||
|
reuses the .history-confirm-* classes unchanged (the pair CSS
|
||||||
|
above). :disabled mirrors the revoke button's rule (the
|
||||||
|
in-flight state, while the rotation POST is out). */
|
||||||
|
.token-regenerate {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.token-regenerate:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
|
.token-regenerate:disabled { opacity: 0.5; cursor: wait; }
|
||||||
|
/* Empty-state + search no-match rows (phase 101): the muted centered
|
||||||
|
message at full table width (the .history-empty-row language,
|
||||||
|
inline in the table) — CLASS-based, so both the active table's
|
||||||
|
#tokens-empty-row / #tokens-no-match-row and the revoked table's
|
||||||
|
#tokens-revoked-no-match-row share it. */
|
||||||
.tokens-empty-row td {
|
.tokens-empty-row td {
|
||||||
padding: 2.25rem 1rem;
|
padding: 2.25rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
/* Phase 101 (task 02, D4): the per-table live label search — the
|
||||||
|
house input surface (the #token-label / archive-upload input
|
||||||
|
family: --line hairline, --surface fill, ink text — AA pairs, no
|
||||||
|
new hue), full width, the ≥44px target, the global :focus-visible
|
||||||
|
ring (no custom focus rule — the house pattern). */
|
||||||
|
.token-search {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
/* The revoked section's sub-heading (phase 101 D1): the phase-97
|
||||||
|
.kb-level h2 voice — mono, 1rem, brand-ink (the path voice, AA on
|
||||||
|
the page background). */
|
||||||
|
.tokens-revoked-heading {
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--brand-ink);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
|
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
|
||||||
The shell's seventh view (#view-theme): the admin palette + branding
|
The shell's seventh view (#view-theme): the admin palette + branding
|
||||||
|
|||||||
+383
-91
@@ -7,9 +7,12 @@
|
|||||||
* Wires the admin-only token endpoints (phase 79 task 02) into the
|
* Wires the admin-only token endpoints (phase 79 task 02) into the
|
||||||
* view:
|
* view:
|
||||||
*
|
*
|
||||||
* • the create row (label + Generate): a BLANK label sends "token"
|
* • the create row (name + Generate): a BLANK / whitespace-only
|
||||||
* (the placeholder documents the fallback; the API's 1–120
|
* name is REFUSED client-side (phase 101 D3) — the live region
|
||||||
* validator is satisfied). POST /api/tokens → 201 — the ONE
|
* reads `Give the token a name first.`, the name input re-focuses,
|
||||||
|
* and NO request is sent (the server's 422 on a blank/over-long
|
||||||
|
* label is defense in depth; the old blank-label "token"
|
||||||
|
* fallback is GONE). On a named generate: POST /api/tokens → 201 — the ONE
|
||||||
* response that carries the plaintext (owner-locked A4) — and the
|
* response that carries the plaintext (owner-locked A4) — and the
|
||||||
* plaintext appears EXACTLY ONCE: in the #token-once block's mono
|
* plaintext appears EXACTLY ONCE: in the #token-once block's mono
|
||||||
* read-only field, with a Copy (the clipboard; a non-secure http
|
* read-only field, with a Copy (the clipboard; a non-secure http
|
||||||
@@ -18,31 +21,68 @@
|
|||||||
* loadTokens() / re-show (and the field is wiped with it) — the
|
* loadTokens() / re-show (and the field is wiped with it) — the
|
||||||
* plaintext is NOT stored anywhere client-side (no localStorage,
|
* plaintext is NOT stored anywhere client-side (no localStorage,
|
||||||
* no data attribute), so a re-render can never re-show it;
|
* no data attribute), so a re-render can never re-show it;
|
||||||
* • the full-width table (AGENTS.md rule 5): Label (textContent —
|
* • the SPLIT tables (phase 101 D1 — AGENTS.md rule 5, full width,
|
||||||
* admin-derived, still text) | Created (locale date+time, the full
|
* no skinny list): the ACTIVE table on top (Label | Created |
|
||||||
* ISO in the title) | Last used (locale or "never") | Status — a
|
* Last used | Actions — the two-step inline Regenerate BEFORE
|
||||||
* plain em-dash for Active, the rose .stale-pill for Revoked (the
|
* the two-step inline Revoke — phase 101 task 03 / D2) and the
|
||||||
* stale-pill visual language; the cell carries its aria-label in
|
* REVOKED table below
|
||||||
* BOTH states — WCAG 2.1 AA) | Actions — Revoke, the inline
|
* it (Label | Created | Last used | Revoked — the revoked_at
|
||||||
* TWO-STEP confirm (the history-confirm-* pattern — NO native
|
* date). The Status column is GONE from both: the table's
|
||||||
* confirm dialog: the first click swaps to "Revoke? [Yes] [No]",
|
* position IS the status (an all-active table needs no status; a
|
||||||
* focus moves to Yes; Yes POSTs /api/tokens/<id>/revoke and the
|
* row in the revoked table is a dead token — no actions, nothing
|
||||||
* row re-renders Revoked + the live region line; No or a failed
|
* left to revoke). The revoked SECTION (heading + search + table
|
||||||
* request restores the Revoke button — retryable). Revoked rows
|
* wrap) is hidden while no token is revoked (an empty table is
|
||||||
* carry NO action (nothing left to revoke).
|
* noise); the active table keeps its empty-state row;
|
||||||
|
* • the per-table LIVE label search (phase 101 D4): one type=search
|
||||||
|
* input per table — a case-insensitive SUBSTRING over the
|
||||||
|
* rendered rows' label cells, applied on `input` with NO fetch
|
||||||
|
* (each data row toggles its `hidden`), and RE-APPLIED after
|
||||||
|
* every loadTokens() (the queries live in module state —
|
||||||
|
* activeQuery / revokedQuery — and survive re-renders / re-shows,
|
||||||
|
* the phase-77 contract). Zero visible matches with a non-empty
|
||||||
|
* query → the per-table no-match row (`No tokens match
|
||||||
|
* "<query>".` — textContent, the distinct-from-empty-state
|
||||||
|
* language); an empty query shows every row;
|
||||||
|
* • the one-click REGENERATE (phase 101 task 03, D2 — rotation,
|
||||||
|
* atomic): every ACTIVE row carries a Regenerate button BEFORE
|
||||||
|
* its Revoke (the primary lifecycle action — ONE button starts
|
||||||
|
* it). The control is a structural mirror of the Revoke control:
|
||||||
|
* the first click swaps the button, in place, to the house
|
||||||
|
* two-step confirm (`Regenerate? The current token is revoked.
|
||||||
|
* [Yes] [No]` — the history-confirm-* classes, focus to Yes),
|
||||||
|
* and it owns its OWN .tokens-actions wrapper span, so a confirm
|
||||||
|
* in it never clobbers the Revoke control's. Yes → POST
|
||||||
|
* /api/tokens/<id>/regenerate (JSON, NO body) — the server
|
||||||
|
* rotates in ONE transaction: the old row is stamped revoked (it
|
||||||
|
* lands in the revoked table) and the successor is created under
|
||||||
|
* the SAME label (it lands in the active table); the 201 body
|
||||||
|
* ({ id, label, token, created_at }) is the new token's ONLY
|
||||||
|
* plaintext moment (A4). On 201 the re-entrant loadTokens() runs
|
||||||
|
* FIRST (the relocation), THEN the shown-once block reveals the
|
||||||
|
* NEW plaintext — the SAME #token-once block, the value-only
|
||||||
|
* contract (the plaintext lives in the field's value, never a
|
||||||
|
* data attribute) — and the live region reads `Regenerated
|
||||||
|
* "<label>" — copy the new token now; it won't be shown again.`.
|
||||||
|
* A 404 (the row vanished — revoked AND deleted elsewhere, or a
|
||||||
|
* stale render) removes the row, re-fetches (the reconciliation),
|
||||||
|
* and reuses the revoke control's 404 line `That token was
|
||||||
|
* already revoked.`; a 409 (revoked between render and click)
|
||||||
|
* re-fetches + the same line; any other failure / network error
|
||||||
|
* restores the Regenerate button (retryable) with the neutral
|
||||||
|
* house copy ("is the app reachable?" / "try again.").
|
||||||
*
|
*
|
||||||
* Every cell is built with the DOM APIs (textContent) — this file
|
* Every cell is built with the DOM APIs (textContent) — this file
|
||||||
* never builds HTML (the XSS-safe-by-construction house rule; a full-
|
* never builds HTML (the XSS-safe-by-construction house rule; a full-
|
||||||
* file source pin enforces it).
|
* file source pin enforces it).
|
||||||
*
|
*
|
||||||
* The whoami gate (phase 19 shared-header module, cached promise):
|
* The whoami gate (phase 19 shared-header module, cached promise):
|
||||||
* • anonymous → the #tokens-gate is shown, the create row + table
|
* • anonymous → the #tokens-gate is shown, the create row + search
|
||||||
* hide, and NO /api/tokens request is made at all (the router
|
* + tables hide, and NO /api/tokens request is made at all (the
|
||||||
* 403s anonymous — the same request-log contract as the history
|
* router 403s anonymous — the same request-log contract as the
|
||||||
* view);
|
* history view);
|
||||||
* • admin → the gate hides, the create row + table reveal, and
|
* • admin → the gate hides, the create row + search + active table
|
||||||
* `loadTokens()` renders the rows; a 0-row fetch (and a failed
|
* reveal, and `loadTokens()` renders the rows; a 0-active-row
|
||||||
* load) reveals the empty-state row.
|
* fetch (and a failed load) reveals the active empty-state row.
|
||||||
*
|
*
|
||||||
* Phase 76 (task 06) — shell view module: the top-level boot is
|
* Phase 76 (task 06) — shell view module: the top-level boot is
|
||||||
* `export async function mount(root)` — root is the view's
|
* `export async function mount(root)` — root is the view's
|
||||||
@@ -68,6 +108,23 @@
|
|||||||
* branch, after the whoami gate passes: anonymous shows the gate and
|
* branch, after the whoami gate passes: anonymous shows the gate and
|
||||||
* never fetches.
|
* never fetches.
|
||||||
*
|
*
|
||||||
|
* Phase 101 (tasks 02 + 03) — the tokens page overhaul: the single
|
||||||
|
* table SPLIT into active + revoked (D1: the table's position IS the
|
||||||
|
* status, the Status column + the em-dash / .stale-pill are gone),
|
||||||
|
* the per-table live label search (D4: client-side, query state
|
||||||
|
* survives re-renders), the required name (D3: a blank name
|
||||||
|
* generates NOTHING), and the one-click REGENERATE (D2: the rotation
|
||||||
|
* is atomic server-side — the old row revoked, the successor created
|
||||||
|
* under the same label — the 201's re-entrant load runs BEFORE the
|
||||||
|
* once-block reveal; a 404 removes the vanished row + re-fetches, a
|
||||||
|
* 409 re-fetches — both landing the house line "That token was
|
||||||
|
* already revoked."; a retryable failure restores the button).
|
||||||
|
* loadTokens keeps its re-entrant core
|
||||||
|
* (once-block hidden + wiped, data rows dropped, the fetch, the error
|
||||||
|
* lines, the return value) and SPLITS the fetched list by
|
||||||
|
* `tok.revoked` — the server's newest-first order kept per table —
|
||||||
|
* before re-applying BOTH persistent filters.
|
||||||
|
*
|
||||||
* The clipboard + inline-fallback helper is tokens.js's OWN ~10-line
|
* The clipboard + inline-fallback helper is tokens.js's OWN ~10-line
|
||||||
* copy (the per-page duplication house style — history.js keeps the
|
* copy (the per-page duplication house style — history.js keeps the
|
||||||
* share link's, app.js the chat page's; no new shared module).
|
* share link's, app.js the chat page's; no new shared module).
|
||||||
@@ -80,9 +137,23 @@ export async function mount(root) {
|
|||||||
const tableWrap = root.querySelector("#tokens-table-wrap");
|
const tableWrap = root.querySelector("#tokens-table-wrap");
|
||||||
const tbody = root.querySelector("#tokens-tbody");
|
const tbody = root.querySelector("#tokens-tbody");
|
||||||
const emptyRow = root.querySelector("#tokens-empty-row");
|
const emptyRow = root.querySelector("#tokens-empty-row");
|
||||||
|
// Phase 101 (task 02, D4): the per-table search inputs + no-match
|
||||||
|
// rows (the active no-match row ships in #tokens-tbody, hidden; its
|
||||||
|
// <td> text is JS-filled).
|
||||||
|
const searchActive = root.querySelector("#token-search-active");
|
||||||
|
const noMatchRow = root.querySelector("#tokens-no-match-row");
|
||||||
|
// Phase 101 (task 02, D1): the REVOKED section — the visible
|
||||||
|
// sub-heading, the section's own search input, the table wrap, the
|
||||||
|
// tbody, and its no-match row. The section ships hidden;
|
||||||
|
// setRevokedSectionVisible(n) shows it iff n ≥ 1.
|
||||||
|
const revokedHeading = root.querySelector("#tokens-revoked-heading");
|
||||||
|
const searchRevoked = root.querySelector("#token-search-revoked");
|
||||||
|
const revokedWrap = root.querySelector("#tokens-revoked-wrap");
|
||||||
|
const revokedTbody = root.querySelector("#tokens-revoked-tbody");
|
||||||
|
const revokedNoMatchRow = root.querySelector("#tokens-revoked-no-match-row");
|
||||||
const gateEl = root.querySelector("#tokens-gate");
|
const gateEl = root.querySelector("#tokens-gate");
|
||||||
const statusEl = root.querySelector("#tokens-status");
|
const statusEl = root.querySelector("#tokens-status");
|
||||||
// The create row (label + Generate) — SHIPS hidden (anonymous-safe;
|
// The create row (name + Generate) — SHIPS hidden (anonymous-safe;
|
||||||
// the admin branch reveals it).
|
// the admin branch reveals it).
|
||||||
const createRow = root.querySelector("#token-create");
|
const createRow = root.querySelector("#token-create");
|
||||||
const labelInput = root.querySelector("#token-label");
|
const labelInput = root.querySelector("#token-label");
|
||||||
@@ -93,6 +164,13 @@ export async function mount(root) {
|
|||||||
const onceValue = root.querySelector("#token-once-value");
|
const onceValue = root.querySelector("#token-once-value");
|
||||||
const onceCopy = root.querySelector("#token-once-copy");
|
const onceCopy = root.querySelector("#token-once-copy");
|
||||||
|
|
||||||
|
/* Phase 101 (task 02, D4): the per-table search queries — module
|
||||||
|
state that SURVIVES every re-render and re-show: a load never
|
||||||
|
resets them (it re-applies them), and the input listeners are
|
||||||
|
the only writers. */
|
||||||
|
let activeQuery = "";
|
||||||
|
let revokedQuery = "";
|
||||||
|
|
||||||
/* Action feedback — the role="status" live region (the "never stale"
|
/* Action feedback — the role="status" live region (the "never stale"
|
||||||
contract: every action lands a line here, success or failure
|
contract: every action lands a line here, success or failure
|
||||||
alike). */
|
alike). */
|
||||||
@@ -141,12 +219,18 @@ export async function mount(root) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One row. The Status cell carries the cell-level aria-label in BOTH
|
/* One row for ONE of the two tables (phase 101 D1): "active" —
|
||||||
states (Active em-dash / the rose Revoked pill — the stale-pill
|
Label | Created | Last used | Actions (the two-step inline
|
||||||
visual language) so the marker is conveyed without the visual
|
Regenerate BEFORE the two-step inline Revoke — phase 101
|
||||||
(WCAG 2.1 AA). The Actions cell carries the two-step Revoke for
|
task 03 / D2: the rotation is the primary lifecycle action), or
|
||||||
active rows only — revoked rows have nothing left to revoke. */
|
"revoked" — Label | Created | Last used | Revoked (the
|
||||||
function makeRow(tok) {
|
revoked_at date: locale date+time, full ISO on hover — the
|
||||||
|
house tokens-date-cell language). The row's very presence in the
|
||||||
|
revoked table IS the status (the em-dash / .stale-pill column is
|
||||||
|
gone from both tables); revoked rows carry NO actions. The label
|
||||||
|
cell (.tokens-label-cell) is the search filter's data source —
|
||||||
|
applyFilter reads its textContent. */
|
||||||
|
function makeRow(tok, table) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
const labelTd = document.createElement("td");
|
const labelTd = document.createElement("td");
|
||||||
@@ -171,43 +255,93 @@ export async function mount(root) {
|
|||||||
}
|
}
|
||||||
tr.appendChild(usedTd);
|
tr.appendChild(usedTd);
|
||||||
|
|
||||||
const statusTd = document.createElement("td");
|
if (table === "revoked") {
|
||||||
statusTd.className = "tokens-status-cell";
|
const revokedTd = document.createElement("td");
|
||||||
if (tok.revoked) {
|
revokedTd.className = "tokens-date-cell";
|
||||||
statusTd.setAttribute(
|
if (tok.revoked_at) {
|
||||||
"aria-label",
|
revokedTd.title = tok.revoked_at; // full ISO on hover
|
||||||
"Revoked — this token is dead and can no longer sign in",
|
revokedTd.textContent = fmtDate(tok.revoked_at);
|
||||||
);
|
|
||||||
const pill = document.createElement("span");
|
|
||||||
pill.className = "stale-pill";
|
|
||||||
pill.title = "Revoked — the token can no longer sign in";
|
|
||||||
pill.textContent = "Revoked";
|
|
||||||
statusTd.appendChild(pill);
|
|
||||||
} else {
|
} else {
|
||||||
statusTd.setAttribute(
|
revokedTd.textContent = "—"; // defensive: the server stamps it
|
||||||
"aria-label",
|
}
|
||||||
"Active — this token can still be used to sign in",
|
tr.appendChild(revokedTd);
|
||||||
);
|
return tr;
|
||||||
statusTd.textContent = "—"; // the em-dash: active rows' marker
|
|
||||||
}
|
}
|
||||||
tr.appendChild(statusTd);
|
|
||||||
|
|
||||||
const actionsTd = document.createElement("td");
|
const actionsTd = document.createElement("td");
|
||||||
actionsTd.className = "tokens-actions-cell";
|
actionsTd.className = "tokens-actions-cell";
|
||||||
if (!tok.revoked) {
|
/* Phase 101 (task 03, D2): Regenerate FIRST (the primary
|
||||||
actionsTd.appendChild(makeRevokeControl(tok, tr));
|
lifecycle action) — each control owns its OWN .tokens-actions
|
||||||
}
|
wrapper span, so a confirm in one never clobbers the other. */
|
||||||
|
actionsTd.append(
|
||||||
|
makeRegenerateControl(tok, tr),
|
||||||
|
makeRevokeControl(tok, tr),
|
||||||
|
);
|
||||||
tr.appendChild(actionsTd);
|
tr.appendChild(actionsTd);
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The inline two-step Regenerate (phase 101 task 03, D2 — a
|
||||||
|
structural mirror of makeRevokeControl, the D2 contract): ONE
|
||||||
|
button starts the rotation, and its first click replaces it, in
|
||||||
|
place, by the "Regenerate? The current token is revoked. [Yes]
|
||||||
|
[No]" pair (the history-confirm-* classes — a destructive
|
||||||
|
rotation deserves the same confirm weight as Revoke); focus
|
||||||
|
moves to Yes (keyboard-reachable confirm). Yes →
|
||||||
|
confirmRegenerate (POST /api/tokens/<id>/regenerate — the
|
||||||
|
atomic rotation; on 201 the re-entrant load runs FIRST, then
|
||||||
|
the shown-once block reveals the new plaintext). No or a failed
|
||||||
|
request restores the Regenerate button (retryable). The control
|
||||||
|
owns its OWN .tokens-actions wrapper span: the Actions cell
|
||||||
|
hosts two independent confirm scopes side by side (this one +
|
||||||
|
the Revoke control's) — a swap in one never clobbers the other.
|
||||||
|
The shipped state IS the Regenerate button. */
|
||||||
|
function makeRegenerateControl(tok, row) {
|
||||||
|
const cell = document.createElement("span");
|
||||||
|
cell.className = "tokens-actions";
|
||||||
|
|
||||||
|
const regenBtn = document.createElement("button");
|
||||||
|
regenBtn.type = "button";
|
||||||
|
regenBtn.className = "token-regenerate";
|
||||||
|
regenBtn.setAttribute("aria-label", `Regenerate token: ${tok.label}`);
|
||||||
|
regenBtn.textContent = "Regenerate";
|
||||||
|
|
||||||
|
function restoreRegenerate() {
|
||||||
|
cell.replaceChildren(regenBtn);
|
||||||
|
regenBtn.focus(); // focus returns to the (restored) control
|
||||||
|
}
|
||||||
|
|
||||||
|
regenBtn.addEventListener("click", () => {
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "history-confirm-text";
|
||||||
|
label.textContent = "Regenerate? The current token is revoked.";
|
||||||
|
const yes = document.createElement("button");
|
||||||
|
yes.type = "button";
|
||||||
|
yes.className = "history-confirm-yes";
|
||||||
|
yes.textContent = "Yes";
|
||||||
|
const no = document.createElement("button");
|
||||||
|
no.type = "button";
|
||||||
|
no.className = "history-confirm-no";
|
||||||
|
no.textContent = "No";
|
||||||
|
yes.addEventListener("click", () =>
|
||||||
|
confirmRegenerate(tok, row, yes, restoreRegenerate));
|
||||||
|
no.addEventListener("click", restoreRegenerate);
|
||||||
|
cell.replaceChildren(label, yes, no);
|
||||||
|
yes.focus(); // the confirm pair takes over the focus
|
||||||
|
});
|
||||||
|
|
||||||
|
cell.appendChild(regenBtn); // the shipped state IS the Regenerate button
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
/* The inline two-step Revoke (the history-confirm-* pattern — NO
|
/* The inline two-step Revoke (the history-confirm-* pattern — NO
|
||||||
native confirm dialog anywhere in this file). The Revoke button is
|
native confirm dialog anywhere in this file). The Revoke button is
|
||||||
replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves
|
replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves
|
||||||
to Yes (keyboard-reachable confirm). Yes → POST
|
to Yes (keyboard-reachable confirm). Yes → POST
|
||||||
/api/tokens/<id>/revoke → the row re-renders Revoked (+ the live
|
/api/tokens/<id>/revoke → the re-entrant load runs (the row
|
||||||
region line); No or a failed request restores the Revoke button
|
LEAVES the active table and lands in the revoked table below —
|
||||||
(retryable). */
|
D1) + the live region line; No or a failed request restores the
|
||||||
|
Revoke button (retryable). */
|
||||||
function makeRevokeControl(tok, row) {
|
function makeRevokeControl(tok, row) {
|
||||||
const cell = document.createElement("span");
|
const cell = document.createElement("span");
|
||||||
cell.className = "tokens-actions";
|
cell.className = "tokens-actions";
|
||||||
@@ -247,12 +381,14 @@ export async function mount(root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The confirmed revoke: POST /api/tokens/<id>/revoke (204 —
|
/* The confirmed revoke: POST /api/tokens/<id>/revoke (204 —
|
||||||
idempotent server-side) → the row STAYS (it is not removed) and
|
idempotent server-side) → the live region gets
|
||||||
re-renders Revoked, and the live region gets `Revoked "<label>".`
|
`Revoked "<label>".` and the re-entrant loadTokens() runs: the
|
||||||
A 404 means the row is gone (revoked elsewhere) — re-render it
|
row LEAVES the active table and lands in the revoked table (its
|
||||||
Revoked and say so. Any other failure or a network error keeps the
|
server-stamped revoked_at renders in the Revoked cell — D1). A
|
||||||
row, restores the Revoke button (retryable), and lands the error
|
404 means the token was revoked elsewhere — the same re-load
|
||||||
line. */
|
reconciles the tables and the line says so. Any other failure or
|
||||||
|
a network error keeps the row, restores the Revoke button
|
||||||
|
(retryable), and lands the error line. */
|
||||||
async function confirmRevoke(tok, row, yesBtn, restoreRevoke) {
|
async function confirmRevoke(tok, row, yesBtn, restoreRevoke) {
|
||||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||||
let r;
|
let r;
|
||||||
@@ -264,8 +400,8 @@ export async function mount(root) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (r.status === 404) {
|
if (r.status === 404) {
|
||||||
row.replaceWith(makeRow({ ...tok, revoked: true }));
|
|
||||||
announce("That token was already revoked.");
|
announce("That token was already revoked.");
|
||||||
|
await loadTokens(); // the row relocates to the revoked table
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -273,26 +409,136 @@ export async function mount(root) {
|
|||||||
restoreRevoke();
|
restoreRevoke();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
row.replaceWith(makeRow({ ...tok, revoked: true }));
|
|
||||||
announce(`Revoked "${tok.label}".`);
|
announce(`Revoked "${tok.label}".`);
|
||||||
|
await loadTokens(); // the row relocates to the revoked table (D1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The confirmed regenerate (phase 101 task 03, D2): POST
|
||||||
|
/api/tokens/<id>/regenerate (JSON, NO body) — the server rotates
|
||||||
|
atomically in ONE transaction: the old row is stamped revoked
|
||||||
|
(it lands in the revoked table) and the successor is created
|
||||||
|
under the SAME label (it lands in the active table). The 201
|
||||||
|
body ({ id, label, token, created_at }) is the new token's ONLY
|
||||||
|
plaintext moment (A4 — the old plaintext was already one-shot
|
||||||
|
and is gone). The 201 sequence is pinned: the re-entrant
|
||||||
|
loadTokens() runs FIRST (the relocation), THEN the shown-once
|
||||||
|
block reveals the new plaintext in the field's VALUE only (never
|
||||||
|
a data attribute — the same #token-once block a create uses),
|
||||||
|
THEN the live region reads the D2 line. A 404 means the row
|
||||||
|
VANISHED (revoked AND deleted by another admin, or a stale
|
||||||
|
render) — the row is removed, the re-fetch reconciles both
|
||||||
|
tables, and the line reuses the revoke control's 404 copy (one
|
||||||
|
house message for the one common case). A 409 means the row was
|
||||||
|
revoked between render and click — the re-fetch reconciles +
|
||||||
|
the same line. Any other failure or a network error keeps the
|
||||||
|
row, restores the Regenerate button (retryable), and lands the
|
||||||
|
neutral error line. */
|
||||||
|
async function confirmRegenerate(tok, row, yesBtn, restoreRegenerate) {
|
||||||
|
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
announce(`Couldn't regenerate "${tok.label}" — is the app reachable?`);
|
||||||
|
restoreRegenerate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 404) {
|
||||||
|
row.remove(); // the row vanished — the re-fetch reconciles both tables
|
||||||
|
await loadTokens();
|
||||||
|
announce("That token was already revoked.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 409) {
|
||||||
|
await loadTokens(); // the row was revoked between render and click
|
||||||
|
announce("That token was already revoked.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
announce(`Couldn't regenerate "${tok.label}" — try again.`);
|
||||||
|
restoreRegenerate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
/* D2: the re-entrant load runs FIRST — the old row relocates to
|
||||||
|
the revoked table, the new row lands in the active one — then
|
||||||
|
the once-block reveals the new plaintext (value only, A4). */
|
||||||
|
await loadTokens();
|
||||||
|
if (onceValue) onceValue.value = data.token;
|
||||||
|
if (onceBlock) onceBlock.hidden = false;
|
||||||
|
announce(
|
||||||
|
`Regenerated "${tok.label}" — copy the new token now; it won't be shown again.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The revoked SECTION (heading + search input + table wrap — D1)
|
||||||
|
shows iff at least one token is revoked: an empty table is noise.
|
||||||
|
count is the revoked-row count of the last load (0 hides it). */
|
||||||
|
function setRevokedSectionVisible(count) {
|
||||||
|
const show = count > 0;
|
||||||
|
if (revokedHeading) revokedHeading.hidden = !show;
|
||||||
|
if (searchRevoked) searchRevoked.hidden = !show;
|
||||||
|
if (revokedWrap) revokedWrap.hidden = !show;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The empty-state row reappears exactly when there is nothing else
|
/* The empty-state row reappears exactly when there is nothing else
|
||||||
in the tbody (the empty row itself ships in the tbody, hidden). */
|
in the tbody (the empty row + the no-match row both ship in the
|
||||||
|
tbody, hidden — a failed load / 0-row fetch restores BOTH). */
|
||||||
function showEmptyState() {
|
function showEmptyState() {
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.replaceChildren(emptyRow);
|
tbody.replaceChildren(emptyRow, noMatchRow);
|
||||||
if (emptyRow) emptyRow.hidden = false;
|
if (emptyRow) emptyRow.hidden = false;
|
||||||
|
if (noMatchRow) noMatchRow.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One table's LIVE label filter (phase 101 D4) — pure DOM, NO
|
||||||
|
fetch. The query is trimmed + lowercased for the MATCH; the
|
||||||
|
no-match copy quotes the ORIGINAL user text. The rendered rows
|
||||||
|
are the source of truth: the filter reads each data row's label
|
||||||
|
cell (.tokens-label-cell) textContent — case-insensitive
|
||||||
|
substring — and toggles row.hidden. The state rows (the
|
||||||
|
no-match row, the empty-state row) are NEVER data rows. The
|
||||||
|
no-match row is visible ⟺ the query is non-empty AND zero data
|
||||||
|
rows are visible — its <td> textContent = `No tokens match
|
||||||
|
"<query>".` (the query inside the quotes is textContent, never
|
||||||
|
HTML). An empty query shows every row and hides the no-match
|
||||||
|
row. */
|
||||||
|
function applyFilter(targetTbody, targetNoMatchRow, rawQuery) {
|
||||||
|
if (!targetTbody) return;
|
||||||
|
const query = (rawQuery ?? "").trim().toLowerCase();
|
||||||
|
let visible = 0;
|
||||||
|
for (const tr of targetTbody.querySelectorAll("tr")) {
|
||||||
|
if (tr === targetNoMatchRow || tr === emptyRow) continue; // state rows
|
||||||
|
const labelCell = tr.querySelector(".tokens-label-cell");
|
||||||
|
const label = (labelCell ? labelCell.textContent : "").toLowerCase();
|
||||||
|
tr.hidden = query !== "" && !label.includes(query);
|
||||||
|
if (!tr.hidden) visible += 1;
|
||||||
|
}
|
||||||
|
if (!targetNoMatchRow) return;
|
||||||
|
const noMatch = query !== "" && visible === 0;
|
||||||
|
targetNoMatchRow.hidden = !noMatch;
|
||||||
|
if (noMatch) {
|
||||||
|
const td = targetNoMatchRow.querySelector("td");
|
||||||
|
if (td) td.textContent = `No tokens match "${rawQuery}".`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GET /api/tokens → render the rows (newest-first — the server's
|
/* GET /api/tokens → render the rows (newest-first — the server's
|
||||||
order). A 0-row fetch shows the empty-state row. Re-entrant (the
|
order). Re-entrant (the phase-77 re-show contract): a re-show
|
||||||
phase-77 re-show contract): a re-show re-run must REPLACE the
|
re-run must REPLACE the list, not append a duplicate set — the
|
||||||
list, not append a duplicate set — the data rows (every <tr>
|
data rows (every <tr> EXCEPT the hidden state rows, which the
|
||||||
EXCEPT the hidden #tokens-empty-row, which the load itself
|
load itself re-hides / reveals) are dropped before the fetch —
|
||||||
re-hides / reveals) are dropped before the fetch — and the
|
and the once-block HIDDEN + its field wiped (the plaintext is
|
||||||
once-block HIDDEN + its field wiped (the plaintext is gone: a
|
gone: a re-render can never re-show it).
|
||||||
re-render can never re-show it).
|
Phase 101 (D1): the render step SPLITS the fetched list by
|
||||||
|
`tok.revoked` — the server's newest-first order kept per table:
|
||||||
|
the active rows fill #tokens-tbody, the revoked rows fill
|
||||||
|
#tokens-revoked-tbody. The active empty-state row shows iff there
|
||||||
|
are ZERO active rows (a 0-active fetch with revoked rows shows
|
||||||
|
the empty active table AND the populated revoked section — both
|
||||||
|
honest); the revoked SECTION shows iff ≥ 1 revoked row. FINALLY
|
||||||
|
both persistent filters re-apply (D4 — a re-render never loses
|
||||||
|
the queries).
|
||||||
A FAILED load announces its line in the live region (the house
|
A FAILED load announces its line in the live region (the house
|
||||||
copy: "is the app reachable?" / "try again.") and RETURNS the
|
copy: "is the app reachable?" / "try again.") and RETURNS the
|
||||||
outcome: true when the fetch settled (a 0-row fetch is a
|
outcome: true when the fetch settled (a 0-row fetch is a
|
||||||
@@ -303,49 +549,74 @@ export async function mount(root) {
|
|||||||
if (onceValue) onceValue.value = "";
|
if (onceValue) onceValue.value = "";
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
for (const tr of tbody.querySelectorAll("tr")) {
|
for (const tr of tbody.querySelectorAll("tr")) {
|
||||||
if (tr !== emptyRow) tr.remove();
|
if (tr !== emptyRow && tr !== noMatchRow) tr.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (revokedTbody) {
|
||||||
|
for (const tr of revokedTbody.querySelectorAll("tr")) {
|
||||||
|
if (tr !== revokedNoMatchRow) tr.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (emptyRow) emptyRow.hidden = true;
|
if (emptyRow) emptyRow.hidden = true;
|
||||||
|
if (noMatchRow) noMatchRow.hidden = true;
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await fetch("/api/tokens");
|
r = await fetch("/api/tokens");
|
||||||
} catch {
|
} catch {
|
||||||
announce("Couldn't load tokens — is the app reachable?");
|
announce("Couldn't load tokens — is the app reachable?");
|
||||||
showEmptyState();
|
showEmptyState();
|
||||||
|
setRevokedSectionVisible(0);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
announce("Couldn't load tokens — try again.");
|
announce("Couldn't load tokens — try again.");
|
||||||
showEmptyState();
|
showEmptyState();
|
||||||
|
setRevokedSectionVisible(0);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { tokens } = await r.json();
|
const { tokens } = await r.json();
|
||||||
if (!tokens.length) {
|
/* D1: the split — the newest-first server order kept per table. */
|
||||||
showEmptyState();
|
const active = tokens.filter((t) => !t.revoked);
|
||||||
return true;
|
const revoked = tokens.filter((t) => t.revoked);
|
||||||
|
for (const tok of active) {
|
||||||
|
tbody.appendChild(makeRow(tok, "active"));
|
||||||
}
|
}
|
||||||
for (const tok of tokens) {
|
for (const tok of revoked) {
|
||||||
tbody.appendChild(makeRow(tok));
|
if (revokedTbody) revokedTbody.appendChild(makeRow(tok, "revoked"));
|
||||||
}
|
}
|
||||||
|
if (emptyRow) emptyRow.hidden = active.length !== 0;
|
||||||
|
setRevokedSectionVisible(revoked.length);
|
||||||
|
/* D4: the persistent queries survive the re-render — BOTH
|
||||||
|
filters re-apply after the load. */
|
||||||
|
applyFilter(tbody, noMatchRow, activeQuery);
|
||||||
|
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Generate: the label comes from #token-label — a BLANK label sends
|
/* Generate: the name comes from #token-label — a BLANK /
|
||||||
"token" (the placeholder documents the fallback; the API's 1–120
|
whitespace-only name is REFUSED client-side (phase 101 D3): the
|
||||||
validator is satisfied). The button runs the §7.4 never-stale
|
live region reads `Give the token a name first.`, the name input
|
||||||
lifecycle: "Generating…" while the POST is in flight, re-enabled
|
re-focuses, and NO request is sent (the server's 422 on a
|
||||||
on success AND failure (the finally — a click can never leave it
|
blank/over-long label stands unchanged — defense in depth; the
|
||||||
stuck disabled). On 201 the re-entrant list load runs FIRST (it
|
old blank-label "token" fallback is DELETED — the name is the
|
||||||
hides the once-block — the re-render contract) and THEN the
|
hand-out identity, not an optional decoration). The button runs the §7.4
|
||||||
once-block reveals with the plaintext (the 201 body's token is
|
never-stale lifecycle: "Generating…" while the POST is in flight,
|
||||||
the ONE plaintext that exists, A4 — it lives in this closure
|
re-enabled on success AND failure (the finally — a click can
|
||||||
until the next loadTokens() hides the block again), the live
|
never leave it stuck disabled). On 201 the re-entrant list load
|
||||||
region gets the shown-once line, and the label input clears (a
|
runs FIRST (it hides the once-block — the re-render contract) and
|
||||||
new token is a new hand-out). A failed create keeps the label
|
THEN the once-block reveals with the plaintext (the 201 body's
|
||||||
|
token is the ONE plaintext that exists, A4 — it lives in this
|
||||||
|
closure until the next loadTokens() hides the block again), the
|
||||||
|
live region gets the shown-once line, and the name input clears
|
||||||
|
(a new token is a new hand-out). A failed create keeps the label
|
||||||
(retryable) and lands the error line. */
|
(retryable) and lands the error line. */
|
||||||
async function generateToken() {
|
async function generateToken() {
|
||||||
const label = (labelInput ? labelInput.value : "").trim() || "token";
|
const label = (labelInput ? labelInput.value : "").trim();
|
||||||
|
if (!label) {
|
||||||
|
announce("Give the token a name first.");
|
||||||
|
if (labelInput) labelInput.focus();
|
||||||
|
return; // refused client-side (D3) — no request is sent
|
||||||
|
}
|
||||||
if (generateBtn) generateBtn.disabled = true;
|
if (generateBtn) generateBtn.disabled = true;
|
||||||
if (generateBtn) generateBtn.textContent = "Generating…";
|
if (generateBtn) generateBtn.textContent = "Generating…";
|
||||||
let created = null;
|
let created = null;
|
||||||
@@ -381,16 +652,20 @@ export async function mount(root) {
|
|||||||
* exactly once, via the chat module (app.js) at shell boot. The
|
* exactly once, via the chat module (app.js) at shell boot. The
|
||||||
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
|
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||||||
* the header uses (zero extra requests). Anonymous: the gate in,
|
* the header uses (zero extra requests). Anonymous: the gate in,
|
||||||
* the create row + table out — and NO /api/tokens request at all
|
* the create row + search + tables out — and NO /api/tokens request
|
||||||
* (the router 403s anonymous, so the view must never call it). */
|
* at all (the router 403s anonymous, so the view must never call
|
||||||
|
* it). */
|
||||||
if (!(await fetchIsAdmin())) {
|
if (!(await fetchIsAdmin())) {
|
||||||
if (gateEl) gateEl.hidden = false;
|
if (gateEl) gateEl.hidden = false;
|
||||||
if (createRow) createRow.hidden = true;
|
if (createRow) createRow.hidden = true;
|
||||||
|
if (searchActive) searchActive.hidden = true;
|
||||||
if (tableWrap) tableWrap.hidden = true;
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
|
setRevokedSectionVisible(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (gateEl) gateEl.hidden = true;
|
if (gateEl) gateEl.hidden = true;
|
||||||
if (createRow) createRow.hidden = false;
|
if (createRow) createRow.hidden = false;
|
||||||
|
if (searchActive) searchActive.hidden = false;
|
||||||
if (tableWrap) tableWrap.hidden = false;
|
if (tableWrap) tableWrap.hidden = false;
|
||||||
if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it
|
if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it
|
||||||
/* Phase 77: a user-initiated re-show of this already-mounted view
|
/* Phase 77: a user-initiated re-show of this already-mounted view
|
||||||
@@ -411,6 +686,23 @@ export async function mount(root) {
|
|||||||
if (onceCopy) {
|
if (onceCopy) {
|
||||||
onceCopy.addEventListener("click", () => void copyTokenAction());
|
onceCopy.addEventListener("click", () => void copyTokenAction());
|
||||||
}
|
}
|
||||||
|
/* Phase 101 (task 02, D4): the per-table search listeners — armed
|
||||||
|
in the ADMIN branch only (after the whoami gate, like the refresh
|
||||||
|
listener): set the module query + applyFilter, NO fetch. The
|
||||||
|
queries persist across re-renders / re-shows (loadTokens
|
||||||
|
re-applies them). */
|
||||||
|
if (searchActive) {
|
||||||
|
searchActive.addEventListener("input", () => {
|
||||||
|
activeQuery = searchActive.value;
|
||||||
|
applyFilter(tbody, noMatchRow, activeQuery);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (searchRevoked) {
|
||||||
|
searchRevoked.addEventListener("input", () => {
|
||||||
|
revokedQuery = searchRevoked.value;
|
||||||
|
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
|
||||||
|
});
|
||||||
|
}
|
||||||
started = true;
|
started = true;
|
||||||
loadTokens();
|
loadTokens();
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-22
@@ -913,21 +913,22 @@
|
|||||||
lines, nothing else. -->
|
lines, nothing else. -->
|
||||||
<span class="tokens-status" id="tokens-status" role="status" aria-live="polite"></span>
|
<span class="tokens-status" id="tokens-status" role="status" aria-live="polite"></span>
|
||||||
|
|
||||||
<!-- The create row: label (1–120 — the server re-validates; a
|
<!-- The create row: name (1–120 — the server re-validates; a
|
||||||
BLANK label sends "token", the placeholder documents the
|
BLANK name is REFUSED client-side — tokens.js announces
|
||||||
fallback) + Generate. SHIPS hidden (anonymous-safe — no
|
"Give the token a name first." and sends NOTHING, phase
|
||||||
dead control beside the gate; tokens.js reveals it for
|
101 D3; the server's 422 is defense in depth) +
|
||||||
admin only). The button is type="button" (no form submit)
|
Generate. SHIPS hidden (anonymous-safe — no dead control
|
||||||
and runs the §7.4 never-stale lifecycle ("Generating…"
|
beside the gate; tokens.js reveals it for admin only).
|
||||||
while the POST is in flight, re-enabled on success AND
|
The button is type="button" (no form submit) and runs the
|
||||||
failure). -->
|
§7.4 never-stale lifecycle ("Generating…" while the POST
|
||||||
|
is in flight, re-enabled on success AND failure). -->
|
||||||
<div class="token-create" id="token-create" hidden>
|
<div class="token-create" id="token-create" hidden>
|
||||||
<input
|
<input
|
||||||
id="token-label"
|
id="token-label"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
placeholder="e.g. alice"
|
placeholder="e.g. alice — required"
|
||||||
aria-label="Token label"
|
aria-label="Token name"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<button type="button" class="token-generate" id="token-generate">Generate</button>
|
<button type="button" class="token-generate" id="token-generate">Generate</button>
|
||||||
@@ -959,16 +960,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The full-width table (AGENTS.md rule 5 — no skinny list):
|
<!-- Phase 101 (task 02, D4): the ACTIVE table's live label
|
||||||
Label | Created (locale date+time, full ISO on hover) |
|
search — a client-side, case-insensitive SUBSTRING filter
|
||||||
Last used (locale or "never") | Status (Active em-dash vs
|
over the rendered rows (no fetch). SHIPS hidden with the
|
||||||
the rose Revoked pill — the stale-pill language) | Actions
|
create row (tokens.js reveals it in the admin branch);
|
||||||
(Revoke — the inline two-step confirm; Revoked rows carry
|
the query lives in module state and is RE-APPLIED after
|
||||||
NO action — nothing left to revoke). tokens.js fills
|
every loadTokens() — a re-render never loses it. -->
|
||||||
#tokens-tbody; #tokens-empty-row ships hidden and is
|
<input
|
||||||
revealed by a 0-row fetch (and a failed load). The Actions
|
type="search"
|
||||||
column header is visually-hidden — the row buttons carry
|
id="token-search-active"
|
||||||
their own aria-labels (the history-table convention). -->
|
aria-label="Search active tokens"
|
||||||
|
placeholder="Search active tokens…"
|
||||||
|
class="token-search"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- The full-width ACTIVE table (AGENTS.md rule 5 — no skinny
|
||||||
|
list): Label | Created (locale date+time, full ISO on
|
||||||
|
hover) | Last used (locale or "never") | Actions (Revoke
|
||||||
|
— the inline two-step confirm; phase 101 task 03 adds
|
||||||
|
Regenerate before it). The Status column is GONE (phase
|
||||||
|
101 D1 — a table that is all-active needs no status; the
|
||||||
|
table's position IS the status). tokens.js fills
|
||||||
|
#tokens-tbody with the revoked === false rows only;
|
||||||
|
#tokens-empty-row ships hidden and is revealed by a
|
||||||
|
0-active-row fetch (and a failed load); #tokens-no-match-row
|
||||||
|
(its text JS-filled) is the D4 search no-match state. The
|
||||||
|
Actions column header is visually-hidden — the row
|
||||||
|
buttons carry their own aria-labels (the history-table
|
||||||
|
convention). -->
|
||||||
<div class="table-wrap tokens-table-wrap" id="tokens-table-wrap" role="region" aria-label="Access tokens" tabindex="0">
|
<div class="table-wrap tokens-table-wrap" id="tokens-table-wrap" role="region" aria-label="Access tokens" tabindex="0">
|
||||||
<table class="tokens-table" id="tokens-table">
|
<table class="tokens-table" id="tokens-table">
|
||||||
<caption class="visually-hidden">Access tokens issued to people who use the app — newest first</caption>
|
<caption class="visually-hidden">Access tokens issued to people who use the app — newest first</caption>
|
||||||
@@ -977,13 +997,53 @@
|
|||||||
<th scope="col">Label</th>
|
<th scope="col">Label</th>
|
||||||
<th scope="col">Created</th>
|
<th scope="col">Created</th>
|
||||||
<th scope="col">Last used</th>
|
<th scope="col">Last used</th>
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tokens-tbody">
|
<tbody id="tokens-tbody">
|
||||||
<tr class="tokens-empty-row" id="tokens-empty-row" hidden>
|
<tr class="tokens-empty-row" id="tokens-empty-row" hidden>
|
||||||
<td colspan="5">No tokens yet — generate one above and hand it out.</td>
|
<td colspan="4">No tokens yet — generate one above and hand it out.</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="tokens-empty-row" id="tokens-no-match-row" hidden>
|
||||||
|
<td colspan="4"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase 101 (task 02, D1 + D4): the REVOKED section — BELOW
|
||||||
|
the active table: a visible sub-heading + its own live
|
||||||
|
label search + its own full-width table (Label | Created
|
||||||
|
| Last used | Revoked — the revoked_at date, locale
|
||||||
|
date+time, full ISO on hover). SHIPS hidden — tokens.js
|
||||||
|
shows it when ≥ 1 token is revoked (an empty table is
|
||||||
|
noise). The table IS the status: the em-dash /
|
||||||
|
.stale-pill column is gone from BOTH tables. Revoked rows
|
||||||
|
carry NO actions (nothing left to revoke or rotate —
|
||||||
|
task 03's Regenerate lives in the active table only). -->
|
||||||
|
<h2 id="tokens-revoked-heading" class="tokens-revoked-heading" hidden>Revoked tokens</h2>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="token-search-revoked"
|
||||||
|
aria-label="Search revoked tokens"
|
||||||
|
placeholder="Search revoked tokens…"
|
||||||
|
class="token-search"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="table-wrap tokens-table-wrap" id="tokens-revoked-wrap" role="region" aria-label="Revoked tokens" tabindex="0" hidden>
|
||||||
|
<table class="tokens-table" id="tokens-revoked-table">
|
||||||
|
<caption class="visually-hidden">Revoked tokens — newest first</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Label</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Last used</th>
|
||||||
|
<th scope="col">Revoked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tokens-revoked-tbody">
|
||||||
|
<tr class="tokens-empty-row" id="tokens-revoked-no-match-row" hidden>
|
||||||
|
<td colspan="4"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -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."
|
"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.
|
# Revoked pill, a Revoke action.
|
||||||
row = page.locator("#tokens-tbody tr", has_text="e2e-alice")
|
row = page.locator("#tokens-tbody tr", has_text="e2e-alice")
|
||||||
expect(row).to_have_count(1)
|
expect(row).to_have_count(1)
|
||||||
expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice")
|
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(".stale-pill")).to_have_count(0)
|
||||||
expect(row.locator("button.token-revoke")).to_have_count(1)
|
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()
|
row.locator("button.token-revoke").click()
|
||||||
expect(row.locator(".history-confirm-yes")).to_be_visible()
|
expect(row.locator(".history-confirm-yes")).to_be_visible()
|
||||||
row.locator(".history-confirm-yes").click()
|
row.locator(".history-confirm-yes").click()
|
||||||
revoked = page.locator("#tokens-tbody tr", has_text="e2e-revoke")
|
# Phase 101 (D1): the row LEAVES the active table and lands in
|
||||||
expect(revoked.locator(".stale-pill")).to_have_text("Revoked", timeout=15_000)
|
# 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".')
|
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".')
|
||||||
|
|
||||||
# Enforcement is IMMEDIATE on the holder's next request (the
|
# 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
|
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
|
the "Stale" pill in History — gray err-ink on gray err-bg with the
|
||||||
gray err-line border, text intact.
|
gray err-line border, text intact.
|
||||||
5. ``test_revoked_pill_gray_labeled`` — screenshot 6: a token
|
5. ``test_revoked_table_state_labeled`` — screenshot 6: a token
|
||||||
generated + revoked THROUGH the Tokens UI renders the "Revoked"
|
generated + revoked THROUGH the Tokens UI lands in the SEPARATE
|
||||||
pill — the same gray err-family computed colors, text intact.
|
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
|
6. ``test_local_badge_gray_labeled`` — screenshot 3: a registered
|
||||||
local-directory source renders the "Local" badge on Git sources —
|
local-directory source renders the "Local" badge on Git sources —
|
||||||
gray ok-ink on gray ok-bg, text intact.
|
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
|
# 5. Tokens: the revoked-table state (screenshot 6) — phase 101 D1
|
||||||
# intact (generated + revoked THROUGH the UI two-step)
|
# 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)
|
page.set_default_timeout(30_000)
|
||||||
login(page, app_url, next="/")
|
login(page, app_url, next="/")
|
||||||
_seed_theme(app_url, _cookies(page))
|
_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")
|
page.click("#token-generate")
|
||||||
row = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
|
row = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
|
||||||
expect(row).to_have_count(1, timeout=15_000)
|
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
|
# Revoke through the UI two-step (Revoke → Yes — the inline
|
||||||
# confirm, no native dialog).
|
# 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()
|
expect(row.locator(".history-confirm-yes")).to_be_visible()
|
||||||
row.locator(".history-confirm-yes").click()
|
row.locator(".history-confirm-yes").click()
|
||||||
|
|
||||||
# Screenshot 6: the red "Revoked" pill — now gray err-ink on gray
|
# Screenshot 6, re-scoped by phase 101 (D1): the "Revoked" pill is
|
||||||
# err-bg with the gray err-line border, text intact.
|
# gone — the table IS the status. The row LEAVES the active table
|
||||||
revoked = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
|
# and lands in the revoked table below with its Revoked date cell
|
||||||
pill = revoked.locator(".stale-pill")
|
# (text + the table's position, never color alone — B5).
|
||||||
expect(pill).to_have_count(1)
|
active = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
|
||||||
expect(pill).to_have_text("Revoked", timeout=15_000)
|
expect(active).to_have_count(0)
|
||||||
_assert_gray(pill, "color", GRAY["err_ink"], label="revoked pill text")
|
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-theme93")
|
||||||
_assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="revoked pill bg")
|
expect(revoked).to_have_count(1, timeout=15_000)
|
||||||
_assert_gray(
|
expect(revoked.locator(".stale-pill")).to_have_count(0)
|
||||||
pill, "borderTopColor", GRAY["err_line"], label="revoked pill border"
|
expect(revoked.locator("td.tokens-date-cell").last).not_to_be_empty()
|
||||||
)
|
|
||||||
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-theme93".')
|
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()
|
||||||
@@ -4,9 +4,9 @@ The admin surface for issued access tokens, driven through the real app
|
|||||||
(TestClient keeps the cookie jar — the house ``test_auth_api`` admin-login
|
(TestClient keeps the cookie jar — the house ``test_auth_api`` admin-login
|
||||||
pattern):
|
pattern):
|
||||||
|
|
||||||
* anonymous → 403 ``admin only`` on all three endpoints (router-level
|
* anonymous → 403 ``admin only`` on every route (router-level
|
||||||
``require_admin``; a token USER, once task 03 lands, is 403 here too —
|
``require_admin``; a token USER is 403 on the admin surface too —
|
||||||
pinned in task 03's matrix);
|
pinned in task 03's matrix and, for the regenerate route, here);
|
||||||
* create → 201 with the plaintext ``token`` (the ONE wire moment it
|
* create → 201 with the plaintext ``token`` (the ONE wire moment it
|
||||||
exists, A4) — and ``GET /tokens`` NEVER exposes it: no ``token`` key,
|
exists, A4) — and ``GET /tokens`` NEVER exposes it: no ``token`` key,
|
||||||
no ``token_hash`` key, and the hash string itself absent from the
|
no ``token_hash`` key, and the hash string itself absent from the
|
||||||
@@ -14,9 +14,16 @@ pattern):
|
|||||||
* labels are display-only and NOT unique (two tokens, one label);
|
* labels are display-only and NOT unique (two tokens, one label);
|
||||||
* blank/over-long labels → 422 (the house ``ValueError`` pattern);
|
* blank/over-long labels → 422 (the house ``ValueError`` pattern);
|
||||||
* revoke → 204, idempotent (re-revoke 204, original stamp kept), the
|
* revoke → 204, idempotent (re-revoke 204, original stamp kept), the
|
||||||
list shows ``revoked: true`` and the row keeps its ``last_used_at``;
|
list shows ``revoked: true`` + the ``revoked_at`` wire timestamp and
|
||||||
unknown id → 404 ``token not found``;
|
the row keeps its ``last_used_at``; unknown id → 404 ``token not
|
||||||
* the list is newest-first (``created_at desc``).
|
found``;
|
||||||
|
* regenerate (phase 101, D2) → 201 with the NEW plaintext (same label,
|
||||||
|
one wire moment, A4) — the old row is revoked in the SAME transaction
|
||||||
|
(``revoked: true`` + non-null ``revoked_at`` in the follow-up list);
|
||||||
|
already-revoked id → 409 ``token already revoked``; unknown id → 404
|
||||||
|
``token not found``;
|
||||||
|
* the list items carry ``revoked_at`` (null active / ISO-8601 revoked —
|
||||||
|
D5, wire-additive) and are newest-first (``created_at desc``).
|
||||||
|
|
||||||
Real Postgres (``podman compose up -d db``); no LLM involved — tokens
|
Real Postgres (``podman compose up -d db``); no LLM involved — tokens
|
||||||
are plain rows, so the suite is deterministic without a fake.
|
are plain rows, so the suite is deterministic without a fake.
|
||||||
@@ -36,7 +43,9 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy import select, text, update
|
from sqlalchemy import select, text, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.main import app as fastapi_app
|
||||||
from app.models import ApiToken
|
from app.models import ApiToken
|
||||||
|
from tests.conftest import ADMIN_PASSWORD
|
||||||
|
|
||||||
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
|
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
|
||||||
#: A fixed "already used" stamp — the row keeps it through revocation
|
#: A fixed "already used" stamp — the row keeps it through revocation
|
||||||
@@ -62,9 +71,21 @@ def _create(admin_client: TestClient, label: str = "alice") -> dict:
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
def test_anonymous_403_on_all_three(client: TestClient) -> None:
|
def _admin_client() -> TestClient:
|
||||||
|
"""A SEPARATE client signed in as the admin — for tests that need an
|
||||||
|
admin and a token user at the same time (the shared ``client``
|
||||||
|
fixture is the token holder in those; the ``test_auth_api``
|
||||||
|
pattern)."""
|
||||||
|
admin = TestClient(fastapi_app)
|
||||||
|
r = admin.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||||
|
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_403_on_all_routes(client: TestClient) -> None:
|
||||||
"""Router-level ``require_admin``: every route is 403 for the
|
"""Router-level ``require_admin``: every route is 403 for the
|
||||||
unsigned-in caller (one fixed detail — no enumeration)."""
|
unsigned-in caller (one fixed detail — no enumeration), including
|
||||||
|
the phase-101 regenerate route."""
|
||||||
r = client.post("/api/tokens", json={"label": "anon"})
|
r = client.post("/api/tokens", json={"label": "anon"})
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
assert r.json() == {"detail": "admin only"}
|
assert r.json() == {"detail": "admin only"}
|
||||||
@@ -74,6 +95,9 @@ def test_anonymous_403_on_all_three(client: TestClient) -> None:
|
|||||||
r = client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
|
r = client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
assert r.json() == {"detail": "admin only"}
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
r = client.post(f"/api/tokens/{uuid.uuid4()}/regenerate")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
|
||||||
|
|
||||||
def test_admin_create_returns_plaintext_exactly_once(
|
def test_admin_create_returns_plaintext_exactly_once(
|
||||||
@@ -103,11 +127,20 @@ def test_admin_create_returns_plaintext_exactly_once(
|
|||||||
items = r.json()["tokens"]
|
items = r.json()["tokens"]
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
item = items[0]
|
item = items[0]
|
||||||
assert set(item) == {"id", "label", "created_at", "last_used_at", "revoked"}
|
# Phase 101, D5: the wire gains ``revoked_at`` — null while active.
|
||||||
|
assert set(item) == {
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"created_at",
|
||||||
|
"last_used_at",
|
||||||
|
"revoked",
|
||||||
|
"revoked_at",
|
||||||
|
}
|
||||||
assert item["id"] == body["id"]
|
assert item["id"] == body["id"]
|
||||||
assert item["label"] == "alice"
|
assert item["label"] == "alice"
|
||||||
assert item["last_used_at"] is None # not used yet
|
assert item["last_used_at"] is None # not used yet
|
||||||
assert item["revoked"] is False
|
assert item["revoked"] is False
|
||||||
|
assert item["revoked_at"] is None
|
||||||
serialized = r.text
|
serialized = r.text
|
||||||
assert body["token"] not in serialized
|
assert body["token"] not in serialized
|
||||||
assert row.token_hash not in serialized
|
assert row.token_hash not in serialized
|
||||||
@@ -163,6 +196,11 @@ def test_revoke_204_idempotent_and_preserves_last_used(
|
|||||||
).scalar_one().revoked_at
|
).scalar_one().revoked_at
|
||||||
assert original_stamp is not None
|
assert original_stamp is not None
|
||||||
|
|
||||||
|
# Phase 101, D5: the revoked row carries the revocation timestamp on
|
||||||
|
# the wire — ISO-8601, the SAME instant as the DB stamp (the
|
||||||
|
# revoked table renders it).
|
||||||
|
assert datetime.fromisoformat(item["revoked_at"]) == original_stamp
|
||||||
|
|
||||||
# Re-revoke: still 204, the original stamp survives (no re-stamp).
|
# Re-revoke: still 204, the original stamp survives (no re-stamp).
|
||||||
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
|
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
@@ -173,6 +211,120 @@ def test_revoke_204_idempotent_and_preserves_last_used(
|
|||||||
) == USED_STAMP
|
) == USED_STAMP
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- phase 101, task 01: the atomic rotation ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_201_rotates_atomically(admin_client: TestClient, db: Session) -> None:
|
||||||
|
"""201 carries the NEW plaintext (same label, one wire moment, A4);
|
||||||
|
the old row is revoked in the SAME transaction — the follow-up list
|
||||||
|
shows it ``revoked: true`` with a non-null ISO ``revoked_at``, while
|
||||||
|
the new row is active (``revoked_at`` null), same label, with a
|
||||||
|
newer ``created_at``."""
|
||||||
|
body = _create(admin_client, "dave")
|
||||||
|
token_id = uuid.UUID(body["id"])
|
||||||
|
original = body["token"]
|
||||||
|
|
||||||
|
# A deterministic created_at gap (transaction timestamps can be
|
||||||
|
# coarser than the create gap — the test_list_is_newest_first
|
||||||
|
# pattern), so "newer" is a strict comparison.
|
||||||
|
old_created = datetime.now(UTC) - timedelta(hours=1)
|
||||||
|
db.execute(
|
||||||
|
update(ApiToken)
|
||||||
|
.where(ApiToken.id == token_id)
|
||||||
|
.values(created_at=old_created)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = admin_client.post(f"/api/tokens/{token_id}/regenerate")
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
rotated = r.json()
|
||||||
|
|
||||||
|
# The 201 shape is TokenCreated — the NEW token's one plaintext
|
||||||
|
# moment (the successor row, same label, a fresh credential).
|
||||||
|
assert set(rotated) == {"id", "label", "token", "created_at"}
|
||||||
|
assert rotated["id"] != body["id"]
|
||||||
|
assert rotated["label"] == "dave"
|
||||||
|
assert TOKEN_SHAPE.fullmatch(rotated["token"]), rotated["token"]
|
||||||
|
assert rotated["token"] != original
|
||||||
|
assert datetime.fromisoformat(rotated["created_at"]) > old_created
|
||||||
|
|
||||||
|
# The successor row stores the sha256 of the NEW plaintext only.
|
||||||
|
row = db.execute(
|
||||||
|
select(ApiToken).where(ApiToken.id == uuid.UUID(rotated["id"]))
|
||||||
|
).scalar_one()
|
||||||
|
assert row.token_hash == hashlib.sha256(rotated["token"].encode("utf-8")).hexdigest()
|
||||||
|
assert row.revoked_at is None
|
||||||
|
|
||||||
|
# The follow-up list: newest first, the old row revoked (D5 wire
|
||||||
|
# timestamp), the new row active.
|
||||||
|
items = admin_client.get("/api/tokens").json()["tokens"]
|
||||||
|
assert [i["id"] for i in items] == [rotated["id"], body["id"]]
|
||||||
|
by_id = {i["id"]: i for i in items}
|
||||||
|
old_item, new_item = by_id[body["id"]], by_id[rotated["id"]]
|
||||||
|
assert old_item["revoked"] is True
|
||||||
|
assert old_item["revoked_at"] is not None
|
||||||
|
datetime.fromisoformat(old_item["revoked_at"]) # ISO-8601
|
||||||
|
assert new_item["revoked"] is False
|
||||||
|
assert new_item["revoked_at"] is None
|
||||||
|
assert old_item["label"] == new_item["label"] == "dave"
|
||||||
|
|
||||||
|
# A4: the list never carries either plaintext or any hash string.
|
||||||
|
serialized = admin_client.get("/api/tokens").text
|
||||||
|
assert original not in serialized
|
||||||
|
assert rotated["token"] not in serialized
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_unknown_id_404(admin_client: TestClient) -> None:
|
||||||
|
"""One fixed message for every unknown id (the revoke endpoint's)."""
|
||||||
|
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/regenerate")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json() == {"detail": "token not found"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_already_revoked_409(admin_client: TestClient, db: Session) -> None:
|
||||||
|
"""A dead token cannot be rotated: 409 ``token already revoked``,
|
||||||
|
and the row is untouched — still ONE row, the original stamp, no
|
||||||
|
successor created."""
|
||||||
|
body = _create(admin_client, "erin")
|
||||||
|
token_id = uuid.UUID(body["id"])
|
||||||
|
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
|
||||||
|
db.expire_all()
|
||||||
|
original_stamp = db.execute(
|
||||||
|
select(ApiToken).where(ApiToken.id == token_id)
|
||||||
|
).scalar_one().revoked_at
|
||||||
|
assert original_stamp is not None
|
||||||
|
|
||||||
|
r = admin_client.post(f"/api/tokens/{token_id}/regenerate")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json() == {"detail": "token already revoked"}
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
|
||||||
|
row = db.execute(select(ApiToken).where(ApiToken.id == token_id)).scalar_one()
|
||||||
|
assert row.revoked_at == original_stamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_403_for_token_user(client: TestClient) -> None:
|
||||||
|
"""Router-wide gate: a token USER (signed in via /api/token-auth)
|
||||||
|
cannot rotate a token — 403 ``admin only``, and the rotation did not
|
||||||
|
happen (the shared ``client`` is the token holder; a separate client
|
||||||
|
is the admin — the ``test_auth_api`` pattern)."""
|
||||||
|
admin = _admin_client()
|
||||||
|
body = _create(admin, "frank")
|
||||||
|
assert client.post("/api/token-auth", json={"token": body["token"]}).status_code == 204
|
||||||
|
assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"}
|
||||||
|
|
||||||
|
r = client.post(f"/api/tokens/{body['id']}/regenerate")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
|
||||||
|
# The token is untouched: one active row, the admin's list agrees.
|
||||||
|
items = admin.get("/api/tokens").json()["tokens"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["id"] == body["id"]
|
||||||
|
assert items[0]["revoked"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_revoke_unknown_id_404(admin_client: TestClient) -> None:
|
def test_revoke_unknown_id_404(admin_client: TestClient) -> None:
|
||||||
"""One fixed message for every unknown id (no enumeration)."""
|
"""One fixed message for every unknown id (no enumeration)."""
|
||||||
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
|
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
|
||||||
|
|||||||
@@ -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
|
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) ----------
|
# ---------- 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);
|
which is what makes the task-03 one-generic-401 safe);
|
||||||
* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the
|
* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the
|
||||||
original stamp; False only for a missing id);
|
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
|
House DB-test pattern (the ``test_sources_meta`` precedent): the service
|
||||||
is a thin session wrapper whose contract (server-default ``created_at``,
|
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
|
seconds=1
|
||||||
)
|
)
|
||||||
db.commit()
|
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