Files
dashboards/tests/auth_client.py
T
ducoterra 1734d81e48 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`.
2026-10-19 07:45:44 -04:00

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