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.
49 lines
2.5 KiB
Python
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
|