"""HTTP contract: what the pages may assume, and what must never leak. The behaviours here are the ones a screenshot cannot show: an unconfigured source answering 404 `configured: false` instead of dialling a hostname that does not exist; a bearer token that stops at the backend; the single write endpoint refusing on every gate; no external asset the network can fail to deliver. """ from __future__ import annotations import re import time from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from typing import Any import httpx import pytest from fastapi import HTTPException from fastapi.testclient import TestClient from auth_client import sign_in from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2 from dashboards import alerts, auth from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range from dashboards.app import create_app from dashboards.collectors import UpstreamError from dashboards.config import Settings, Target from dashboards.severity import classify from dashboards.sources import nut from dashboards.sources.disks import SMART_SERIES, DisksCollector, parse_disks_snapshot from dashboards.sources.llm import ( HISTORY_METRICS, parse_gufo_metrics, parse_vllm_metrics, ) from dashboards.store import Event, Sample, SampleStore from slots_contract import ( CANONICAL_TASK_ID_FIELD, OPTIONAL_NEXT_TOKEN_FIELDS, OPTIONAL_ROW_FIELDS, REQUIRED_NEXT_TOKEN_FIELDS, REQUIRED_ROW_FIELDS, TASK_ID_ALIASES, USER_CONTENT_FIELDS, load_slots_capture, ) # refused immediately, and never dials out of the test environment DEAD_URL = "http://127.0.0.1:1" #: Every dashboard route, in nav order. The markup pins below (no CDN, no `.sev-*`, no #: narration, no orphan `id`) run across this list rather than a hand-written four, so a #: fifth page cannot join the masthead and skip the checks the others had to pass. PAGES = ("/", "/system", "/disks", "/llm", "/ups") #: `/settings` is a **page** and not a dashboard (phase 11): it carries the same masthead with #: no `aria-current` tab, and the account link in every dashboard's masthead is how you reach #: it. Kept out of `PAGES` because the pins over that tuple assert tab structure — one current #: tab, a fixed tab order — which this page deliberately does not have. `MARKUP_PAGES` is the #: list for the checks that are about the bytes rather than the nav. SETTINGS_PAGE = "/settings" MARKUP_PAGES = (*PAGES, SETTINGS_PAGE) @contextmanager def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False, **overrides: Any) -> Iterator[TestClient]: """A TestClient whose lifespan runs, against a private DB and no real network. The store is injected, so `create_app()` deliberately does not close it (production owns its own); the caller here does, or every test leaks a SQLite handle and the `--cov` log fills with ResourceWarnings that bury the warnings worth reading. """ if simulate: monkeypatch.setenv("NUT_SIMULATE", "1") else: monkeypatch.delenv("NUT_SIMULATE", raising=False) db = str(tmp_path / "api.sqlite3") settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2, **overrides) store = SampleStore(db) client = TestClient(create_app(settings, store)) client.__enter__() try: sign_in(client) yield client finally: client.__exit__(None, None, None) store.close() @pytest.fixture def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: with build_client(tmp_path, monkeypatch) as test_client: yield test_client @pytest.fixture def ups_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: """NUT on the labelled simulator, actions allowed — the dev configuration.""" with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1", nut_allow_actions=True) as c: yield c def wait_for_payload(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]: deadline = time.time() + timeout while time.time() < deadline: response = client.get(path) if response.status_code == 200: return response.json() time.sleep(0.1) pytest.fail(f"{path} never became available") def failing_host_payload(scrape: Any, agent_snapshot: dict[str, Any]) -> dict[str, Any]: """Two scrapes of the real driveripper capture, nudged onto known sides of the table. Driven from the fixture rather than hand-written so a schema drift breaks here first. """ def nudge(snap: dict[str, Any]) -> None: snap["memory"]["used_bytes"] = snap["memory"]["total_bytes"] * 0.95 # mem.used_pct -> fail snap["cpu_temperature_celsius"] = 91.0 # cpu.temp_c -> fail for fs in snap["filesystems"]: fs["used_pct"] = 93.0 # disk -> fail return scrape(agent_snapshot, cpu_busy=10.0, mutate=nudge) @pytest.fixture def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent_snapshot: dict[str, Any], host_scrape: Any) -> Iterator[TestClient]: """Overview-shaped app: one host payload in the hub plus the simulated UPSes. The host job points at a dead port, so a poll can never overwrite the injected payload (a failed poll keeps the last good one) — the numbers below are ours, not the fleet's. """ monkeypatch.setenv("NUT_SIMULATE", "1") db = str(tmp_path / "summary.sqlite3") store = SampleStore(db) app = create_app(Settings(db_path=db, nut_host="127.0.0.1", admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2, host_targets=(Target("driveripper", DEAD_URL),)), store) test_client = TestClient(app) test_client.__enter__() try: sign_in(test_client) app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload( host_scrape, agent_snapshot) wait_for_payload(test_client, "/api/ups/snapshot") yield test_client finally: test_client.__exit__(None, None, None) store.close() @pytest.fixture def healthy_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent_snapshot: dict[str, Any], host_scrape: Any) -> Iterator[TestClient]: """The all-clear app: one host answering on both of its jobs, nothing past a threshold. The job's collector is swapped for one that hands back the injected payload, so the health record says `ok` truthfully instead of being overridden behind the poller — and that has to happen for the `disks` job too, which polls the same dead port: a host is either answering or it is not, and a fixture that claims all-clear cannot leave one of its two readers failing. Each reader gets **its own** projection of the same capture (`parse_disks_snapshot` for the drive page), because a `disks` job holding a `system` payload would make `/api/summary`'s drive block answer from the wrong shape and read as a passing test that proved nothing. """ with build_client(tmp_path, monkeypatch, host_targets=(Target("driveripper", DEAD_URL),)) as test_client: from dashboards.poller import Result # noqa: PLC0415 - only this fixture needs it payload = host_scrape(agent_snapshot, cpu_busy=10.0) disks_payload = parse_disks_snapshot("driveripper", agent_snapshot, DisksCollector()).payload async def collect() -> Result: return Result(payload=payload, samples=[]) async def collect_disks() -> Result: return Result(payload=disks_payload, samples=[]) hub = test_client.app.state.ctx.hub # type: ignore[attr-defined] job = hub.jobs["host:driveripper"] disks_job = hub.jobs["disks:driveripper"] job.fn = collect disks_job.fn = collect_disks # let the poll already in flight against the dead port land first (it fails in # microseconds), then take the health record over: the alerts must describe a host # that is answering, not the half-second of startup where it was not deadline = time.time() + 2.0 while time.time() < deadline and job.state.attempts == 0: time.sleep(0.02) time.sleep(0.05) for reader in hub.jobs.values(): if reader.dashboard not in ("system", "disks"): continue reader.state.payload = disks_payload if reader is disks_job else payload reader.state.ok = True reader.state.error = None reader.state.consecutive_failures = 0 reader.state.last_ok = time.time() yield test_client # ---- severity travels with the value --------------------------------------- # def test_summary_carries_the_verdict_for_every_column_the_page_colours(data_client) -> None: body = data_client.get("/api/summary").json() row = body["system"]["hosts"][0] assert row["severity"] == {"cpu_usage_pct": "ok", "mem_used_pct": "fail", "disk_worst_used_pct": "fail", "temp_c": "fail"} # the fleet card is judged by the same table, so amber means the same thing at both # sizes — and no GPU verdict, at fleet size or host size (phase 06: busy is the job) assert body["system"]["fleet"]["severity"] == { "cpu_usage_pct_avg": "ok", "memory_used_pct": "fail", "disk_worst_used_pct": "fail", } for ups in body["ups"]["ups"]: assert set(ups["severity"]) == {"charge_pct", "load_pct", "runtime_seconds"} assert ups["severity"]["charge_pct"] == classify("ups.charge_pct", ups["charge_pct"]) assert ups["severity"]["charge_pct"] == "ok", "the simulator starts at float charge" def test_summary_shape_did_not_shrink(data_client) -> None: """Additive-only anchor: /api/summary gained `severity` and lost nothing. The overview, the live tests, and any external consumer read the flat keys; this test is the tripwire for a well-meaning refactor that "tidies" them away. """ body = data_client.get("/api/summary").json() fleet_keys = {"hosts_total", "hosts_live", "cpu_cores", "cpu_usage_pct_avg", "memory_total_bytes", "memory_used_bytes", "memory_used_pct", "disk_worst_used_pct", "load1_total", "net_rx_bytes_per_s", "net_tx_bytes_per_s", "gpu_count", "gpu_busy_pct_avg", "power_estimate_w"} host_keys = {"name", "os", "cpu_usage_pct", "load1", "cores", "mem_used_pct", "disk_worst_used_pct", "gpu_busy_pct", "temp_c", "uptime_seconds"} ups_keys = {"name", "overall", "status", "charge_pct", "load_pct", "runtime_seconds"} assert fleet_keys <= set(body["system"]["fleet"]), fleet_keys - set(body["system"]["fleet"]) assert host_keys <= set(body["system"]["hosts"][0]), host_keys - set(body["system"]["hosts"][0]) assert ups_keys <= set(body["ups"]["ups"][0]), ups_keys - set(body["ups"]["ups"][0]) # `disks` joined in phase 09 and changed nothing else: the set grows, never shrinks assert set(body) == {"ts", "system", "disks", "llm", "ups", "recent_events", "alerts"} # ...and the new keys are there beside the old ones, not instead of them assert "severity" in body["system"]["fleet"] assert "severity" in body["system"]["hosts"][0] assert "severity" in body["ups"]["ups"][0] # `/` paints two panels; the payload is still the whole-fleet contract. Every path here is # read by something that is not the overview page: the live suite, an external probe, a # notifier later. Deleting a card must never delete a key. SUMMARY_CONTRACT = ("system.enabled", "system.fleet", "system.hosts", "disks.enabled", "disks.fleet", "disks.hosts", "llm.engines", "ups.ups", "recent_events", "alerts") def dig(body: dict[str, Any], path: str) -> Any: """`body["a"]["b"]` that names the missing path when it is not there.""" node: Any = body for part in path.split("."): assert isinstance(node, dict) and part in node, f"/api/summary lost `{path}`" node = node[part] return node @pytest.mark.parametrize("fixture", ["client", "data_client"]) def test_summary_serves_every_path_another_consumer_reads(request, fixture: str) -> None: """The keys survive the overview cleanup whether or not anything is configured. Run twice on purpose: `client` has no targets at all, so it proves the keys are present as `None`/`[]` rather than omitted when a source is unconfigured (invariant 3 is about the payload shape as much as about colour); `data_client` proves the same paths carry the fleet once there is one. """ test_client = request.getfixturevalue(fixture) body = test_client.get("/api/summary").json() populated = fixture == "data_client" for path in SUMMARY_CONTRACT: dig(body, path) # presence is the assertion; a missing path raises with its name assert dig(body, "system.enabled") is populated assert (dig(body, "system.fleet") is not None) is populated names = {host["name"] for host in dig(body, "system.hosts")} assert names == ({"driveripper"} if populated else set()) # the drive-health block is configured exactly when the agents it reads are, and lists the # same hosts: a `disks` job exists for every `HOST_TARGETS` entry and for no other reason assert dig(body, "disks.enabled") is populated assert (dig(body, "disks.fleet") is not None) is populated assert {host["name"] for host in dig(body, "disks.hosts")} == names batteries = {item["name"] for item in dig(body, "ups.ups")} assert batteries == ({"apc", "cyberpower"} if populated else set()) assert dig(body, "llm.engines") == [], "neither fixture configures an engine" assert isinstance(dig(body, "recent_events"), list) assert isinstance(dig(body, "alerts"), list) # and the payload as a whole still carries every top-level section it always did assert set(body) >= {"ts", "system", "llm", "ups", "recent_events", "alerts"} # ---- alerts: the landing page's answer to "what is wrong" ------------------- # def test_summary_is_empty_of_alerts_when_everything_is_healthy(healthy_client) -> None: """`[]`, not a green banner: an empty list is the clearest possible all-clear.""" body = healthy_client.get("/api/summary").json() assert body["alerts"] == [] assert body["system"]["fleet"]["hosts_live"] == 1 def test_summary_raises_one_alert_for_the_host_that_stopped_answering(data_client) -> None: """One dead host is one line naming it, with the link to the page that explains it.""" body = data_client.get("/api/summary").json() down = [item for item in body["alerts"] if item["source"] == "driveripper"] assert len(down) == 1, "its stale payload must not add amber lines on top of the red one" assert down[0]["severity"] == "fail" assert down[0]["href"] == "/system" assert "unreachable" in down[0]["message"] def test_a_host_that_has_never_answered_is_still_a_row_in_the_fleet( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A dead agent is a red row in the fleet, not a fleet that quietly got smaller. `/api/system/snapshot` already stubs a job with no payload; `/api/summary` has to agree with it, or a target that never answered since startup leaves the table at "2/2 hosts" while the panel above says a third box is down (`AGENTS.md` invariants 3 and 4). """ with build_client(tmp_path, monkeypatch, host_targets=(Target("driveripper", DEAD_URL),)) as test_client: body = test_client.get("/api/summary").json() rows = body["system"]["hosts"] assert [row["name"] for row in rows] == ["driveripper"] assert rows[0]["unreachable"] is True assert rows[0]["cpu_usage_pct"] is None, "n/a, never a 0.0 nobody measured" assert body["system"]["fleet"]["hosts_total"] == 1 assert body["system"]["fleet"]["hosts_live"] == 0 # and the alert list stays exactly one line for it, not a row plus a stale payload assert [item["source"] for item in body["alerts"]] == ["driveripper"] def test_summary_alerts_are_worst_first_and_carry_the_contract(data_client) -> None: body = data_client.get("/api/summary").json() alerts_list = body["alerts"] assert alerts_list, "a dead host plus simulated UPS data cannot read as all-clear" for item in alerts_list: assert set(item) == {"id", "severity", "source", "message", "href", "at"} assert item["severity"] in ("warn", "fail") ranks = [0 if item["severity"] == "fail" else 1 for item in alerts_list] assert ranks == sorted(ranks), "fails before warns" assert any("simulat" in item["message"].lower() and item["href"] == "/ups" for item in alerts_list), "NUT_SIMULATE must be said out loud on the landing page" def test_summary_reuses_the_alert_builder_verbatim(data_client) -> None: """The endpoint is a thin call: what `alerts.build` says over the same state is the body.""" body = data_client.get("/api/summary").json() hub = data_client.app.state.ctx.hub # type: ignore[attr-defined] settings = data_client.app.state.ctx.settings # type: ignore[attr-defined] hosts = [job.state.payload for job in hub.jobs.values() if job.dashboard == "system" and job.state.payload] ups_payload = hub.jobs["nut"].state.payload expected = alerts.build(settings, hub.states(), hosts, [], ups_payload, []) assert [item["id"] for item in body["alerts"]] == [item["id"] for item in expected] def test_snapshot_payloads_carry_their_own_severity_sections(data_client) -> None: """/api/system/snapshot and /api/ups/snapshot ship the same verdicts the summary reuses.""" system = data_client.get("/api/system/snapshot").json() host = system["hosts"][0] assert set(host["cpu"]["severity"]) == {"usage_pct", "temp_c"} assert set(host["memory"]["severity"]) == {"used_pct"} assert set(host["disks"]["severity"]) == {"worst_used_pct"} assert "severity" not in host["gpus"][0], ( "a GPU verdict came back on /api/system/snapshot — phase 06 removed it" ) ups = data_client.get("/api/ups/snapshot").json() for card in ups["ups"]: assert set(card["severity"]) == {"charge_pct", "load_pct", "battery_runtime"} # numbers keep their own shape; severity is a sibling, never a replacement assert isinstance(card["numbers"], dict) and "charge_pct" in card["numbers"] # ---- unconfigured: say so, and do not dial -------------------------------- # def test_config_reports_every_source_as_off(client) -> None: body = client.get("/api/config").json() assert body["system"]["enabled"] is False assert body["system"]["targets"] == [] assert body["disks"] == {"enabled": False, "interval_seconds": 300.0, "timeout_seconds": 30.0}, ( "the drive-health page is configured exactly when the host agents are") assert body["llm"]["gufo"]["enabled"] is False assert body["llm"]["vllm"]["enabled"] is False assert body["ups"]["enabled"] is False assert body["ups"]["actions_enabled"] is False assert body["ranges"], "the page builds its window buttons from this list" def test_health_with_nothing_configured_is_not_a_failure(client) -> None: """Nothing configured is 'empty', not 'broken' — and the page must not go red.""" body = client.get("/api/health").json() assert body["status"] in ("ok", "empty") assert body["ok"] is True assert body["sources"] == [] def test_the_disk_source_has_its_own_health_record_per_host( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """`disks` is a source of its own, not a field of `system` (phase 09). Its own record is what gives the page its own dot and its own staleness, and it is also what makes `alerts.py` need a rule: two jobs read the same agent, so one dead host must still be one line on the overview (see `test_summary_raises_one_alert...`). """ with build_client(tmp_path, monkeypatch, host_targets=(Target("driveripper", DEAD_URL),)) as test_client: sources = {row["key"]: row for row in test_client.get("/api/health").json()["sources"]} assert "disks:driveripper" in sources, "no separate record, no separate dot" assert sources["disks:driveripper"]["dashboard"] == "disks" assert sources["disks:driveripper"]["label"] == "driveripper" assert sources["disks:driveripper"]["configured"] is True assert sources["host:driveripper"]["dashboard"] == "system", ( "the system job is still its own source: one slow SMART poll must not " "decide whether the host looks alive") def test_no_targets_means_no_disk_job_that_could_look_broken(client) -> None: """No host agents, no `disks` task at all — so nothing red for an unconfigured page.""" assert client.get("/api/health").json()["sources"] == [] @pytest.mark.parametrize("path", ["/api/system/snapshot", "/api/llm/snapshot", "/api/ups/snapshot", "/api/ups/commands"]) def test_unconfigured_source_is_404_configured_false(client, path) -> None: response = client.get(path) assert response.status_code == 404 assert response.json()["configured"] is False assert "detail" in response.json() def test_summary_still_answers_with_no_sources(client) -> None: body = client.get("/api/summary").json() assert body["system"]["enabled"] is False assert body["system"]["fleet"] is None assert body["llm"]["enabled"] is False assert body["ups"]["enabled"] is False def test_unknown_route_is_json_not_html(client) -> None: response = client.get("/api/nope") assert response.status_code == 404 assert response.headers["content-type"].startswith("application/json") assert "no such route" in response.json()["detail"] def test_an_upstream_failure_escaping_a_route_keeps_its_own_status(tmp_path: Path) -> None: """`collectors.UpstreamError` is the one error type a route may raise to say "upstream". The handler turns it into `{ok: false, detail}` with the collector's status (502, or 504 for a timeout) instead of a 500 traceback, and the message is the collector's already redacted sentence. Nothing dials here: the error is raised by the route under test. """ db = str(tmp_path / "upstream-error.sqlite3") store = SampleStore(db) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2), store) @app.get("/api/_boom") async def boom() -> None: raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504) with TestClient(app) as test_client: sign_in(test_client) response = test_client.get("/api/_boom") assert response.status_code == 504, "a timeout stays a timeout, not a 500" assert response.json() == {"ok": False, "detail": "driveripper: http://127.0.0.1:9120/snapshot " "timed out"} assert "no-store" in response.headers["cache-control"] store.close() # ---- tokens --------------------------------------------------------------- # def test_agent_tokens_never_reach_the_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NUT_SIMULATE", "") targets = [Target(name="driveripper", url="http://driveripper:9120", headers={"Authorization": "Bearer SEKRIT-TOKEN"})] db = str(tmp_path / "tokens.sqlite3") store = SampleStore(db) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2, host_targets=tuple(targets)), store) with TestClient(app) as client: sign_in(client) body = client.get("/api/config") assert "SEKRIT-TOKEN" not in body.text target = body.json()["system"]["targets"][0] assert target["name"] == "driveripper" assert "SEKRIT" not in target["url"] and "@" not in target["url"] # and a host that never answered stays in the list, marked, with the fleet card intact snapshot = wait_for_state(client, "/api/system/snapshot") assert snapshot["hosts"][0]["name"] == "driveripper" assert snapshot["hosts"][0].get("unreachable") is True assert snapshot["fleet"]["hosts_total"] == 1 assert snapshot["fleet"]["hosts_live"] == 0 assert snapshot["fleet"]["memory_total_bytes"] is None store.close() def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> None: """The second path a credential can take to a browser: the failure message. `parse_targets` moves a userinfo token out of the URL and into a bearer header, so the documented `HOST_TARGETS` path never puts one in `Target.url` — this is the backstop for any `Target` that does (a hand-built one, a future parser), and the message path is where it would matter: the poller keeps an `UpstreamError` string in the health record and logs it as an event, so a userinfo-bearing URL there would sit in SQLite and be served on `/api/events` forever (AGENTS.md invariant 6). Asserted on every endpoint a page reads, not just `/api/config`. """ db = str(tmp_path / "dead-host-token.sqlite3") store = SampleStore(db) settings = Settings( db_path=db, admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2, host_interval=0.05, host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),), ) with TestClient(create_app(settings, store)) as client: sign_in(client) deadline = time.time() + 10 while time.time() < deadline: health = client.get("/api/health").json() if health["sources"] and health["sources"][0]["error"]: break time.sleep(0.05) else: pytest.fail("the dead host never recorded an error") for path in ("/api/health", "/api/events", "/api/system/snapshot", "/api/disks/snapshot", "/api/summary"): body = client.get(path).text assert "SEKRIT-AGENT-TOKEN" not in body, f"{path} leaked the agent token" # and the outage is still legible — redaction is not the same as silence assert "driveripper" in client.get("/api/health").text assert "unreachable" in client.get("/api/events").text # the `disks` job dials the same token-bearing URL on its own cadence, so its # sentence is a second copy of the same risk: `fetch_snapshot` is the one place # either dashboard builds a failure message, and it uses the redacted URL disks_state = next(row for row in client.get("/api/health").json()["sources"] if row["key"] == "disks:driveripper") assert disks_state["error"] and "SEKRIT-AGENT-TOKEN" not in disks_state["error"] assert "127.0.0.1:1/snapshot" in disks_state["error"], "the failure still names where" store.close() def test_engine_urls_are_redacted_the_same_way_hosts_are(tmp_path: Path) -> None: """`GUFO_BASE_URL`/`VLLM_BASE_URL` go to the browser verbatim in `/api/config`, and unlike `HOST_TARGETS` nothing parses a userinfo token out of them into a header — so a URL that happens to carry credentials has to be redacted where it is served (invariant 6). """ db = str(tmp_path / "engine-redact.sqlite3") settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2, gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731", vllm_url="http://driveripper:8000") store = SampleStore(db) with TestClient(create_app(settings, store)) as client: sign_in(client) body = client.get("/api/config") assert "SEKRIT-ENGINE-TOKEN" not in body.text llm = body.json()["llm"] assert llm["gufo"]["base_url"] == "http://deskwork:8731" assert llm["vllm"]["base_url"] == "http://driveripper:8000" # untouched when clean store.close() def wait_for_state(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]: deadline = time.time() + timeout while time.time() < deadline: response = client.get(path) if response.status_code == 200: return response.json() time.sleep(0.1) pytest.fail(f"{path} never answered") def test_target_problems_are_surfaced_not_swallowed(client) -> None: body = client.get("/api/config").json() assert body["system"]["target_problems"] == [] # ---- the credential the server refuses to start without (phase 11) --------- # def test_no_verifier_and_no_seed_means_the_server_does_not_come_up(tmp_path: Path) -> None: """Fail closed, at the one place a mistake is unmissable: startup. Task 03 is what demands a session on every route; this is the half before it — a deployment with neither a stored `auth.verifier` row nor `DASH_ADMIN_PASSWORD` must not run open, so the lifespan raises instead of publishing a context, starting the pollers, or serving a page. Asserting that nothing was written is part of it: "refused" that seeded a row on the way out would leave the next start half-configured. """ db = str(tmp_path / "no-credential.sqlite3") store = SampleStore(db) app = create_app(Settings(db_path=db, admin_password=""), store) try: with pytest.raises(RuntimeError) as caught, TestClient(app): pytest.fail("the lifespan yielded with no admin credential") message = str(caught.value) assert "DASH_ADMIN_PASSWORD" in message, "the operator's fix is in the exit output" assert "auth.verifier" in message and "DELETE FROM meta" in message assert not hasattr(app.state, "ctx"), "nothing is published, so nothing can serve" assert store.get_meta("auth.verifier") is None assert store.get_meta("auth.session_secret") is None finally: store.close() def test_the_same_server_starts_once_the_environment_seeds_a_password(tmp_path: Path) -> None: """The other side of the gate, so the failure above cannot be satisfied by a broken app. With a seed the process comes up, the state hangs off the context (task 03 reads it on every request), and both rows are durable — which is also what lets the operator delete `DASH_ADMIN_PASSWORD` from the deployment after the first boot. """ db = str(tmp_path / "seeded.sqlite3") store = SampleStore(db) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2), store) try: with TestClient(app) as client: assert client.get("/api/health").status_code == 200 # public: the healthcheck state = app.state.ctx.auth assert state is not None assert state.seeded is True and state.env_ignored is False assert state.iterations == FAST_PBKDF2 assert auth.verify_password(ADMIN_PASSWORD, state.verifier) is True assert store.get_meta("auth.verifier") == state.verifier assert store.get_meta("auth.session_secret") == state.secret assert ADMIN_PASSWORD not in state.verifier + state.secret finally: store.close() # ---- pages ---------------------------------------------------------------- # @pytest.mark.parametrize("path,title", [ ("/", "overview"), ("/system", "system"), ("/disks", "disks"), ("/llm", "llm"), ("/ups", "ups"), ]) def test_pages_serve_and_declare_themselves(client, path, title) -> None: response = client.get(path) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert title in response.text.lower() def test_no_page_loads_anything_from_the_internet(client) -> None: """No CDN, no webfonts: this dashboard has to work with no route to the internet. `/login` is not in `MARKUP_PAGES` and cannot be: this `client` is signed in, a signed-in `GET /login` is a `303`, and following it would audit `/`'s bytes under the wrong name. The same fragments are checked against the real login page, unsigned, by `tests/test_auth_api.py::test_the_login_page_loads_nothing_from_the_internet`. """ for path in MARKUP_PAGES: html = client.get(path).text for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"): assert fragment not in html, f"{path} references {fragment}" def test_static_assets_are_served(client) -> None: for asset in ASSETS: assert client.get(asset).status_code == 200, asset # ---- /settings: the account page (phase 11 task 06) ----------------------- # # # `/settings` is the operator's own surface: the session this browser holds, a sign-out, and the # one form that changes the admin password. Its HTTP surface is tested twice on purpose — the # route-enumeration test in `tests/test_auth_api.py` proves nothing outside the public list # answers anonymously (which is how `/settings` is gated for free by being in `PAGES` in # `app.py`), and the assertions here pin the **page**: the form fields a password manager needs, # the outcome line that is a live region, the precedence sentence that stops the "I edited the # env file and nothing happened" support call, and the masthead link that is the only way in. def test_the_settings_page_carries_the_session_and_the_form(client) -> None: response = client.get(SETTINGS_PAGE) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] html = response.text # the session panel and its way out for fragment in ('id="sign-out"', 'id="session-user"', 'id="session-expires"', 'id="session-left"'): assert fragment in html, f"/settings has no {fragment}" # the change-password form: three fields, each labelled, each with the floor spelled out, # and the autocomplete values a password manager needs to fill the right one for fragment in ('id="current-password"', 'autocomplete="current-password"', 'id="new-password"', 'autocomplete="new-password"', 'id="confirm-password"', 'id="change-password"', '