**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`.
49 lines
2.5 KiB
Python
49 lines
2.5 KiB
Python
"""How a test gets a signed-in client, and the credential it signs in with.
|
|
|
|
Phase 11 task 03 put every route except `PUBLIC_PATHS` behind a session, so a TestClient that
|
|
used to `GET /api/config` now gets a 401 unless it signs in first. This module is the one place
|
|
that does it, for the same reason `tests/card_contract.py` is the one place the card paths are
|
|
written down: if signing in is a sequence twelve test files have to remember, one of them will
|
|
remember it wrong and assert the 401.
|
|
|
|
`sign_in` also copies the CSRF token onto the client's default headers, which is what keeps the
|
|
twelve existing `client.post(`/`ups_client.post(` assertions in `tests/test_api.py` unedited:
|
|
they get the session cookie *and* the write header for free, so the assertions still assert the
|
|
UPS behaviour they were written for instead of the new gate.
|
|
|
|
The password itself lives in `auth_contract.py` (with the cheap PBKDF2 cost the suite hashes it
|
|
at) and is re-exported here, so `from auth_client import ADMIN_PASSWORD, sign_in` works and
|
|
there is still exactly one file that says what the testing password is.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2 # re-exported: one constant, two doors
|
|
from dashboards import auth
|
|
|
|
__all__ = ["ADMIN_PASSWORD", "FAST_PBKDF2", "sign_in"]
|
|
|
|
|
|
def sign_in(client: TestClient, password: str = ADMIN_PASSWORD,
|
|
**payload: Any) -> dict[str, Any]:
|
|
"""Log the client in and make its writes carry the CSRF token.
|
|
|
|
Returns the login body (`{ok, username, expires_at, csrf, next}`) so a test can assert on it
|
|
without repeating the request. The assertion that the sign-in *worked* is part of the helper:
|
|
a test that silently started from a 401 would otherwise fail three lines later on something
|
|
that looks like an unrelated bug.
|
|
|
|
Pass extra keyword arguments to reach the parts of the endpoint the pages do not use (a
|
|
`next`, or a body the login form would never send).
|
|
"""
|
|
response = client.post("/api/auth/login", json={"password": password, **payload})
|
|
assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}"
|
|
body: dict[str, Any] = response.json()
|
|
# On the client, not per request: the ~200 existing HTTP assertions in test_api.py post to
|
|
# the one write endpoint, and a token the helper did not attach would 403 every one of them.
|
|
client.headers[auth.CSRF_HEADER] = str(body.get("csrf", ""))
|
|
return body
|