"""Ledger-gap operational check tests (REQ-055 / dcy-011 FR-5, issue #11). PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN). dcy-020 (BAT-030): the detector keys off slot_transition_log sell fills (the `closed` status no longer exists). Inserts sold slots + outbox rows directly and asserts find_ledger_gap_slots detects only the gaps. """ from __future__ import annotations import subprocess import uuid from datetime import UTC, date, datetime from pathlib import Path import pytest from trex_core.adapters.persistence.ops_checks import ( collect_release_provenance, find_ledger_gap_slots, isolate_dirty_checkout_files, ) _DAY = date(2026, 6, 10) _TS = datetime(2026, 6, 10, 6, 0, tzinfo=UTC) def _insert_slot(conn, *, status, portfolio_id, symbol="005930"): sid = str(uuid.uuid4()) with conn.cursor() as cur: cur.execute( "INSERT INTO daily_slot (slot_id, status, symbol, portfolio_id) " "VALUES (%s, %s, %s, %s)", (sid, status, symbol, portfolio_id), ) return sid def _insert_sell_fill(conn, slot_id, *, reason="sell_filled", ts=_TS): with conn.cursor() as cur: cur.execute( "INSERT INTO slot_transition_log (slot_id, from_state, to_state, ts, reason, actor) " "VALUES (%s, 'pending_sell', 'inactive', %s, %s, 'execution_loop')", (slot_id, ts, reason), ) def _insert_outbox(conn, *, asset_id, order_id="O1", execution_id="E1"): with conn.cursor() as cur: cur.execute( "INSERT INTO ledger_outbox (correlation_id, asset_id, order_id, execution_id, " "side, qty, price) VALUES (%s, %s, %s, %s, 'sell', 1, 1000)", (f"{asset_id}:{order_id}:{execution_id}", asset_id, order_id, execution_id), ) def test_detects_only_gap_slots(pg_clean): import psycopg dsn = pg_clean with psycopg.connect(dsn, autocommit=True) as conn: gap_null = _insert_slot(conn, status="inactive", portfolio_id=None) # gap (no pf) _insert_sell_fill(conn, gap_null) gap_no_outbox = _insert_slot(conn, status="inactive", portfolio_id="PF-GAP") _insert_sell_fill(conn, gap_no_outbox) # gap (no outbox row) ok = _insert_slot(conn, status="inactive", portfolio_id="PF-OK") # not a gap _insert_sell_fill(conn, ok) _insert_outbox(conn, asset_id="PF-OK") never_sold = _insert_slot(conn, status="holding", portfolio_id=None) # no sell fill → ignored gaps = find_ledger_gap_slots(dsn, trading_date=_DAY) gap_ids = {str(g["slot_id"]) for g in gaps} assert gap_null in gap_ids assert gap_no_outbox in gap_ids assert ok not in gap_ids assert never_sold not in gap_ids def test_no_gaps_when_all_recorded(pg_clean): import psycopg dsn = pg_clean with psycopg.connect(dsn, autocommit=True) as conn: sid = _insert_slot(conn, status="inactive", portfolio_id="PF-1") _insert_sell_fill(conn, sid) _insert_outbox(conn, asset_id="PF-1") assert find_ledger_gap_slots(dsn, trading_date=_DAY) == [] def test_date_scope_filters_by_sell_fill_day(pg_clean): import psycopg dsn = pg_clean other_day_ts = datetime(2026, 6, 9, 6, 0, tzinfo=UTC) with psycopg.connect(dsn, autocommit=True) as conn: sid = _insert_slot(conn, status="inactive", portfolio_id="PF-OLD") _insert_sell_fill(conn, sid, ts=other_day_ts) assert find_ledger_gap_slots(dsn, trading_date=_DAY) == [] gaps = find_ledger_gap_slots(dsn, trading_date=date(2026, 6, 9)) assert {str(g["slot_id"]) for g in gaps} == {sid} def _init_git_repo(tmp_path: Path) -> tuple[Path, str]: repo = tmp_path / "repo" repo.mkdir() subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True, capture_output=True) (repo / "tracked.txt").write_text("v1\n", encoding="utf-8") subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True) head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() subprocess.run(["git", "checkout", "--detach", head], cwd=repo, check=True, capture_output=True) return repo, head def _provenance_kwargs(repo: Path, head: str) -> dict: broker_log = repo.parent / "broker.log" broker_log.write_text("event=order_submit token=must-not-leak\n", encoding="utf-8") bindir = repo.parent / "bin" bindir.mkdir(exist_ok=True) trex = bindir / "trex-core" rabbit = bindir / "rabbitmqctl" trex.write_text("#!/bin/sh\nprintf '{}\\n'\n", encoding="utf-8") rabbit.write_text("#!/bin/sh\nprintf 'consumer\\n'\n", encoding="utf-8") trex.chmod(0o755) rabbit.chmod(0o755) instance = "inst-a" return { "actor": "operator-a", "reason": "deploy", "instance": instance, "command_class": "manual-start", "operation_id": "op-1", "phase": "pre", "checkout_dir": repo, "observed_evidence": { "schema_version": 1, "target_id": "service-a", "target_kind": "compose-service", "image_id": "sha256:" + ("b" * 64), "image_digest": "sha256:" + ("a" * 64), "oci_revision": head, "observed_at": "2026-09-20T02:00:00Z", }, "runtime_files": ["tracked.txt"], "capture_commands": { "pending_slots": [ str(trex), "slots", "--status", "pending", "--instance", instance, "--json" ], "reconciliation_slots": [ str(trex), "slots", "--status", "all", "--instance", instance, "--json" ], "queue_consumers": [str(rabbit), "list_consumers"], }, "broker_log_path": broker_log, } def test_collect_release_provenance_passes_on_clean_checkout(tmp_path): repo, head = _init_git_repo(tmp_path) report = collect_release_provenance(**_provenance_kwargs(repo, head)) assert report["checkout"]["git_clean"] is True assert report["checkout"]["detached_head"] is True assert report["observed_evidence"]["source"] == "operator_observed" assert report["runtime_file_verification"][0]["matches_expected_revision"] is True assert report["gate"]["passed"] is True assert report["operation_id"] == "op-1" assert report["phase"] == "pre" rendered = str(report) assert "must-not-leak" not in rendered assert report["state_capture"]["broker_side_effect_logs"]["matches"][0]["event"] == "order_submit" def test_collect_release_provenance_blocks_attached_checkout(tmp_path): repo, head = _init_git_repo(tmp_path) subprocess.run(["git", "switch", "-c", "audit-attached"], cwd=repo, check=True, capture_output=True) report = collect_release_provenance(**_provenance_kwargs(repo, head)) assert report["checkout"]["git_clean"] is True assert report["checkout"]["detached_head"] is False assert report["state_capture"]["command_outputs"] == {} assert report["gate"]["passed"] is False def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_path): repo, old_head = _init_git_repo(tmp_path) (repo / "tracked.txt").write_text("v2\n", encoding="utf-8") subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "v2"], cwd=repo, check=True, capture_output=True) kwargs = _provenance_kwargs(repo, old_head) report = collect_release_provenance(**kwargs) assert report["checkout"]["git_clean"] is True assert report["checkout"]["head_revision"] != report["observed_evidence"]["oci_revision"] assert report["runtime_file_verification"][0]["matches_expected_revision"] is False assert report["gate"]["passed"] is False def test_isolate_dirty_checkout_files_copies_dirty_and_untracked(tmp_path): repo, _ = _init_git_repo(tmp_path) (repo / "tracked.txt").write_text("dirty\n", encoding="utf-8") (repo / "new.txt").write_text("new\n", encoding="utf-8") isolated = tmp_path / "isolated" manifest = isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated) entries = {entry["path"]: entry for entry in manifest["entries"]} assert set(entries) == {"tracked.txt", "new.txt"} assert all(entry["source_type"] == "regular" for entry in entries.values()) assert all(entry["matches"] is True for entry in entries.values()) snapshot = Path(manifest["isolate_dir"]) assert snapshot.parent == isolated assert (snapshot / "tracked.txt").read_text(encoding="utf-8") == "dirty\n" assert (snapshot / "new.txt").read_text(encoding="utf-8") == "new\n" assert (snapshot / "manifest.json").stat().st_mode & 0o222 == 0 @pytest.mark.parametrize("target_kind", ["absolute", "relative_escape"]) def test_isolate_dirty_checkout_files_rejects_symlink_escape_without_mutating_source( tmp_path, target_kind ): repo, _ = _init_git_repo(tmp_path) outside = tmp_path / "outside.txt" outside.write_text("outside\n", encoding="utf-8") link = repo / "escape-link" target = str(outside) if target_kind == "absolute" else "../outside.txt" link.symlink_to(target) isolated = tmp_path / "isolated" with pytest.raises(ValueError, match="symlink target escapes checkout"): isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated) assert link.is_symlink() assert link.readlink() == Path(target) assert outside.read_text(encoding="utf-8") == "outside\n" if isolated.exists(): assert not list(isolated.glob("*/manifest.json")) def test_isolate_dirty_checkout_files_preserves_internal_relative_symlink(tmp_path): repo, _ = _init_git_repo(tmp_path) nested = repo / "nested" nested.mkdir() (nested / "target.txt").write_text("target\n", encoding="utf-8") link = repo / "internal-link" link.symlink_to("nested/target.txt") manifest = isolate_dirty_checkout_files( checkout_dir=repo, isolate_dir=tmp_path / "isolated" ) entry = next(item for item in manifest["entries"] if item["path"] == "internal-link") copied = Path(manifest["isolate_dir"]) / "internal-link" assert entry["source_type"] == "symlink" assert entry["copied_type"] == "symlink" assert entry["matches"] is True assert copied.readlink() == Path("nested/target.txt") assert link.readlink() == Path("nested/target.txt") def test_collect_release_provenance_rejects_runtime_path_escape(tmp_path): repo, head = _init_git_repo(tmp_path) kwargs = _provenance_kwargs(repo, head) kwargs["runtime_files"] = ["../outside.txt"] report = collect_release_provenance(**kwargs) runtime = report["runtime_file_verification"][0] assert runtime["path_within_checkout"] is False assert runtime["matches_expected_revision"] is False assert report["gate"]["passed"] is False def test_collect_release_provenance_reports_git_failure_context(tmp_path): not_repo = tmp_path / "not-repo" not_repo.mkdir() trex = tmp_path / "trex-core" rabbit = tmp_path / "rabbitmqctl" with pytest.raises(RuntimeError, match="git command failed in checkout="): collect_release_provenance( actor="operator-a", reason="deploy", instance="inst-a", command_class="manual-start", operation_id="op-1", phase="pre", checkout_dir=not_repo, observed_evidence={ "schema_version": 1, "target_id": "service-a", "target_kind": "compose-service", "image_id": "sha256:" + ("b" * 64), "image_digest": "sha256:" + ("a" * 64), "oci_revision": "0" * 40, "observed_at": "2026-09-20T02:00:00Z", }, runtime_files=["tracked.txt"], capture_commands={ "pending_slots": [ str(trex), "slots", "--status", "pending", "--instance", "inst-a", "--json" ], "reconciliation_slots": [ str(trex), "slots", "--status", "all", "--instance", "inst-a", "--json" ], "queue_consumers": [str(rabbit), "list_consumers"], }, ) def test_dirty_checkout_blocks_before_capture_commands_run(tmp_path): repo, head = _init_git_repo(tmp_path) marker = tmp_path / "capture-ran" (repo / "tracked.txt").write_text("dirty\n", encoding="utf-8") kwargs = _provenance_kwargs(repo, head) for command in kwargs["capture_commands"].values(): executable = Path(command[0]) executable.write_text(f"#!/bin/sh\ntouch {marker}\n", encoding="utf-8") executable.chmod(0o755) report = collect_release_provenance(**kwargs) assert report["gate"]["passed"] is False assert report["gate"]["deployment_blocked"] is True assert report["state_capture"]["command_outputs"] == {} assert not marker.exists() def test_capture_failure_or_missing_broker_log_blocks_gate(tmp_path): repo, head = _init_git_repo(tmp_path) kwargs = _provenance_kwargs(repo, head) trex = Path(kwargs["capture_commands"]["pending_slots"][0]) trex.write_text("#!/bin/sh\nexit 3\n", encoding="utf-8") trex.chmod(0o755) kwargs["broker_log_path"] = tmp_path / "missing.log" report = collect_release_provenance(**kwargs) assert report["gate"]["passed"] is False assert report["gate"]["state_captures_succeeded"] is False assert report["gate"]["broker_log_captured"] is False def test_empty_broker_log_is_explicitly_captured_as_no_matching_events(tmp_path): repo, head = _init_git_repo(tmp_path) kwargs = _provenance_kwargs(repo, head) Path(kwargs["broker_log_path"]).write_bytes(b"") report = collect_release_provenance(**kwargs) broker = report["state_capture"]["broker_side_effect_logs"] assert broker["capture_status"] == "captured_empty" assert broker["file_sha256"] == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" assert broker["matches"] == [] assert report["gate"]["passed"] is True @pytest.mark.parametrize( ("field", "value"), [ ("observed_evidence", {}), ("runtime_files", []), ("capture_commands", {}), ("instance", "../escape"), ("reason", "password=must-not-be-recorded"), ], ) def test_required_provenance_inputs_fail_closed(tmp_path, field, value): repo, head = _init_git_repo(tmp_path) kwargs = _provenance_kwargs(repo, head) kwargs[field] = value with pytest.raises((TypeError, ValueError)): collect_release_provenance(**kwargs)