**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`.
94 lines
3.9 KiB
Python
94 lines
3.9 KiB
Python
"""Startup wiring: `python -m dashboards` / the `dashboards` console script.
|
|
|
|
Two things are worth pinning: the reload path must hand uvicorn an import string (an
|
|
app object cannot be reloaded), and debugpy must stay completely inert unless asked.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import ModuleType
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from auth_client import sign_in
|
|
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
|
from dashboards import __main__ as entry
|
|
from dashboards import debug as debug_module
|
|
from dashboards.config import Settings
|
|
|
|
|
|
class FakeUvicorn(ModuleType):
|
|
def __init__(self) -> None:
|
|
super().__init__("uvicorn")
|
|
self.calls: list[tuple[Any, dict[str, Any]]] = []
|
|
|
|
def run(self, target: Any, **kwargs: Any) -> None:
|
|
self.calls.append((target, kwargs))
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_uvicorn(monkeypatch: pytest.MonkeyPatch) -> FakeUvicorn:
|
|
module = FakeUvicorn()
|
|
monkeypatch.setitem(sys.modules, "uvicorn", module)
|
|
return module
|
|
|
|
|
|
def test_main_serves_the_assembled_app(fake_uvicorn: FakeUvicorn, tmp_path: Any,
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Settings is frozen on purpose, so the module attribute is replaced, not poked. The DB
|
|
# path is private: a test must not open the developer's running database.
|
|
monkeypatch.setattr(entry, "SETTINGS",
|
|
Settings(host="0.0.0.0", port=8090, db_path=str(tmp_path / "main.sqlite3"),
|
|
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2))
|
|
monkeypatch.delenv("DASH_RELOAD", raising=False)
|
|
entry.main()
|
|
(target, kwargs), = fake_uvicorn.calls
|
|
assert kwargs == {"host": "0.0.0.0", "port": 8090}
|
|
# FastAPI keeps included routers in a private wrapper, so the honest check is a request
|
|
assert isinstance(target, FastAPI)
|
|
with TestClient(target) as client:
|
|
# `/api/health` is public and must stay so — the Containerfile HEALTHCHECK has no
|
|
# session and no way to get one. `/ups` is not: signing in here is what keeps this
|
|
# test about startup wiring instead of about which routes are gated (that is
|
|
# tests/test_auth_api.py's job).
|
|
assert client.get("/api/health").status_code == 200
|
|
sign_in(client)
|
|
assert client.get("/ups").status_code == 200
|
|
|
|
|
|
def test_reload_mode_passes_an_import_string(fake_uvicorn: FakeUvicorn, tmp_path: Any,
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(entry, "SETTINGS", Settings(db_path=str(tmp_path / "reload.sqlite3")))
|
|
# uvicorn cannot reload an object it was handed, only a module:app string
|
|
monkeypatch.setenv("DASH_RELOAD", "1")
|
|
entry.main()
|
|
(target, kwargs), = fake_uvicorn.calls
|
|
assert target == "dashboards.app:app"
|
|
assert kwargs["reload"] is True
|
|
|
|
|
|
def test_debugpy_is_inert_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("DEBUGPY", raising=False)
|
|
assert debug_module.maybe_listen() is False
|
|
|
|
|
|
def test_debugpy_listens_when_asked(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[tuple[str, int]] = []
|
|
fake = ModuleType("debugpy")
|
|
fake.listen = lambda addr: calls.append(addr) # type: ignore[attr-defined]
|
|
monkeypatch.setitem(sys.modules, "debugpy", fake)
|
|
monkeypatch.setenv("DEBUGPY", "yes")
|
|
monkeypatch.setenv("DEBUGPY_PORT", "5699")
|
|
assert debug_module.maybe_listen() is True
|
|
assert calls == [("0.0.0.0", 5699)]
|
|
|
|
|
|
def test_debugpy_missing_is_a_message_not_a_crash(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("DEBUGPY", "1")
|
|
# a None entry makes `import debugpy` raise ImportError, as it does in a prod venv
|
|
monkeypatch.setitem(sys.modules, "debugpy", None) # type: ignore[arg-type]
|
|
assert debug_module.maybe_listen() is False
|