Files
UmbraAudio/tests/test_audio_controller_connect.py
B.D. Murphy 0909d050d9 Probe the route correctly: credentials, CSRF ordering, and a real dry run
The controller's ingest endpoint came up between runs and this tool misreported
it three more times, each in the same direction — naming the state it was in
when the check was written rather than the state it was reading.

* POST was probed without a credential, so a live route kept answering 401 and
  the verdict stayed AUTH_REQUIRED after auth was configured and T02 passed.
  The probe now sends whatever headers exist, because an unauthenticated probe
  answers "is there a door", never "does it open".

* The rule for reading a status was wrong twice. First it accepted only
  200/201/405 as routed, so a 401 on a live route read as absent. Then it was
  widened to "anything but 404", which would have read a CSRF 403 as a route:
  the controller checks CSRF in middleware before routing (app.py:246-248) and
  exempts only /api/node (app.py:89), so POST /api/compute/audio/import answers
  403 whether or not a route exists — and none does. Now: on a safe method, 404
  means unrouted and anything else means routed; on an unsafe method a 403 off
  an exempt prefix is discarded and recorded as csrf_masked_probe.

* T04 dropped csrf_masked_probe from its FAIL evidence, which is exactly the
  case where the next reader needs it. Found by the new tests, not by reading.

* T05 returned BLOCKED unconditionally, so the one test that mattered was the
  least willing to run. It now executes a real dry run against --bundle.
  dry_run short-circuits before store.commit(), so running it cannot write.

* authenticate() sent X-Node-Key, but require_node parses only "bearer "
  (routes_compute.py:477) — a valid key reported as rejected. It also probed
  /api/auth/me, which resolves a browser session, so no node credential could
  ever pass it. Now Bearer on /api/node/agent, and 403 there counts as
  authenticated-but-unscoped, since the scope check runs after resolution.

* _summarise reduced a plain-text body to "<str>", discarding "Internal Server
  Error" and every handler message — the detail worth reading.

* Four verdict names, including INGEST_ROUTED_NEEDS_RESTART, kept describing a
  restart that had already happened. _readiness now derives the name from
  evidence held by this run, and a real dry run outranks every transport
  observation: the current verdict is DRY_RUN_REJECTED.

Measured against the controller: migration 0008 applied (27 -> 31 tables, guard
proven to fire, idempotent on re-run), service restarted to PID 392511, endpoint
routed and parsing. The dry run executes and is rejected on content_hash,
because the HTTP path re-serializes records (json.dumps default separators,
ensure_ascii=True) while their own read_bundle() hashes the file bytes — the
same bundle yields d0011ae6 over HTTP and 21ce5687 on disk, and their validator
reports 0 problems on disk. Reported to the controller; bundle hash unchanged.

Also reported: routes_compute.py:1809 constructs InMemoryAudioImportStore,
whose commit() appends to a list, and no PostgreSQL adapter exists — so a
successful commit would return a uuid4() project_id while all four
translation_* tables stayed empty. Read from source, not reproduced.

30 new tests pin the routing rule, the CSRF ordering, the credential header,
and every verdict branch. Full suite 799 passed / 8 failed (pre-existing
voice_profile_manager + test_pronunciation). ruff and mypy clean.
2026-10-15 20:41:05 -07:00

519 lines
19 KiB
Python

