Files
rune/tests/test_capture_failure.py
T
Nick 5458585e84 feat(fixtures): production failure capture with redaction (#157)
scripts/capture_failure.py: one command turns a raw failure payload into a
sanitised fixture under tests/fixtures/production/<class>/<id>/ plus a
manifest.json entry carrying the payload's sha256 (tamper-evidence for the
generated regression tests). Redaction reuses #122's filer patterns (query
creds, DSN userinfo, bearer, assignments) — copied with attribution because
the filer module ships on the failure-auto-filing branch; parity is pinned
on the filer's own test literals. After redaction an independent residual
scan aborts the capture loudly (exit 3, nothing persisted) when a
credential-shaped span survives — over-redaction is a shape witness, a
leaked fixture is an incident.
2026-09-07 19:33:46 +01:00

262 lines
11 KiB
Python

"""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/<class>/<id>/
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=<redacted>&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://<redacted>:<redacted>@db.example.com:5432/rune",
),
("redis://:s3cr3t@cache.example.com:6379/0", "redis://<redacted>:<redacted>@cache.example.com:6379/0"),
# Authorization / bearer headers
("Authorization: Bearer eyJhbGciOi.eyJzdWIi.SflKxwRJSMeK", "Authorization: Bearer <redacted>"),
# 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=<redacted>' and secret=<redacted>",
),
# 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 <class>/<id>/"
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=<redacted>" 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