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.
519 lines
19 KiB
Python
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
|