phase: 11_admin_login
**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.
**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).
**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.
**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.
**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.
**Next pending phase**: `12_nut_self_test_verdict`.
This commit is contained in:
@@ -326,3 +326,77 @@ def test_a_300s_source_is_pruned_by_the_existing_retention(db) -> None:
|
||||
assert counts["pruned"] >= 4
|
||||
assert db.query("disks", "h", "disk.temp_c@sda", start=0) == []
|
||||
assert len(db.query("disks", "h", "disk.temp_c@sdb", start=0)) == 1
|
||||
|
||||
|
||||
# ---- meta: the durable key/value state (phase 11) --------------------------- #
|
||||
|
||||
|
||||
def test_get_meta_answers_none_for_a_key_nobody_wrote(db) -> None:
|
||||
"""`None` and `""` are different sentences here.
|
||||
|
||||
`auth.verifier` missing means "this box has never been seeded" (fail closed at startup),
|
||||
while a value means "the credential lives here". A store that returned `""` for an absent
|
||||
row would read as a corrupt one instead.
|
||||
"""
|
||||
assert db.get_meta("no.such.key") is None
|
||||
db.set_meta("empty.on.purpose", "")
|
||||
assert db.get_meta("empty.on.purpose") == ""
|
||||
|
||||
|
||||
def test_meta_round_trips_through_a_reopen(db) -> None:
|
||||
db.set_meta("auth.session_secret", "ab" * 32)
|
||||
assert db.get_meta("auth.session_secret") == "ab" * 32
|
||||
db.close()
|
||||
reopened = SampleStore(db.path)
|
||||
try:
|
||||
assert reopened.get_meta("auth.session_secret") == "ab" * 32
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_set_meta_replaces_instead_of_adding(db) -> None:
|
||||
"""One row per key (`INSERT OR REPLACE`), because `key` is the primary key.
|
||||
|
||||
An `INSERT` alone would raise on the second write, which is the change-password path
|
||||
(`auth.update_verifier`) failing after the user pressed Save.
|
||||
"""
|
||||
db.set_meta("auth.verifier", "first")
|
||||
db.set_meta("auth.verifier", "second")
|
||||
assert db.get_meta("auth.verifier") == "second"
|
||||
(rows,) = db._db.execute( # the private handle is the point: exactly one row, not two
|
||||
"SELECT COUNT(*) FROM meta WHERE key='auth.verifier'").fetchone()
|
||||
assert rows == 1
|
||||
|
||||
|
||||
def test_delete_meta_reports_whether_a_row_was_there(db) -> None:
|
||||
"""The rowcount is the whole point: the password-reset procedure has to say what it did."""
|
||||
db.set_meta("auth.verifier", "x")
|
||||
assert db.delete_meta("auth.verifier") == 1
|
||||
assert db.delete_meta("auth.verifier") == 0
|
||||
assert db.get_meta("auth.verifier") is None
|
||||
|
||||
|
||||
def test_delete_meta_touches_only_the_key_it_names(db) -> None:
|
||||
"""A reset that deletes one row too many deletes the operator's only credential."""
|
||||
db.set_meta("auth.verifier", "the-verifier")
|
||||
db.set_meta("auth.session_secret", "the-secret")
|
||||
db.set_meta("last_maintain", "1700000000.0")
|
||||
|
||||
assert db.delete_meta("auth.session_secret") == 1
|
||||
|
||||
assert db.get_meta("auth.session_secret") is None
|
||||
assert db.get_meta("auth.verifier") == "the-verifier"
|
||||
assert db.get_meta("last_maintain") == "1700000000.0"
|
||||
|
||||
|
||||
def test_stats_reads_last_maintain_through_get_meta(db) -> None:
|
||||
"""One path to the table.
|
||||
|
||||
`stats()` reported `None` before any maintenance ran and the timestamp afterwards; that
|
||||
behaviour is pinned here while the read goes through the same helper everything else uses,
|
||||
and it is why the helper is called outside `stats()`'s own lock (`threading.Lock` is not
|
||||
reentrant — call it inside and the dashboard deadlocks on the first `/api/health`).
|
||||
"""
|
||||
assert db.stats()["last_maintain"] is None
|
||||
db.maintain(now=1_700_000_000.0)
|
||||
assert db.stats()["last_maintain"] == 1_700_000_000.0
|
||||
|
||||
Reference in New Issue
Block a user