"""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: """`` 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 "" 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()}