Files
dashboards/tests/test_auth_bootstrap.py
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

307 lines
13 KiB
Python

"""Where the admin credential lives, and what happens when it does not (phase 11 task 02).
`auth.load_auth_state` is the only code that turns a deployment's environment plus the durable
`meta` rows into the state every request is checked against, and it owns three decisions that
are easy to get subtly wrong and impossible to see in a screenshot:
* the environment **seeds** a password once — after the first boot the stored verifier wins, so
`/settings` can change it without a redeploy and a restart cannot roll it back;
* a server with neither a stored verifier nor `DASH_ADMIN_PASSWORD` **refuses to start**
(fail closed) rather than serving the fleet open behind a public domain;
* changing the password rotates the signing secret, which is what signs out every *other*
browser when there is no session table to walk.
The store here is a dict. That is the point of the `MetaStore` protocol: the security-relevant
branch has no SQLite in it, so these tests assert the decisions rather than the schema — and
one test at the end puts the real `SampleStore` back underneath to prove the durability the
protocol is written against.
"""
from __future__ import annotations
import logging
from dataclasses import FrozenInstanceError
from pathlib import Path
from typing import Any
import pytest
from dashboards import auth
from dashboards.store import SampleStore
#: Nothing in this file is about the cost; `tests/test_auth.py` pins the 600 000 default.
CHEAP = 1_000
PASSWORD = "testpass"
class FakeMeta:
"""The three-method shape `load_auth_state` asks for, with no database in it."""
def __init__(self, rows: dict[str, str] | None = None) -> None:
self.rows: dict[str, str] = dict(rows or {})
def get_meta(self, key: str) -> str | None:
return self.rows.get(key)
def set_meta(self, key: str, value: str) -> None:
self.rows[key] = value
def delete_meta(self, key: str) -> int:
return 1 if self.rows.pop(key, None) is not None else 0
def load(store: FakeMeta, *, admin_password: str = PASSWORD, **kwargs: Any) -> auth.AuthState:
return auth.load_auth_state(store, admin_password=admin_password, iterations=CHEAP,
**kwargs)
def seed(*, admin_password: str = PASSWORD) -> tuple[FakeMeta, auth.AuthState]:
"""A store that has already booted once, and the state that boot produced."""
store = FakeMeta()
return store, load(store, admin_password=admin_password)
# --- first boot: the environment seeds ---------------------------------------------------- #
def test_first_boot_seeds_the_verifier_and_persists_the_secret() -> None:
store = FakeMeta()
state = load(store)
assert state.seeded is True, "this start wrote the verifier row"
assert state.env_ignored is False, "there was nothing stored to ignore the env with"
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
assert store.rows[auth.META_SESSION_SECRET] == state.secret
assert state.iterations == CHEAP
assert state.session_hours == auth.SESSION_HOURS
def test_the_seed_is_stored_as_a_verifier_and_never_as_a_password() -> None:
"""`AGENTS.md` invariant 6 on the way *in*: the row is PBKDF2 text, not the secret.
The completion criterion for the task is that `grep -c testpass` on a real database is 0;
this is the same claim one layer up, where a regression would show.
"""
store = FakeMeta()
state = load(store)
for value in [*store.rows.values(), state.verifier, state.secret]:
assert PASSWORD not in value
assert store.rows[auth.META_VERIFIER].startswith(f"{auth.PBKDF2_ALGO}${CHEAP}$")
def test_the_stored_rows_survive_a_reopen_of_the_real_database(tmp_path: Path) -> None:
"""The whole reason this is in SQLite and not in process memory.
A restart must not re-seed, must not mint a new signing secret (that would sign every
browser out on every deploy), and must not need `DASH_ADMIN_PASSWORD` any more.
"""
path = tmp_path / "dashboards.sqlite3"
first_store = SampleStore(path)
try:
first = auth.load_auth_state(first_store, admin_password=PASSWORD, iterations=CHEAP)
assert first.seeded is True
finally:
first_store.close()
reopened = SampleStore(path)
try:
second = auth.load_auth_state(reopened, admin_password="", iterations=CHEAP)
finally:
reopened.close()
assert second.seeded is False
assert second.verifier == first.verifier
assert second.secret == first.secret, "a new secret on every restart would log everyone out"
assert auth.verify_password(PASSWORD, second.verifier) is True
# --- the stored verifier wins ------------------------------------------------------------- #
def test_a_stored_verifier_beats_a_conflicting_env_password(caplog: pytest.LogCaptureFixture) -> None:
store, _first = seed()
original = store.rows[auth.META_VERIFIER]
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
second = load(store, admin_password="some-other-password")
assert second.env_ignored is True
assert second.seeded is False
assert store.rows[auth.META_VERIFIER] == original, "the env must not overwrite the row"
assert second.verifier == original
assert auth.verify_password(PASSWORD, second.verifier) is True
assert auth.verify_password("some-other-password", second.verifier) is False
def test_the_ignored_env_var_is_reported_once_in_fixed_words(caplog: pytest.LogCaptureFixture) -> None:
"""The operator who set `DASH_ADMIN_PASSWORD` and saw nothing happen gets one sentence.
The sentence must name the variable and the row; it must not name the value, the hash, or
any prefix of either — that line ends up in a container log, which is exactly where
invariant 6 says a credential must not go.
"""
store, _first = seed()
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
state = load(store, admin_password="ignored-now")
messages = [rec.getMessage() for rec in caplog.records if rec.name == "dashboards.auth"]
assert messages == [auth.ENV_IGNORED_MESSAGE], "exactly one line, at startup"
line = messages[0]
assert "DASH_ADMIN_PASSWORD is ignored" in line
assert auth.META_VERIFIER in line
for secret in (PASSWORD, "ignored-now", state.verifier, state.secret):
assert secret not in line
def test_the_warning_is_not_written_when_the_env_actually_seeded(caplog: pytest.LogCaptureFixture) -> None:
"""`seeded` and `env_ignored` are mutually exclusive, and only the second one speaks."""
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
state = load(FakeMeta())
assert state.env_ignored is False
assert [r for r in caplog.records if r.name == "dashboards.auth"] == []
def test_the_environment_variable_can_be_dropped_after_first_boot() -> None:
"""The operator's wording: "configured initially with an env var, not required to change".
Second boot with no seed at all reuses the row, reports neither `seeded` nor
`env_ignored`, and stays quiet.
"""
store, first = seed()
second = load(store, admin_password="")
assert second.seeded is False
assert second.env_ignored is False
assert second.verifier == first.verifier
def test_a_blank_stored_verifier_counts_as_no_verifier_at_all() -> None:
"""A row of whitespace is not a password — reseeding it is the only way back in."""
store = FakeMeta({auth.META_VERIFIER: " \n "})
state = load(store)
assert state.seeded is True
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
# --- fail closed -------------------------------------------------------------------------- #
def test_no_verifier_and_no_seed_refuses_to_start_instead_of_running_open() -> None:
store = FakeMeta()
with pytest.raises(RuntimeError) as caught:
load(store, admin_password="")
message = str(caught.value)
assert "DASH_ADMIN_PASSWORD" in message, "names the variable to set"
assert auth.META_VERIFIER in message, "names the row that would have been used"
assert auth.META_SESSION_SECRET in message
assert "DELETE FROM meta WHERE key IN" in message, "gives the reset command"
assert "sqlite3" in message
assert store.rows == {}, "refusing means writing nothing, not writing and then complaining"
def test_the_refusal_names_the_database_the_operator_has_to_reset() -> None:
"""`<db>` is a placeholder nobody can run; the real path is what makes the fix copy-paste."""
with pytest.raises(RuntimeError) as caught:
auth.load_auth_state(FakeMeta(), admin_password="", db_path="/var/lib/dashboards/d.db")
assert "/var/lib/dashboards/d.db" in str(caught.value)
with pytest.raises(RuntimeError) as bare:
auth.load_auth_state(FakeMeta(), admin_password="")
assert "<db>" in str(bare.value), "no path known, and no invented one either"
def test_the_refusal_carries_no_password_and_no_verifier() -> None:
"""The message reaches a terminal and a container log; it may carry instructions only."""
store = FakeMeta()
with pytest.raises(RuntimeError) as caught:
load(store, admin_password="")
assert PASSWORD not in str(caught.value)
# --- the signing secret -------------------------------------------------------------------- #
def test_a_usable_session_secret_is_reused_as_is() -> None:
"""A restart must not rotate the key, or every browser is signed out for nothing."""
existing = auth.new_secret()
# a second boot, not a first: the verifier row exists and the secret is what is under test
store = FakeMeta({auth.META_VERIFIER: auth.hash_password(PASSWORD, iterations=CHEAP),
auth.META_SESSION_SECRET: existing})
state = load(store, admin_password="")
assert state.secret == existing
@pytest.mark.parametrize("corrupt", [
"",
" ",
"deadbeef", # hex, but a 16-bit key is not what we sign a 30-day cookie with
"z" * 64, # long enough, and not hex
"0" * 63, # one character short of 256 bits
])
def test_a_corrupt_session_secret_is_regenerated_and_persisted(corrupt: str) -> None:
"""The chosen behaviour, stated: replace it and write the replacement.
The effect on the operator is "every browser is asked to sign in again" — a login prompt,
not a lockout — which is the safe direction for a damaged row. Refusing to start instead
would turn a corrupt cell into an outage of a dashboard that still has a working password.
"""
store, first = seed()
store.rows[auth.META_SESSION_SECRET] = corrupt
state = load(store, admin_password="")
assert state.secret != corrupt
assert state.secret == store.rows[auth.META_SESSION_SECRET], "the replacement is durable"
assert len(state.secret) == auth.SECRET_MIN_BYTES * 2
bytes.fromhex(state.secret) # raises if the replacement is not the format we mint
assert state.verifier == first.verifier, "the password is untouched"
# and the state is usable: a session minted from it reads back
token = auth.mint_session(state.secret, now=1_700_000_000.0)
assert auth.read_session(state.secret, token, now=1_700_000_000.0) is not None
# --- changing the password ----------------------------------------------------------------- #
def test_update_verifier_replaces_both_rows_and_returns_the_new_state() -> None:
store, before = seed()
old_token = auth.mint_session(before.secret, now=1_700_000_000.0)
after = auth.update_verifier(store, before, "horse battery staple")
assert after.secret != before.secret
assert store.rows[auth.META_SESSION_SECRET] == after.secret
assert store.rows[auth.META_VERIFIER] == after.verifier
assert auth.verify_password("horse battery staple", after.verifier) is True
assert auth.verify_password(PASSWORD, after.verifier) is False
assert after.session_hours == before.session_hours
assert after.iterations == before.iterations
assert after.seeded is False and after.env_ignored is False
# the browser that was not in front of the form is out: its cookie is signed by the old key
assert auth.read_session(after.secret, old_token, now=1_700_000_000.0) is None
# and the new state can still mint one that reads
fresh = auth.mint_session(after.secret, now=1_700_000_000.0)
assert auth.read_session(after.secret, fresh, now=1_700_000_000.0) is not None
@pytest.mark.parametrize("blank", ["", " ", "\t", "\n \r"])
def test_update_verifier_refuses_to_store_an_empty_password(blank: str) -> None:
store, before = seed()
with pytest.raises(ValueError) as caught:
auth.update_verifier(store, before, blank)
assert store.rows[auth.META_VERIFIER] == before.verifier
assert store.rows[auth.META_SESSION_SECRET] == before.secret
# fixed text: nothing the caller typed is echoed back into a log or a traceback
assert str(caught.value) == "a new password must not be empty or whitespace"
def test_the_state_is_a_frozen_bundle_of_credentials() -> None:
"""Nothing may mutate a live `AppContext.auth`, and nothing may treat it as display data."""
_store, state = seed()
with pytest.raises(FrozenInstanceError):
state.verifier = "swapped" # type: ignore[misc]
assert {"verifier", "secret", "session_hours", "iterations", "seeded", "env_ignored"} == {
f.name for f in state.__dataclass_fields__.values()}