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

266 lines
13 KiB
Python

"""The operator-facing setup surface of phase 11: the two templates, the dev script, the docs.
Nothing here runs a server. What it does is pin the four artefacts an operator actually reads
when a dashboard will not come up, because every one of them is prose that can go stale without
breaking a single assertion in the rest of the suite:
* the tracked env templates must **declare** every auth setting (a setting nobody has to write a
line for is a setting nobody knows exists) and must carry **no real secret** — the same rule
`.agents/validate.sh` 2b enforces through `git ls-files`, restated here so `uv run pytest`
catches it too and catches it on the working tree, not only on what was committed;
* `scripts/dev-server.sh` must keep working **unsigned**: its readiness probe and its `status`
both ride `/api/health`, the one data route the gate leaves open (AGENTS.md invariant 12). The
day someone "fixes" the script by signing in first, the script grows a password and the
Containerfile healthcheck is the next thing that needs one;
* `README.md`'s *Signing in* section and `AGENTS.md`'s invariant/gap prose must still name the
things an operator has to act on — the four public paths, the reset command, the four
variables, and the deployment flags. A doc that stops naming the reset command is a support
call about a locked-out admin.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
ENV_EXAMPLE = ROOT / ".env.example"
ENV_DEV_EXAMPLE = ROOT / ".env.dev.example"
DEV_SERVER = ROOT / "scripts" / "dev-server.sh"
README = ROOT / "README.md"
AGENTS = ROOT / "AGENTS.md"
#: The four settings phase 11 added. `tests/test_config.py` pins what they *mean*; these tests
#: pin that a human setting up a box is told they exist.
AUTH_VARS = (
"DASH_ADMIN_PASSWORD",
"DASH_SESSION_HOURS",
"DASH_COOKIE_SECURE",
"DASH_PBKDF2_ITERATIONS",
)
#: The public surface, as `app.py`'s `PUBLIC_PATHS`/`PUBLIC_PREFIXES` and AGENTS.md invariant 12
#: name it. A README that lists a different set is the doc drifting from the gate.
PUBLIC_ENTRIES = ("/login", "/api/auth/login", "/api/health", "/static/")
#: `.agents/validate.sh` 2b's placeholder allow-list, copied deliberately: the gate and the test
#: are two readers of one rule, and a test that invents a laxer list would pass while the gate
#: fails (or the reverse). The empty alternative is the "no secret here, unset it" spelling.
PLACEHOLDER = re.compile(
r"^(|changeme|change-me|replace-me[-a-z0-9]*|secret|placeholder|dummy|example|example\.net|x+)$",
re.IGNORECASE,
)
#: A secret-shaped *assignment* (never a comment: the pattern is anchored at `^` exactly like the
#: gate's, which is what makes prose about the local testing password safe to write).
SECRET_ASSIGNMENT = re.compile(
r"^[ \t]*(?:export[ \t]+)?([A-Za-z0-9_]*(?:PASSWORD|TOKEN|SECRET|KEY)[A-Za-z0-9_]*)=(.*)$"
)
#: The local-testing word (`tests/auth_contract.ADMIN_PASSWORD`). It is documentation, not a
#: credential — AGENTS.md invariant 11 states it in prose too — but it must never become the
#: *value* of a tracked line, which is the exact trap the phase task calls out.
LOCAL_TESTING_PASSWORD = "testpass"
TEMPLATES = (ENV_EXAMPLE, ENV_DEV_EXAMPLE)
def _assignments(path: Path) -> dict[str, str]:
"""Every secret-shaped assignment in a file, comment lines excluded by the anchor."""
out: dict[str, str] = {}
for line in path.read_text().splitlines():
match = SECRET_ASSIGNMENT.match(line)
if match is None:
continue
name, value = match.group(1), match.group(2)
# The gate's own value munging: strip a trailing comment, whitespace, and quotes.
value = value.split("#", 1)[0].replace('"', "").replace("'", "").strip()
out[name] = value
return out
# --- the two tracked env templates ---------------------------------------------------------
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_every_auth_setting_is_declared_in_the_template(template: Path) -> None:
"""A setting that appears nowhere in the template is a setting nobody knows to set."""
text = template.read_text()
declared = {
name for name in AUTH_VARS if re.search(rf"^[ \t]*(?:export[ \t]+)?{name}=", text, re.MULTILINE)
}
assert declared == set(AUTH_VARS), f"{template.name} declares {sorted(declared)}, wants all of {AUTH_VARS}"
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_no_secret_shaped_assignment_carries_anything_but_a_placeholder(template: Path) -> None:
"""The hermetic twin of `.agents/validate.sh` 2b, run on the working tree.
`testpass` is not on the allow-list and must not be: the fix for a template that needs a
dev password is a placeholder plus prose, with the real word in the gitignored file.
"""
offenders = {
name: value for name, value in _assignments(template).items() if not PLACEHOLDER.match(value)
}
assert not offenders, f"{template.name} carries a real-looking secret: {offenders}"
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_the_local_testing_password_is_prose_and_never_an_assignment(template: Path) -> None:
"""The word is allowed in a comment (it is in AGENTS.md too) and forbidden as a value."""
text = template.read_text()
values = _assignments(template)
assert LOCAL_TESTING_PASSWORD not in values.values(), (
f"{template.name} assigns the local-testing password; it belongs in .env.dev"
)
commented = [ln for ln in text.splitlines() if ln.lstrip().startswith("#")]
if LOCAL_TESTING_PASSWORD in text:
assert any(LOCAL_TESTING_PASSWORD in ln for ln in commented), (
f"{template.name} names the local-testing password outside a comment"
)
def test_the_dev_template_points_at_where_the_local_password_lives() -> None:
"""A dev copying the template needs to know what to type at the first login prompt."""
text = ENV_DEV_EXAMPLE.read_text().lower()
assert LOCAL_TESTING_PASSWORD in text, "the dev template stopped naming the local-testing word"
assert "auth_contract" in text or "auth_client" in text, (
"the dev template stopped pointing at the file that defines that word"
)
# …and the seed-vs-override rule, which is the sentence that prevents the "I edited the env
# file and nothing happened" call, has to be in the file people actually edit.
assert "seed" in text
assert "refuses to start" in text
def test_the_tracked_templates_are_the_only_tracked_env_files() -> None:
"""`.gitignore` un-ignores exactly the two `.example` files; a third one is a leak waiting."""
tracked = sorted(p.name for p in ROOT.glob(".env*"))
allowed = {".env", ".env.dev", ".env.example", ".env.dev.example"}
assert set(tracked) <= allowed, f"unexpected env file in the checkout: {set(tracked) - allowed}"
for name in (".env.example", ".env.dev.example"):
assert (ROOT / name).is_file(), f"{name} must stay tracked — it is the setup documentation"
# --- scripts/dev-server.sh ----------------------------------------------------------------
def test_the_dev_server_probes_the_public_health_route() -> None:
"""`start`'s readiness probe and `status` both ride `/api/health`, the one open data route."""
text = DEV_SERVER.read_text()
assert text.count("/api/health") >= 2, "both start()'s probe and status() must use /api/health"
assert "/api/summary" not in text and "/api/config" not in text, (
"the dev script started polling a route that needs a session"
)
def test_the_dev_server_grew_no_auth_logic_and_no_password() -> None:
"""Invariant 12's corollary: the script needs no credential, so it must never hold one."""
text = DEV_SERVER.read_text()
for forbidden in ("dash_session", "/api/auth/", "X-CSRF-Token", "Authorization:"):
assert forbidden not in text, f"dev-server.sh grew auth ({forbidden!r})"
assert LOCAL_TESTING_PASSWORD not in text, "the local-testing password does not belong in a script"
# What it *does* say: where the credential comes from, without quoting it. Naming the
# variable is fine; an assignment is a value, and a value here is a committed password.
assert "DASH_ADMIN_PASSWORD" in text, "the script stopped telling the operator where to sign in with"
assert not re.search(r"DASH_ADMIN_PASSWORD\s*=", text), (
"dev-server.sh assigns the admin password; it may only name the variable"
)
def test_the_dev_server_still_parses() -> None:
"""`bash -n` is the only syntax gate a shell script here gets outside a real run."""
bash = shutil.which("bash") or "/bin/bash"
result = subprocess.run([bash, "-n", str(DEV_SERVER)], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
# --- README.md ----------------------------------------------------------------------------
def test_readme_has_a_signing_in_section_before_configuration() -> None:
"""The first thing an operator meets is a login prompt; the doc says so before the table."""
text = README.read_text()
signing = text.index("## Signing in")
configuration = text.index("## Configuration")
assert signing < configuration, "*Signing in* must come before *Configuration*"
assert "## Admin login" not in text, "the section was renamed; a stale heading is a broken anchor"
def test_readme_documents_the_public_surface_and_why_health_is_one_of_it() -> None:
text = README.read_text()
section = text[text.index("## Signing in"):text.index("## Configuration")]
for entry in PUBLIC_ENTRIES:
assert entry in section, f"*Signing in* stopped naming the public entry {entry}"
# The reason is the part an operator decides with, not the list.
assert "HEALTHCHECK" in section and "dev-server.sh" in section, (
"*Signing in* must say why /api/health stays open"
)
def test_readme_carries_the_reset_command_verbatim() -> None:
"""Copy-pasteable: someone locked out of a public dashboard reads this line and runs it."""
text = README.read_text()
assert "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')" in text, (
"README's password-reset command changed shape; make it match the RuntimeError in auth.py"
)
def test_readme_documents_every_auth_variable_and_the_precedence_rule() -> None:
text = README.read_text()
table = text[text.index("## Configuration"):text.index("## Architecture")]
for name in AUTH_VARS:
assert f"`{name}`" in table, f"the configuration table lost {name}"
# seed → stored verifier wins, and the bearer path for scripted callers
assert "seed" in table
signing = text[text.index("## Signing in"):text.index("## Configuration")]
assert "Authorization: Bearer" in signing, "the scripted-caller path is undocumented"
assert "30 days" in signing or "720" in signing, "the session lifetime is undocumented"
def test_readme_tells_the_deployment_repo_what_to_set() -> None:
"""Two lines belong in the quadlet env template; a note about nginx lives there, not here."""
text = README.read_text()
deployment = text[text.index("## Deployment"):]
assert "DASH_ADMIN_PASSWORD" in deployment
assert "DASH_COOKIE_SECURE=1" in deployment, "the deployment note stopped asking for Secure cookies"
assert "HSTS" in deployment and "deployment repo" in deployment, (
"README must say nginx config/HSTS live in the deployment repo like every other service"
)
# --- AGENTS.md ----------------------------------------------------------------------------
def test_agents_md_carries_the_login_invariants() -> None:
"""One rule for the credential, one for the gate, and the public list named in full."""
text = AGENTS.read_text()
rules = text[text.index("## Non-negotiable rules"):text.index("## Architecture")]
for entry in PUBLIC_ENTRIES:
assert entry in rules, f"the invariants stopped naming the public entry {entry}"
assert "seed" in rules and "refuses to start" in rules
assert "auth.verifier" in rules and "auth.session_secret" in rules
# /api/health being open is a decision, and the invariant has to say what that costs.
assert "nothing may be added to it without re-deciding" in rules
def test_agents_md_documents_the_session_and_the_pages_that_need_it() -> None:
text = AGENTS.read_text()
intro = text[:text.index("## Non-negotiable rules")]
assert "/settings" in intro and "/login" in intro, "the page list stopped naming the auth pages"
assert "invariant 12" in intro, "the page list must point at the gate rule, not restate it"
assert "- `auth.py` —" in text, "the module-responsibility list lost auth.py"
def test_agents_md_names_the_three_auth_gaps() -> None:
"""Peer-address lockout, single factor by design, per-process throttle: all three stay known."""
text = AGENTS.read_text()
gaps = text[text.index("## Known gaps"):text.index("## Locked decisions")]
assert "socket peer address" in gaps, "the lockout-is-global gap went missing"
assert "no second factor and no per-user accounts" in gaps, "the single-factor gap went missing"
assert "The throttle is per-process" in gaps, "the multi-worker gap went missing"