"""Tests for the controller connectivity probe.
These exist because three consecutive runs of this tool reported the ingest
endpoint wrongly, each time with confidence and each time in the same
direction — describing the day the check was written rather than the state it
was reading. None of the faults were subtle:
1. Six guessed paths were probed while the controller's route table was one SSH
command away, and 404 on a guess was read as absence.
2. Probing used GET only. The route is POST-only, so it answered GET with 404
and looked missing for a whole session.
3. After the route came up and answered 401, that was read as a dead end rather
than as a live route refusing an unauthenticated request.
Each is pinned here. The fake client scripts exact status codes so the
assertions are about the *interpretation* rule — which is where the faults
were — and not about a live controller.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import pytest
REPO = Path(__file__).resolve().parent.parent
TOOL_PATH = REPO / "scripts" / "audio_controller_connect.py"
NODE_IMPORT = "/api/node/audio/import"
def _load_tool() -> Any:
spec = importlib.util.spec_from_file_location("audio_controller_connect", TOOL_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
tool = _load_tool()
class FakeClient:
"""Stands in for the HTTP client, returning scripted status codes.
Keyed by (method, path). Any path not scripted answers 404, which is what a
real controller does for an unrouted path, and is the case that must be
distinguished from every routed one.
"""
def __init__(self, scripted: dict[tuple[str, str], int],
bodies: dict[tuple[str, str], Any] | None = None) -> None:
self.scripted = scripted
self.bodies = bodies or {}
self.calls: list[tuple[str, str, dict[str, str]]] = []
def request(self, method: str, path: str, *, body: Any = None,
headers: dict[str, str] | None = None,
phase: str = "probe") -> tuple[int, Any, int]:
self.calls.append((method, path, dict(headers or {})))
status = self.scripted.get((method, path), 404)
return status, self.bodies.get((method, path)), 1.0
# ---------------------------------------------------------------------------
# Finding an endpoint
# ---------------------------------------------------------------------------
def test_everything_404_is_reported_as_unconfirmed_not_missing() -> None:
"""Bug 1: a guessed path that 404s is evidence about the guess."""
result = tool.t04_ingest_exists(FakeClient({}))
assert result.status == "FAIL"
assert result.evidence["finding"] == "UNCONFIRMED_INGEST_ENDPOINT"
assert "route table" in result.detail
assert "guessed" in result.detail
def test_get_only_404_with_post_401_is_routed() -> None:
"""Bug 2: a POST-only route answers GET with 404. That is not absence."""
result = tool.t04_ingest_exists(FakeClient({
("GET", NODE_IMPORT): 404,
("POST", NODE_IMPORT): 401,
}))
assert result.status == "PASS"
assert result.evidence["routed"] == f"POST {NODE_IMPORT}"
assert result.evidence["auth_gated"] is True
assert result.evidence["reachable"] is False
def test_405_counts_as_routed() -> None:
"""The original signal: path in the table, wrong method.
405 is routing, not authentication — it says the path matched a route
registered for another method, so it must not be reported as an auth gate.
"""
result = tool.t04_ingest_exists(FakeClient({
("GET", NODE_IMPORT): 404,
("POST", NODE_IMPORT): 405,
}))
assert result.status == "PASS"
assert result.evidence["routed"] == f"POST {NODE_IMPORT}"
assert result.evidence["auth_gated"] is False
assert result.evidence["handler_error"] is False
assert result.evidence["reachable"] is False
assert "another method" in result.detail
def test_post_401_on_a_node_path_is_not_a_dead_end() -> None:
"""Bug 3: require_node runs inside the route, so 401 proves it resolved."""
result = tool.t04_ingest_exists(FakeClient({("POST", NODE_IMPORT): 401}))
assert result.status == "PASS"
assert result.evidence["auth_gated"] is True
assert "credential doing its job" in result.detail
assert "not the path going missing" in result.detail
def test_csrf_403_on_a_non_node_path_is_not_routed() -> None:
"""A 403 from CSRF middleware runs before routing and proves nothing.
The controller checks CSRF in middleware (app.py:246-248) and exempts only
``/api/node`` (app.py:89). So POST /api/compute/audio/import answers 403
whether or not a route exists — there is in fact no such route. Counting it
would report a live ingest path that does not exist.
"""
client = FakeClient({
("POST", "/api/compute/audio/import"): 403,
("GET", "/api/compute/audio/import"): 404,
("GET", NODE_IMPORT): 404,
("POST", NODE_IMPORT): 404,
})
result = tool.t04_ingest_exists(client)
assert result.status == "FAIL", "a pre-routing 403 must not count as routed"
assert result.evidence["finding"] == "UNCONFIRMED_INGEST_ENDPOINT"
# The discounted probe must survive into the report, otherwise the next
# reader sees only 404s and re-litigates the same false positive.
masked = result.evidence["csrf_masked_probe"]
assert "middleware that runs before routing" in masked
assert "/api/compute/audio/import" in masked
per_path = result.evidence["probed"]["/api/compute/audio/import"]
assert "csrf_token_missing" in per_path["POST_note"]
assert per_path["POST"] == 403
assert per_path["GET"] == 404
def test_403_on_a_node_path_is_routed_because_that_prefix_is_csrf_exempt() -> None:
result = tool.t04_ingest_exists(FakeClient({("POST", NODE_IMPORT): 403}))
assert result.status == "PASS"
assert result.evidence["auth_gated"] is True
def test_handler_error_is_distinguished_from_absent() -> None:
result = tool.t04_ingest_exists(FakeClient({("POST", NODE_IMPORT): 500}))
assert result.status == "PASS"
assert result.evidence["handler_error"] is True
assert result.evidence["auth_gated"] is False
assert "handler is reachable" in result.detail
def test_credential_is_forwarded_to_the_probe() -> None:
"""Probing without the credential made a live route look permanently gated."""
client = FakeClient({("POST", NODE_IMPORT): 401})
tool.t04_ingest_exists(client, {"Authorization": "Bearer <key>"})
assert client.calls, "no request was made"
assert all(h.get("Authorization") for _, _, h in client.calls)
def test_probe_without_credential_omits_headers_cleanly() -> None:
client = FakeClient({("POST", NODE_IMPORT): 401})
result = tool.t04_ingest_exists(client)
assert result.status == "PASS"
assert client.calls[0][2] == {}
def test_first_routed_path_in_candidate_order_wins() -> None:
"""The real route is first in INGEST_CANDIDATES and must be the one named."""
client = FakeClient({
("POST", NODE_IMPORT): 401,
("POST", "/api/compute/audio/import"): 403,
("GET", "/api/compute/audio/import"): 404,
})
result = tool.t04_ingest_exists(client)
assert result.evidence["routed"] == f"POST {NODE_IMPORT}"
# ---------------------------------------------------------------------------
# Readiness verdicts
# ---------------------------------------------------------------------------
def _readiness(evidence: dict[str, Any] | None, **kwargs: Any) -> tuple[str, list[str]]:
"""Build a routed result from a bare evidence dict and ask for a verdict."""
routed = None
if evidence is not None:
routed = tool.Result("T04", "Ingest endpoint exists", "PASS", "detail", evidence)
kwargs.setdefault("missing_ingest", evidence is None)
return tool._readiness(routed, **kwargs)
def test_readiness_names_unconfirmed_path_without_claiming_absence() -> None:
verdict, rationale = _readiness(None)
assert verdict == "INGEST_PATH_NOT_CONFIRMED"
assert any("UNCONFIRMED" in line for line in rationale)
def test_readiness_auth_required_is_separate_from_needs_restart() -> None:
verdict, rationale = _readiness({
"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
"auth_gated": True, "handler_error": False,
})
assert verdict == "INGEST_ROUTED_AUTH_REQUIRED"
assert any("401/403" in line for line in rationale)
def test_readiness_handler_error_is_not_a_restart_request() -> None:
"""After the restart happened, the verdict must stop asking for one."""
verdict, _ = _readiness({
"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
"auth_gated": False, "handler_error": True,
})
assert verdict == "INGEST_ROUTED_HANDLER_ERROR"
assert "RESTART" not in verdict
def test_readiness_ready_only_when_serving() -> None:
verdict, _ = _readiness({
"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
"auth_gated": False, "handler_error": False,
})
assert verdict == "READY_FOR_PUSH"
def test_readiness_routed_but_not_serving_is_named() -> None:
verdict, _ = _readiness({
"routed": f"POST {NODE_IMPORT}", "status": 405, "reachable": False,
"auth_gated": False, "handler_error": False,
})
assert verdict == "INGEST_ROUTED_NOT_SERVING"
def test_readiness_422_means_parsing_not_broken() -> None:
"""A 422 against an empty probe body is the schema working, not a fault."""
verdict, rationale = _readiness({
"routed": f"POST {NODE_IMPORT}", "status": 422, "reachable": False,
"auth_gated": False, "handler_error": False, "schema_rejected": True,
})
assert verdict == "INGEST_ROUTED_AWAITING_BUNDLE"
assert any("parsing" in line for line in rationale)
def test_a_real_dry_run_failure_outranks_transport_observations() -> None:
"""Once T05 has run, connectivity is settled and the data is the story."""
t05 = tool.Result("T05", "Dry-run validation", "FAIL",
"dry run rejected (500); content_hash mismatch", {})
verdict, rationale = _readiness(
{"routed": f"POST {NODE_IMPORT}", "status": 422, "reachable": False,
"auth_gated": False, "handler_error": False, "schema_rejected": True},
missing_ingest=False, t05=t05)
assert verdict == "DRY_RUN_REJECTED"
assert any("content_hash mismatch" in line for line in rationale)
assert any("Nothing was pushed" in line for line in rationale)
def test_a_passing_dry_run_still_requires_confirmation() -> None:
t05 = tool.Result("T05", "Dry-run validation", "PASS",
"controller accepted the bundle", {"status": "dry_run"})
verdict, rationale = _readiness(
{"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
"auth_gated": False, "handler_error": False},
missing_ingest=False, t05=t05)
assert verdict == "DRY_RUN_OK_CONFIRMATION_REQUIRED"
assert any("not an import" in line for line in rationale)
assert any("Nothing has been pushed" in line for line in rationale)
def test_dry_run_blocked_does_not_override_transport_state() -> None:
"""A bundle that was never supplied must not be reported as a rejection."""
t05 = tool.Result("T05", "Dry-run validation", "BLOCKED",
"no --bundle given", {"ran": False})
verdict, _ = _readiness(
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
"auth_gated": True, "handler_error": False},
missing_ingest=False, t05=t05)
assert verdict == "INGEST_ROUTED_AUTH_REQUIRED"
def test_no_verdict_still_claims_a_restart_after_the_restart_happened() -> None:
"""INGEST_ROUTED_NEEDS_RESTART survived the restart it was describing."""
seen = set()
for evidence in (
None,
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
"auth_gated": True, "handler_error": False},
{"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
"auth_gated": False, "handler_error": True},
{"routed": f"POST {NODE_IMPORT}", "status": 405, "reachable": False,
"auth_gated": False, "handler_error": False},
{"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
"auth_gated": False, "handler_error": False},
):
verdict, _ = _readiness(evidence)
seen.add(verdict)
assert "INGEST_ROUTED_NEEDS_RESTART" not in seen
def test_every_readiness_branch_carries_the_standing_facts() -> None:
"""The bundle story and the sequenced tests are true in every state."""
for evidence in (
None,
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
"auth_gated": True, "handler_error": False},
{"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
"auth_gated": False, "handler_error": True},
{"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
"auth_gated": False, "handler_error": False},
):
_, rationale = _readiness(evidence)
assert any("metadata bundle" in line for line in rationale)
assert any("T05-T08 are sequenced" in line for line in rationale)
def test_every_non_ready_verdict_says_nothing_was_pushed() -> None:
"""Only READY_FOR_PUSH may omit it; every other state must state it plainly."""
non_ready = (
None,
{"routed": f"POST {NODE_IMPORT}", "status": 401, "reachable": False,
"auth_gated": True, "handler_error": False},
{"routed": f"POST {NODE_IMPORT}", "status": 500, "reachable": False,
"auth_gated": False, "handler_error": True},
{"routed": f"POST {NODE_IMPORT}", "status": 405, "reachable": False,
"auth_gated": False, "handler_error": False},
)
for evidence in non_ready:
verdict, rationale = _readiness(evidence)
assert verdict != "READY_FOR_PUSH"
assert any("pushed" in line for line in rationale), (
f"{verdict} does not say whether anything was pushed"
)
verdict, rationale = _readiness({
"routed": f"POST {NODE_IMPORT}", "status": 200, "reachable": True,
"auth_gated": False, "handler_error": False,
})
assert verdict == "READY_FOR_PUSH"
assert not any("Nothing was pushed" in line for line in rationale)
# ---------------------------------------------------------------------------
# Credential handling
# ---------------------------------------------------------------------------
def test_node_key_is_sent_as_bearer_not_x_node_key(monkeypatch: pytest.MonkeyPatch,
tmp_path: Path) -> None:
"""require_node parses only 'bearer ' (routes_compute.py:477).
Sending X-Node-Key meant a perfectly valid key came back rejected.
"""
monkeypatch.setenv("UMBRA_AUDIO_NODE_KEY", "<secret>")
monkeypatch.delenv("UMBRA_AUDIO_API_TOKEN", raising=False)
log = tool.SyncLog(tmp_path / "log.jsonl")
client = tool.Client.__new__(tool.Client)
headers, how = client.authenticate(log)
assert how == "node_bearer"
assert "X-Node-Key" not in headers
assert headers["Authorization"] == "Bearer <secret>"
def test_service_token_falls_back_when_no_node_key(monkeypatch: pytest.MonkeyPatch,
tmp_path: Path) -> None:
monkeypatch.delenv("UMBRA_AUDIO_NODE_KEY", raising=False)
monkeypatch.setenv("UMBRA_AUDIO_API_TOKEN", "<token>")
log = tool.SyncLog(tmp_path / "log.jsonl")
client = tool.Client.__new__(tool.Client)
headers, how = client.authenticate(log)
assert how == "service_token_bearer"
assert headers["Authorization"] == "Bearer <token>"
def test_no_credential_configured_returns_empty_headers(monkeypatch: pytest.MonkeyPatch,
tmp_path: Path) -> None:
monkeypatch.delenv("UMBRA_AUDIO_NODE_KEY", raising=False)
monkeypatch.delenv("UMBRA_AUDIO_API_TOKEN", raising=False)
log = tool.SyncLog(tmp_path / "log.jsonl")
client = tool.Client.__new__(tool.Client)
headers, how = client.authenticate(log)
assert headers == {}
assert how == "none"
def test_auth_log_records_a_mechanism_but_never_the_key(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""The key goes in an env var and out in a header, never into a log line."""
secret = "YMcrD3QlREDACTEDTESTKEY"
monkeypatch.setenv("UMBRA_AUDIO_NODE_KEY", secret)
monkeypatch.delenv("UMBRA_AUDIO_API_TOKEN", raising=False)
path = tmp_path / "log.jsonl"
log = tool.SyncLog(path)
client = tool.Client.__new__(tool.Client)
client.authenticate(log)
log.write("discovery", "example", endpoint="/api/node/agent")
text = path.read_text(encoding="utf-8")
assert secret not in text
entries = [json.loads(line) for line in text.splitlines() if line.strip()]
assert any(e.get("action") == "try_node_key" for e in entries)
# ---------------------------------------------------------------------------
# Dry-run safety
# ---------------------------------------------------------------------------
def test_dry_run_is_blocked_without_a_bundle(tmp_path: Path) -> None:
result = tool.t05_dry_run(FakeClient({}), None, {})
assert result.status == "BLOCKED"
assert result.evidence["ran"] is False
def test_dry_run_is_blocked_on_an_incomplete_bundle(tmp_path: Path) -> None:
(tmp_path / "bundle.json").write_text("{}", encoding="utf-8")
result = tool.t05_dry_run(FakeClient({}), str(tmp_path), {})
assert result.status == "BLOCKED"
assert "manifest.jsonl" in result.detail
def test_dry_run_never_claims_to_have_written(tmp_path: Path) -> None:
"""Whatever happens, a dry run must not report a write."""
(tmp_path / "bundle.json").write_text(
json.dumps({"bundle_format_version": 1,
"content_hash": {"value": "0" * 64}}), encoding="utf-8")
(tmp_path / "manifest.jsonl").write_text(
json.dumps({"id": "b-1", "kind": "book"}) + "\n", encoding="utf-8")
for status in (200, 400, 422, 500):
client = FakeClient({("POST", NODE_IMPORT): status},
bodies={("POST", NODE_IMPORT): {"status": "dry_run"}})
result = tool.t05_dry_run(client, str(tmp_path), {})
assert result.evidence["dry_run"] is True
assert result.evidence["wrote"] is False
assert result.evidence["confirm"] is None
assert result.evidence["records_sent"] == 1
def test_dry_run_passes_only_on_a_2xx(tmp_path: Path) -> None:
(tmp_path / "bundle.json").write_text(
json.dumps({"bundle_format_version": 1,
"content_hash": {"value": "0" * 64}}), encoding="utf-8")
(tmp_path / "manifest.jsonl").write_text(
json.dumps({"id": "b-1", "kind": "book"}) + "\n", encoding="utf-8")
passed = tool.t05_dry_run(
FakeClient({("POST", NODE_IMPORT): 200},
bodies={("POST", NODE_IMPORT): {"status": "dry_run",
"content_hash": "a" * 64}}),
str(tmp_path), {})
failed = tool.t05_dry_run(FakeClient({("POST", NODE_IMPORT): 500}), str(tmp_path), {})
assert passed.status == "PASS"
assert passed.evidence["content_hash"] == "a" * 64
assert failed.status == "FAIL"
assert failed.evidence["status_code"] == 500