"""v2.4 tests for design review V1-V8 (beyond the original repro tests in test_review_r1.py).""" import json, os, shutil, sys import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import aicep_sub from aicep_common import Ledger, Reject, canonical, b64 from aicep_pub import publish from aicep_sub import Held, ingest from test_delivery import w, send, OBJ # noqa: F401 from test_tamper import env, pub, sub, resign, NOW # noqa: F401 def skid_seq(d): m = json.load(open(os.path.join(d, "manifest.json"))); return m["sender_key_id"], m["seq"] # V1: a forged manifest sitting in holding/ is dead-lettered, never alerted on def test_forged_batch_in_holding_goes_to_dlq(w): res = send(w); d = res["batch_dir"] files = {n: open(os.path.join(d, n), "rb").read() for n in os.listdir(d)} m = json.loads(files["manifest.json"]); m["seq"] = 50; files["manifest.json"] = canonical(m) hp = os.path.join(w["bus"].root, "c1", "holding", "forged-h"); os.makedirs(hp) for n, b in files.items(): open(os.path.join(hp, n), "wb").write(b) r = w["recv"](); w["clk"].t += 4000 out = r.run_once() assert out["forged-h"] == "dlq" assert not any(n["event"] == "gap_timeout" for n in r.notices) # V2b: commit merges, never rolls back last_seq written by another receiver def test_commit_state_never_rolls_back_last_seq(w): r = w["recv"]() aicep_sub.atomic_write(r.sub_state, json.dumps({"last_seq": {"k": 9, "o": 1}}).encode()) tmp = os.path.join(r.dir, "sub_state.x.tmp") aicep_sub.atomic_write(tmp, json.dumps({"last_seq": {"k": 3, "o": 2}}).encode()) r._commit_state(tmp) assert json.load(open(r.sub_state))["last_seq"] == {"k": 9, "o": 2} # V2c: losing the pub->processing rename race skips the batch instead of aborting fetch def test_fetch_rename_race_skips(w, monkeypatch): send(w); send(w) real = os.rename; calls = [] def flaky(a, b): calls.append(a) if len(calls) == 1: raise FileNotFoundError(a) return real(a, b) monkeypatch.setattr(os, "rename", flaky) got = w["bus"].fetch("c1", 10, 300, "r1", folders={"agent-a"}) assert len(got) == 1 # V3: ingest-level downgrade guard (CLI path too) def test_ingest_rejects_v1_after_chain(env): d1 = pub(env, prev_batch_id="genesis"); sub(env, d1) d2 = pub(env) # no prev_batch_id with pytest.raises(Reject, match="chain downgrade"): sub(env, d2) def test_ingest_rejects_wrong_prev(env): d1 = pub(env, prev_batch_id="genesis"); sub(env, d1) d2 = pub(env, prev_batch_id="not-the-last") with pytest.raises(Reject, match="chain break"): sub(env, d2) # V4: missing size_padded is a schema reject def test_missing_size_padded_rejected(env): d = pub(env); resign(env, d, lambda m: [f.pop("size_padded") for f in m["files"]]) with pytest.raises(Reject, match="schema"): sub(env, d) # V5: receiver routes a signed-but-malformed manifest to the DLQ (no exception, no poison loop) def test_receiver_non_int_size_padded_to_dlq(w): res = send(w); d = os.path.join(w["bus"].root, "c1", "pub", "agent-a", res["batch_id"]) mp = os.path.join(d, "manifest.json"); m = json.load(open(mp)) for f in m["files"]: f["size_padded"] = "big" mb = canonical(m); open(mp, "wb").write(mb); open(mp + ".sig", "w").write(b64(w["sk"].sign(mb))) assert w["recv"]().run_once()[res["batch_id"]] == "dlq" def test_non_int_size_padded_reject(env): d = pub(env); resign(env, d, lambda m: [f.__setitem__("size_padded", "4096") for f in m["files"]]) with pytest.raises(Reject): sub(env, d) # V6: expected_head mismatch and malformed lines fail verify (no exception) def test_ledger_expected_head_and_malformed(tmp_path): L = Ledger(str(tmp_path / "l.jsonl")); h1 = L.append("a", t=1); L.append("b", t=2) assert L.verify() and L.verify(expected_head=h1) assert not L.verify(expected_head="f" * 64) open(L.path, "a").write("{not json\n") assert L.verify() is False # V7: created in the future (beyond skew) is rejected def test_future_created_rejected(env): d = pub(env); resign(env, d, lambda m: m.__setitem__("created", NOW + 10_000)) with pytest.raises(Reject, match="future"): sub(env, d) # V8: exact-match human gap ack, logged to the ledger def test_ack_gap_to_must_match_and_is_logged(env): pub(env); d2 = pub(env); skid, seq = skid_seq(d2) kw = dict(now=NOW + 5) with pytest.raises(Held): ingest(d2, env["ident"], env["registry"], env["sstate"], "c1", ack_gap_to=f"{skid}:{seq + 1}", operator="h", **kw) with pytest.raises(Held): ingest(d2, env["ident"], env["registry"], env["sstate"], "c1", ack_gap_to=f"ed25519:other:{seq}", operator="h", **kw) with pytest.raises(ValueError): ingest(d2, env["ident"], env["registry"], env["sstate"], "c1", ack_gap_to=f"{skid}:{seq}", **kw) out = ingest(d2, env["ident"], env["registry"], env["sstate"], "c1", ack_gap_to=f"{skid}:{seq}", operator="h", **kw) assert out["gap_alarm"] is True L = Ledger(os.path.join(os.path.dirname(env["sstate"]), "ledger.jsonl")) e = [x for x in L.entries() if x["event"] == "gap_acked"] assert e and e[0]["operator"] == "h" and e[0]["skid"] == skid and (e[0]["from"], e[0]["to"]) == (0, seq) assert L.verify() def test_cli_has_no_blanket_ack_gap(): src = open(aicep_sub.__file__).read() assert '"--ack-gap"' not in src and '"--ack-gap-to"' in src # ---- v2.4 fixes (design review re-review W1-W4) import subprocess from aicep_common import LedgerError, MAX_TTL def test_w1_expires_not_after_created_rejected(env): d = pub(env); resign(env, d, lambda m: m.__setitem__("expires", m["created"])) with pytest.raises(Reject, match="expires <= created"): sub(env, d) def test_w1_ttl_at_cap_ok_over_cap_rejected(env): sub(env, pub(env, ttl=MAX_TTL)) with pytest.raises(Reject, match="MAX_TTL"): sub(env, pub(env, ttl=MAX_TTL + 1)) def test_w3_reanchor_is_human_only_and_logged(tmp_path): p = str(tmp_path / "l.jsonl"); L = Ledger(p); L.append("e", t=1); L.append("e", t=2) os.remove(L.anchor_path) with pytest.raises(LedgerError): L.append("e", t=3) with pytest.raises(PermissionError): L.reanchor("") L.reanchor("human-x", t=4) assert L.verify() and L.entries()[-1]["event"] == "reanchored" and L.entries()[-1]["operator"] == "human-x" L.append("e", t=5); assert L.verify() def test_w3_reanchor_refuses_broken_links(tmp_path): p = str(tmp_path / "l.jsonl"); L = Ledger(p); L.append("e", t=1); L.append("e", t=2); L.append("e", t=3) lines = open(p).read().splitlines(True); open(p, "w").writelines([lines[0], lines[2]]) with pytest.raises(LedgerError): L.reanchor("human-x") def test_w3_cli_reanchor(tmp_path): p = str(tmp_path / "l.jsonl"); L = Ledger(p); L.append("e", t=1); os.remove(L.anchor_path) sub_py = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "aicep_sub.py") assert subprocess.run([sys.executable, sub_py, "--reanchor-ledger", p], capture_output=True).returncode != 0 r = subprocess.run([sys.executable, sub_py, "--reanchor-ledger", p, "--operator", "human-x"], capture_output=True) assert r.returncode == 0 and Ledger(p).verify() def test_w3_receiver_start_fails_closed_on_truncated_ledger(w): send(w); send(w); w["recv"]().run_once() p = w["t"] / "r" / "ledger.jsonl" lines = open(p).read().splitlines(True); open(p, "w").writelines(lines[:-3]) with pytest.raises(LedgerError): w["recv"]() Ledger(str(p)).reanchor("human-x") # human re-anchor still can't hide receipt heads that are gone with pytest.raises(LedgerError): w["recv"]() def test_w4_existing_v1_sender_then_genesis_chain(w): def put(seq, prev): b = publish(OBJ, [w["recip"]], w["sk"], "A", "c1", str(w["t"] / "f"), str(w["t"] / "fps.json"), now=w["clk"].t, seq=seq, ttl=3600, prev_batch_id=prev) w["bus"].publish(b["batch_dir"], {"compartment": "c1", "batch_id": b["batch_id"], "folder": "agent-a"}); return b["batch_id"] r = w["recv"]() b1 = put(1, None); assert r.run_once()[b1][0] == "committed" # legacy v1 history b2 = put(2, "genesis"); assert r.run_once()[b2][0] == "committed" # outbox with no last_published b3 = put(3, b2); assert r.run_once()[b3][0] == "committed" b4 = put(4, "wrong"); assert r.run_once()[b4] == "dlq" def test_w3_reanchor_blank_operator_refused(tmp_path): L = Ledger(str(tmp_path / "l.jsonl")); L.append("e", t=1); os.remove(L.anchor_path) for op in ("", " ", "\t\n", None): with pytest.raises(PermissionError): L.reanchor(op) L.reanchor(" human-x ", t=2) assert L.entries()[-1]["operator"] == "human-x" and L.verify()