Files
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

49 lines
2.5 KiB
Python

"""How a test gets a signed-in client, and the credential it signs in with.
Phase 11 task 03 put every route except `PUBLIC_PATHS` behind a session, so a TestClient that
used to `GET /api/config` now gets a 401 unless it signs in first. This module is the one place
that does it, for the same reason `tests/card_contract.py` is the one place the card paths are
written down: if signing in is a sequence twelve test files have to remember, one of them will
remember it wrong and assert the 401.
`sign_in` also copies the CSRF token onto the client's default headers, which is what keeps the
twelve existing `client.post(`/`ups_client.post(` assertions in `tests/test_api.py` unedited:
they get the session cookie *and* the write header for free, so the assertions still assert the
UPS behaviour they were written for instead of the new gate.
The password itself lives in `auth_contract.py` (with the cheap PBKDF2 cost the suite hashes it
at) and is re-exported here, so `from auth_client import ADMIN_PASSWORD, sign_in` works and
there is still exactly one file that says what the testing password is.
"""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2 # re-exported: one constant, two doors
from dashboards import auth
__all__ = ["ADMIN_PASSWORD", "FAST_PBKDF2", "sign_in"]
def sign_in(client: TestClient, password: str = ADMIN_PASSWORD,
**payload: Any) -> dict[str, Any]:
"""Log the client in and make its writes carry the CSRF token.
Returns the login body (`{ok, username, expires_at, csrf, next}`) so a test can assert on it
without repeating the request. The assertion that the sign-in *worked* is part of the helper:
a test that silently started from a 401 would otherwise fail three lines later on something
that looks like an unrelated bug.
Pass extra keyword arguments to reach the parts of the endpoint the pages do not use (a
`next`, or a body the login form would never send).
"""
response = client.post("/api/auth/login", json={"password": password, **payload})
assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}"
body: dict[str, Any] = response.json()
# On the client, not per request: the existing HTTP assertions in test_api.py post to the
# upstream write endpoints, and a token the helper did not attach would 403 every one of them.
client.headers[auth.CSRF_HEADER] = str(body.get("csrf", ""))
return body