Files
dashboards/tests/test_auth_docs.py
ducoterra 86c8bf4992 phase: 33_gateway_route_switching
Phase 33 completion pass is done. Final report:

## Phase 33 — completion report

**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.

### Work in this pass

1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).

2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.

3. **Documentation consistency fixes** (task 08 criterion 6):
   - `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
   - `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
   - `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
   - `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.

### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported

### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
2026-11-01 11:50:51 -05:00

299 lines
15 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"
)
#: 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"