"""Tests for scripts/capture_failure.py — production failure → sanitised fixture (#157). The capture pipeline (plan 2026-09-07-157): a payload file + a failure class + a source become a redacted fixture under tests/fixtures/production/// plus a manifest entry carrying the payload's sha256 (tamper-evidence — the generated regression tests assert it). Redaction reuses #122's filer patterns; after redaction an INDEPENDENT residual scan must abort the capture loudly rather than persist a leaked fixture (plan design decision 1: when in doubt, redact — a leaked fixture is an incident). """ from __future__ import annotations import hashlib import importlib.util import json from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] def _load_module(): spec = importlib.util.spec_from_file_location("capture_failure", ROOT / "scripts" / "capture_failure.py") assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module cf = _load_module() # --------------------------------------------------------------------------- # Redaction parity with #122's filer (shared cases) # --------------------------------------------------------------------------- # These literals are the shared redaction contract with # src/rune/failure_auto_filer.py::redact (commit 9105ca8, #122). They are # copied verbatim from that module's tests so drift on either side is caught: # capture_failure.redact must behave identically on every case the filer # defines. (The filer module itself is not importable from this branch — it # ships via the failure-auto-filing branch — so parity is pinned by literals. # When that branch merges, switch to importing it directly and asserting # equality on these cases.) PARITY_CASES = [ # query-string credential params ( "GET https://forge.example.com/api?token=supersecret&page=2 failed", "GET https://forge.example.com/api?token=&page=2 failed", ), # DSN userinfo (postgres/redis/mysql, with and without username) ( "connect failed for postgres://alice:hunter2@db.example.com:5432/rune", "connect failed for postgres://:@db.example.com:5432/rune", ), ("redis://:s3cr3t@cache.example.com:6379/0", "redis://:@cache.example.com:6379/0"), # Authorization / bearer headers ("Authorization: Bearer eyJhbGciOi.eyJzdWIi.SflKxwRJSMeK", "Authorization: Bearer "), # assignment forms (note: the trailing quote of a quoted value survives — # the span replaces only the credential value, exactly as the filer does) ( "login failed with password='hunter2' and secret=abcdef1234", "login failed with password=' and secret=", ), # clean text is a no-op ("plain error, nothing sensitive", "plain error, nothing sensitive"), ] @pytest.mark.parametrize(("raw", "expected"), PARITY_CASES, ids=lambda v: v[:40]) def test_redact_matches_filer_on_shared_cases(raw: str, expected: str) -> None: assert cf.redact(raw) == expected def test_redact_keeps_error_shape_visible() -> None: out = cf.redact("RuntimeError: connection to https://user:pass@forge.example.com refused (port 443)") assert out.startswith("RuntimeError: ") assert "refused (port 443)" in out # non-credential detail survives # --------------------------------------------------------------------------- # Capture: payload → redacted fixture + manifest entry # --------------------------------------------------------------------------- def _capture(tmp_path: Path, payload: str, failure_class: str = "ci-crash", source: str = "manual", **kwargs): payload_file = tmp_path / "payload.txt" payload_file.write_text(payload, encoding="utf-8") fixtures_root = tmp_path / "fixtures" return cf.capture( payload_file, failure_class=failure_class, source=source, fixtures_root=fixtures_root, **kwargs, ) def test_capture_writes_redacted_fixture_and_manifest_entry(tmp_path: Path) -> None: raw = "broker boom: GET https://forge.example.com/api?token=supersecret&page=2 failed" entry = _capture(tmp_path, raw, failure_class="ci-crash", source="ci") # derived slug: class + first words of the (redacted) first line assert entry["id"].startswith("ci-crash-broker-boom") fixture_file = tmp_path / "fixtures" / "ci-crash" / entry["id"] / "payload.json" assert fixture_file.exists(), "fixture payload.json must be written under //" stored = json.loads(fixture_file.read_text(encoding="utf-8")) assert "supersecret" not in stored["payload"], "credential must be redacted in the stored payload" assert "token=" in stored["payload"] assert stored["failure_class"] == "ci-crash" assert stored["source"] == "ci" assert stored["date"] # capture date recorded assert stored["original_shape"] == "text" assert stored["redactions_applied"] >= 1 assert "query-creds" in stored["redaction_kinds"] # manifest entry carries the sha256 of the stored payload text manifest = json.loads((tmp_path / "fixtures" / "manifest.json").read_text(encoding="utf-8")) ids = [e["id"] for e in manifest["fixtures"]] assert entry["id"] in ids recorded = next(e for e in manifest["fixtures"] if e["id"] == entry["id"]) assert recorded["sha256"] == hashlib.sha256(stored["payload"].encode("utf-8")).hexdigest() assert recorded["class"] == "ci-crash" assert recorded["source"] == "ci" def test_capture_records_json_original_shape(tmp_path: Path) -> None: raw = '{"error": "KeyError: False", "step": "convergence"}' _capture(tmp_path, raw, fixture_id="json-shape-case") fixture_file = tmp_path / "fixtures" / "ci-crash" / "json-shape-case" / "payload.json" stored = json.loads(fixture_file.read_text(encoding="utf-8")) assert stored["original_shape"] == "json" assert stored["payload"] == raw # JSON payloads are stored verbatim (redaction still applied) def test_capture_is_idempotent_on_recapture(tmp_path: Path) -> None: raw = "boom with password=hunter2 in the log" _capture(tmp_path, raw, fixture_id="same-id") _capture(tmp_path, raw, fixture_id="same-id") manifest = json.loads((tmp_path / "fixtures" / "manifest.json").read_text(encoding="utf-8")) assert [e["id"] for e in manifest["fixtures"]].count("same-id") == 1, "re-capture updates, never duplicates" def test_capture_clean_payload_records_zero_redactions(tmp_path: Path) -> None: entry = _capture(tmp_path, "harmless failure text", fixture_id="clean-case") assert entry["redactions_applied"] == 0 assert entry["redaction_kinds"] == [] # --------------------------------------------------------------------------- # Residual-secret abort (the loud backstop) # --------------------------------------------------------------------------- def test_residual_scan_flags_surviving_credentials() -> None: # A credential shape the redaction patterns cannot clean (embedded quote # defeats the assignment span) must be FLAGGED by the independent scan. assert cf.residual_secrets("config had secret=ab'cd embedded") != [] assert cf.residual_secrets("password=abcd") != [] assert cf.residual_secrets("https://x.example/?key=zzz") != [] assert cf.residual_secrets("Authorization: Bearer eyJ.abc") != [] assert cf.residual_secrets("postgres://alice:hunter2@db/x") != [] def test_residual_scan_is_quiet_on_redacted_text() -> None: redacted = cf.redact( "token=supersecret&page=2 postgres://alice:hunter2@db/x Authorization: Bearer eyJ.abc password=hunter2" ) assert cf.residual_secrets(redacted) == [], "redaction output must pass the residual scan" def test_capture_aborts_loudly_on_residual_secret(tmp_path: Path) -> None: raw = "config had secret=ab'cd embedded" # survives redaction with pytest.raises(cf.ResidualSecretError) as excinfo: _capture(tmp_path, raw, fixture_id="leaky-case") assert "secret" in str(excinfo.value).lower(), "abort message must name the residual secret" # nothing persisted: no fixture directory, no manifest entry assert not (tmp_path / "fixtures" / "ci-crash" / "leaky-case").exists() manifest_file = tmp_path / "fixtures" / "manifest.json" if manifest_file.exists(): manifest = json.loads(manifest_file.read_text(encoding="utf-8")) assert "leaky-case" not in [e["id"] for e in manifest["fixtures"]] def test_capture_rejects_unknown_source(tmp_path: Path) -> None: with pytest.raises(ValueError): _capture(tmp_path, "boom", source="twitter") def test_capture_rejects_unsafe_class_or_id(tmp_path: Path) -> None: with pytest.raises(ValueError): _capture(tmp_path, "boom", failure_class="../escape") with pytest.raises(ValueError): _capture(tmp_path, "boom", fixture_id="../escape") # --------------------------------------------------------------------------- # CLI surface # --------------------------------------------------------------------------- def test_cli_captures_and_exits_zero(tmp_path: Path, capsys) -> None: payload_file = tmp_path / "raw.txt" payload_file.write_text("boom: token=supersecret leaked", encoding="utf-8") fixtures_root = tmp_path / "fixtures" code = cf.main( [ str(payload_file), "--class", "ci-crash", "--source", "ci", "--id", "cli-case", "--fixtures-root", str(fixtures_root), ] ) assert code == 0 assert (fixtures_root / "ci-crash" / "cli-case" / "payload.json").exists() out = capsys.readouterr().out assert "cli-case" in out def test_cli_aborts_nonzero_on_residual_secret(tmp_path: Path, capsys) -> None: payload_file = tmp_path / "raw.txt" payload_file.write_text("config had secret=ab'cd embedded", encoding="utf-8") code = cf.main( [ str(payload_file), "--class", "ci-crash", "--source", "manual", "--id", "leaky-cli", "--fixtures-root", str(tmp_path / "fixtures"), ] ) assert code == cf.EXIT_RESIDUAL_SECRET, "residual secret must abort with a distinct exit code" assert "secret" in capsys.readouterr().err.lower() def test_cli_missing_payload_file_exits_nonzero(tmp_path: Path) -> None: code = cf.main( [ str(tmp_path / "does-not-exist.txt"), "--class", "ci-crash", "--source", "manual", "--fixtures-root", str(tmp_path / "fixtures"), ] ) assert code == cf.EXIT_USAGE