"""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" ) #: The write gate phase 33 added. The pin is the **declaration** — a comment that names #: the variable (with its default and what enabling it authorises) — never an assignment: #: a value in a tracked template is a decision made for everyone who copies the file, and #: the configuration table refuses that trap (the two writes are two decisions, and one #: flag does not carry the other). WRITE_GATES = ("GATEWAY_ALLOW_WRITES",) @pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name) def test_every_write_gate_is_declared_and_never_assigned_in_the_template(template: Path) -> None: """A write gate no template names is a gate nobody finds. The declaration is a comment (a commented-out line, or prose naming the variable with its default and what enabling it authorises). An *active* assignment in a tracked template is the gate armed by default for everyone who copies the file — `GATEWAY_ALLOW_WRITES=1` in a template is a route switch waiting in a fresh deployment. """ text = template.read_text() for name in WRITE_GATES: commented = [ln for ln in text.splitlines() if name in ln and ln.lstrip().startswith("#")] assert commented, ( f"{template.name} never names {name} in a comment — a write gate nobody finds " "is a gate whose default nobody can see" ) active = re.search(rf"^[ \t]*(?:export[ \t]+)?{name}[ \t]*=(?!=)", text, re.MULTILINE) assert active is None, ( f"{template.name} actively assigns {name} — a value in a tracked template is a " "decision for everyone who copies it; the pin is the declaration, never a value" ) 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"