366 lines
15 KiB
Python
366 lines
15 KiB
Python
"""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)
|