Address formal review 438 REQUEST_CHANGES on PR #710 (F6/F7/F8). F6 — HMAC key-version validation fails closed: - verify_authorization_artifact validates key_version BEFORE any MAC work, so an attacker-chosen version can never select the signing key. - Require exactly one nonempty, well-formed key-version field; missing, empty, unknown, malformed, duplicated (including identical-valued and nested aliases), and mismatched versions all fail. No versionless legacy fallback. - Artifact version must equal the configured active version; production now requires GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION explicitly (an implicit default made rotation ambiguous). Version stays inside the signed material. F7 — strictly canonical incident evidence: - Replace substring/first-match parsing with an exact schema: marker on line 1, every field once, fixed order, no duplicate/unknown/empty/conflicting fields in or outside the signed block. The parsed body is re-rendered and compared for exact equality before acceptance. - content_digest now binds the full recovery scope: repository identity, PR, decision-lock identity, destroyed subject, recovery action, recorded and expected head, incident issue, evidence author, minting actor, key version, nonce and issued_at. - Actor identity is the immutable user id with login consistency; conflicting ids/logins and display-name-only identities fail closed. Edited comments are rejected. The independent-author rule is preserved and enforced by stable id. - build_canonical_incident_body is the single source of the accepted format and refuses to emit ambiguous evidence. F8 — archival is a prerequisite for clearing terminal evidence: - _clear_decision_lock_for_profile no longer swallows archive failures. It requires a successful write plus a durable read-back matching the PR/head, and otherwise returns a structured, retry-safe failure that retains the lock and records actionable recovery evidence. - Fixes a latent bug the read-back exposed: the archive payload inherited the source lock's session_profile_lock, so save_state keyed the archive under the reviewer profile instead of the archive identity and it never read back. Adversarial regressions added for every listed case: key-version missing/empty/ unknown/malformed/duplicate/rotation/wrong-key-after-restart, reordered fields, duplicate identical and conflicting fields, conflicting actor ids/names, digest-preserving substitution, decision-lock and recovery-action substitution, cross-PR/repo/org/remote/head replay, archive exception/timeout/false/empty/ partial-readback with proof the lock survives, exactly-one permitted clear, and retry after archive failure. No existing assertion was weakened. Validation: focused 150 passed in three module orders; full tests/ 2809 passed, 6 skipped, 1 warning (pre-existing StarletteDeprecationWarning in tests/test_webui_audit.py:8), 161 subtests passed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2236 lines
80 KiB
Python
2236 lines
80 KiB
Python
"""#709: cross-profile decision-lock cleanup, overwrite protection, recovery.
|
||
|
||
Covers AC1–AC8 plus review-434 F1/F2/F3 and review-435 F3-residual/F4/F5
|
||
remediations without fabricating historical PR provenance or special-casing
|
||
live PR numbers in production code.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from unittest.mock import patch
|
||
|
||
import irrecoverable_provenance as irp
|
||
import mcp_session_state as ss
|
||
import stale_review_decision_lock as srdl
|
||
|
||
|
||
def _lock(
|
||
mutations=None,
|
||
*,
|
||
profile="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
head=None,
|
||
):
|
||
muts = []
|
||
for m in mutations or []:
|
||
row = dict(m)
|
||
if head and "head_sha" not in row:
|
||
row["head_sha"] = head
|
||
muts.append(row)
|
||
return {
|
||
"task": "review_pr",
|
||
"remote": remote,
|
||
"org": org,
|
||
"repo": repo,
|
||
"session_pid": os.getpid(),
|
||
"session_profile": profile,
|
||
"session_profile_lock": profile,
|
||
"profile_identity": profile,
|
||
"final_review_decision_ready": False,
|
||
"ready_pr_number": None,
|
||
"ready_action": None,
|
||
"ready_expected_head_sha": None,
|
||
"live_mutations": muts,
|
||
"correction_authorized": False,
|
||
"correction_reason": None,
|
||
"kind": ss.KIND_DECISION_LOCK,
|
||
}
|
||
|
||
|
||
APPROVE = {"pr_number": 100, "action": "approve", "review_id": 9}
|
||
APPROVE_OTHER = {"pr_number": 200, "action": "approve", "review_id": 10}
|
||
HEAD_A = "a" * 40
|
||
HEAD_B = "b" * 40
|
||
RECONCILER_OPS = [
|
||
"gitea.read",
|
||
"gitea.pr.close",
|
||
"gitea.pr.comment",
|
||
"gitea.issue.comment",
|
||
]
|
||
DEDICATED_RECOVERY_OPS = RECONCILER_OPS + [
|
||
irp.CAPABILITY_IRRECOVERABLE_RECOVERY,
|
||
]
|
||
DURABLE_TEST_HMAC_KEY = "0" * 64 # 32-byte hex durable key for F4 tests
|
||
|
||
# #709 F7 (review 438): canonical incident evidence binds the full recovery
|
||
# scope, so the fixtures carry stable actor ids, the decision-lock identity,
|
||
# the recovery action, both heads, the key version, and a replay nonce.
|
||
DECISION_LOCK_ID = "review_decision_lock-prgs-reviewer"
|
||
DESTROYED_SUBJECT = "prgs-reviewer terminal approval ledger"
|
||
RECOVERY_ACTION = irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE
|
||
INCIDENT_NONCE = "11111111-2222-3333-4444-555555555555"
|
||
INCIDENT_ISSUED_AT = "2026-07-13T00:00:00+00:00"
|
||
AUTHOR_LOGIN = "controller-ops"
|
||
AUTHOR_ID = 4242
|
||
MINT_ACTOR_LOGIN = "sysadmin"
|
||
MINT_ACTOR_ID = 7
|
||
|
||
|
||
def _canonical_incident_body(
|
||
*,
|
||
pr_number=42,
|
||
head=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
incident_issue=700,
|
||
decision_lock_id=DECISION_LOCK_ID,
|
||
destroyed_subject=DESTROYED_SUBJECT,
|
||
recovery_action=RECOVERY_ACTION,
|
||
recorded_head_sha=HEAD_A,
|
||
evidence_author_id=AUTHOR_ID,
|
||
evidence_author_login=AUTHOR_LOGIN,
|
||
mint_actor_id=MINT_ACTOR_ID,
|
||
mint_actor_login=MINT_ACTOR_LOGIN,
|
||
key_version=None,
|
||
nonce=INCIDENT_NONCE,
|
||
issued_at=INCIDENT_ISSUED_AT,
|
||
narrative="forensic diagnosis",
|
||
):
|
||
return irp.build_canonical_incident_body(
|
||
remote=remote,
|
||
org=org,
|
||
repo=repo,
|
||
pr_number=pr_number,
|
||
decision_lock_id=decision_lock_id,
|
||
destroyed_subject=destroyed_subject,
|
||
recovery_action=recovery_action,
|
||
recorded_head_sha=recorded_head_sha,
|
||
expected_head_sha=head,
|
||
incident_issue=incident_issue,
|
||
evidence_author_id=evidence_author_id,
|
||
evidence_author_login=evidence_author_login,
|
||
mint_actor_id=mint_actor_id,
|
||
mint_actor_login=mint_actor_login,
|
||
key_version=key_version or irp.auth_key_version(),
|
||
nonce=nonce,
|
||
issued_at=issued_at,
|
||
narrative=narrative,
|
||
)
|
||
|
||
|
||
def _incident_comment_payload(
|
||
*,
|
||
comment_id=11489,
|
||
author=AUTHOR_LOGIN,
|
||
author_id=None,
|
||
pr_number=42,
|
||
head=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
incident_issue=700,
|
||
body=None,
|
||
**body_kwargs,
|
||
):
|
||
uid = AUTHOR_ID if author_id is None else author_id
|
||
return {
|
||
"id": comment_id,
|
||
"body": body
|
||
if body is not None
|
||
else _canonical_incident_body(
|
||
pr_number=pr_number,
|
||
head=head,
|
||
remote=remote,
|
||
org=org,
|
||
repo=repo,
|
||
incident_issue=incident_issue,
|
||
evidence_author_id=uid,
|
||
evidence_author_login=author,
|
||
**body_kwargs,
|
||
),
|
||
"user": {"id": uid, "login": author},
|
||
"created_at": "2026-07-13T00:00:00Z",
|
||
"updated_at": "2026-07-13T00:00:00Z",
|
||
"html_url": f"https://gitea.example/{org}/{repo}/issues/{incident_issue}#issuecomment-{comment_id}",
|
||
}
|
||
|
||
|
||
def _mint_auth(
|
||
*,
|
||
pr_number=42,
|
||
head=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
issuer="sysadmin",
|
||
profile="prgs-reconciler",
|
||
destroyed_subject=None,
|
||
):
|
||
return irp.build_authorization_artifact(
|
||
remote=remote,
|
||
org=org,
|
||
repo=repo,
|
||
pr_number=pr_number,
|
||
expected_head_sha=head,
|
||
incident_issue=incident_issue,
|
||
incident_comment_id=incident_comment_id,
|
||
destroyed_subject=destroyed_subject,
|
||
issuer_username=issuer,
|
||
issuer_profile=profile,
|
||
native_provenance={
|
||
"native_mcp_transport": True,
|
||
"production_native_mcp_transport": False,
|
||
"pytest": True,
|
||
"token_fingerprint": "testfp",
|
||
"entrypoint": "pytest",
|
||
"pid": os.getpid(),
|
||
},
|
||
)
|
||
|
||
|
||
class TestAC2InitOverwrite(unittest.TestCase):
|
||
def test_empty_lock_allows_reinit(self):
|
||
a = srdl.assess_init_overwrite(_lock([]), force=True)
|
||
self.assertTrue(a["overwrite_allowed"])
|
||
|
||
def test_terminal_lock_blocks_force_reinit(self):
|
||
a = srdl.assess_init_overwrite(_lock([APPROVE]), force=True)
|
||
self.assertFalse(a["overwrite_allowed"])
|
||
self.assertTrue(a["has_unresolved_terminal"])
|
||
self.assertEqual(a["last_terminal_pr"], 100)
|
||
|
||
def test_none_lock_allows_init(self):
|
||
a = srdl.assess_init_overwrite(None)
|
||
self.assertTrue(a["overwrite_allowed"])
|
||
|
||
|
||
class TestAC1TargetApproval(unittest.TestCase):
|
||
def test_targets_matching_approve(self):
|
||
self.assertTrue(
|
||
srdl.lock_targets_merged_pr_approval(
|
||
_lock([APPROVE], head=HEAD_A),
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_A,
|
||
)
|
||
)
|
||
|
||
def test_rejects_other_pr(self):
|
||
self.assertFalse(
|
||
srdl.lock_targets_merged_pr_approval(
|
||
_lock([APPROVE_OTHER]), pr_number=100
|
||
)
|
||
)
|
||
|
||
def test_rejects_head_mismatch(self):
|
||
self.assertFalse(
|
||
srdl.lock_targets_merged_pr_approval(
|
||
_lock([APPROVE], head=HEAD_A),
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_B,
|
||
)
|
||
)
|
||
|
||
def test_f3_residual_rejects_legacy_no_head_when_expected_head_given(self):
|
||
"""Primary approve-match requires recorded-head (#709 F3 residual / 435)."""
|
||
# APPROVE without head fields — legacy ledger.
|
||
legacy = _lock([APPROVE]) # no head=
|
||
self.assertIsNone(srdl.mutation_head_sha(APPROVE, legacy))
|
||
self.assertFalse(
|
||
srdl.lock_targets_merged_pr_approval(
|
||
legacy,
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_A,
|
||
)
|
||
)
|
||
# Without expected_head_sha, PR-number-only match still works for
|
||
# non-destructive callers that do not pass a head pin.
|
||
self.assertTrue(
|
||
srdl.lock_targets_merged_pr_approval(legacy, pr_number=100)
|
||
)
|
||
|
||
|
||
class TestF1AuthorizationNotSelfAssertable(unittest.TestCase):
|
||
def test_operator_authorized_true_cannot_authorize_via_build(self):
|
||
rec = srdl.build_irrecoverable_provenance_record(
|
||
pr_number=42,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
actor_username="sysadmin",
|
||
profile_name="prgs-reconciler",
|
||
reason="evidence destroyed",
|
||
incident_ref="anything",
|
||
operator_authorized=True,
|
||
)
|
||
self.assertFalse(rec["applied"])
|
||
self.assertFalse(rec["historical_cleanup_proven"])
|
||
self.assertFalse(rec["merger_may_accept"])
|
||
|
||
def test_confirmation_string_not_authorization(self):
|
||
# Confirmation is only intent text; capability assess ignores it.
|
||
conf = irp.expected_confirmation(99)
|
||
self.assertEqual(conf, "IRRECOVERABLE DECISION PROVENANCE PR 99")
|
||
# Without auth artifact, merger cannot accept.
|
||
rec = srdl.build_irrecoverable_provenance_record(
|
||
pr_number=99,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="x",
|
||
profile_name="y",
|
||
reason="r",
|
||
operator_authorized=False,
|
||
)
|
||
self.assertFalse(rec["merger_may_accept"])
|
||
|
||
def test_gitea_read_alone_insufficient(self):
|
||
a = irp.assess_capability_for_irrecoverable_recovery(
|
||
allowed_operations=["gitea.read"],
|
||
forbidden_operations=[],
|
||
role_kind="author",
|
||
profile_name="prgs-author",
|
||
)
|
||
self.assertFalse(a["allowed"])
|
||
|
||
def test_expected_head_missing_fails(self):
|
||
g = irp.assess_live_head_binding(
|
||
expected_head_sha=None,
|
||
live_head_sha=HEAD_A,
|
||
)
|
||
self.assertFalse(g["valid"])
|
||
|
||
def test_expected_head_differs_fails(self):
|
||
g = irp.assess_live_head_binding(
|
||
expected_head_sha=HEAD_A,
|
||
live_head_sha=HEAD_B,
|
||
)
|
||
self.assertFalse(g["valid"])
|
||
|
||
def test_missing_incident_fails(self):
|
||
g = irp.assess_incident_evidence(
|
||
incident_issue=None,
|
||
incident_comment_id=None,
|
||
comment_payload=None,
|
||
)
|
||
self.assertFalse(g["valid"])
|
||
|
||
def test_nonexistent_incident_fails(self):
|
||
g = irp.assess_incident_evidence(
|
||
incident_issue=1,
|
||
incident_comment_id=2,
|
||
comment_payload=None,
|
||
comment_lookup_error="404",
|
||
)
|
||
self.assertFalse(g["valid"])
|
||
|
||
def test_valid_auth_succeeds_exact_scope(self):
|
||
auth = _mint_auth()
|
||
v = irp.verify_authorization_artifact(
|
||
auth,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
)
|
||
self.assertTrue(v["valid"], v)
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=42,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
actor_username="sysadmin",
|
||
profile_name="prgs-reconciler",
|
||
reason="evidence destroyed",
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
authorization=auth,
|
||
)
|
||
self.assertTrue(rec["merger_may_accept"])
|
||
self.assertFalse(rec["applied"])
|
||
body = irp.format_irrecoverable_audit_comment(rec)
|
||
self.assertIn("applied: `False`", body)
|
||
|
||
def test_wrong_repo_auth_fails(self):
|
||
auth = _mint_auth(repo="Other-Repo")
|
||
v = irp.verify_authorization_artifact(
|
||
auth,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
)
|
||
self.assertFalse(v["valid"])
|
||
|
||
def test_wrong_pr_auth_fails(self):
|
||
auth = _mint_auth(pr_number=1)
|
||
v = irp.verify_authorization_artifact(
|
||
auth,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
)
|
||
self.assertFalse(v["valid"])
|
||
|
||
def test_wrong_head_auth_fails(self):
|
||
auth = _mint_auth(head=HEAD_B)
|
||
v = irp.verify_authorization_artifact(
|
||
auth,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
)
|
||
self.assertFalse(v["valid"])
|
||
|
||
def test_altered_signature_fails(self):
|
||
auth = _mint_auth()
|
||
auth["server_signature"] = "0" * 64
|
||
v = irp.verify_authorization_artifact(
|
||
auth,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
)
|
||
self.assertFalse(v["valid"])
|
||
|
||
def test_fresh_non_pytest_process_cannot_mint_accepted_record(self):
|
||
"""Ordinary Python process: merger_may_accept stays False without server auth."""
|
||
script = (
|
||
"import stale_review_decision_lock as s\n"
|
||
"r=s.build_irrecoverable_provenance_record(\n"
|
||
" pr_number=1, head_sha=None, remote='prgs', org=None, repo=None,\n"
|
||
" actor_username='x', profile_name='y', reason='r',\n"
|
||
" incident_ref=None, operator_authorized=True)\n"
|
||
"print(r.get('merger_may_accept'), r.get('head_sha'))\n"
|
||
)
|
||
env = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST")}
|
||
env.pop("PYTEST_CURRENT_TEST", None)
|
||
proc = subprocess.run(
|
||
[sys.executable, "-c", script],
|
||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
capture_output=True,
|
||
text=True,
|
||
env=env,
|
||
timeout=30,
|
||
)
|
||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||
out = (proc.stdout or "").strip()
|
||
self.assertTrue(out.startswith("False"), msg=out)
|
||
|
||
def test_unauthorized_profile_capability(self):
|
||
a = irp.assess_capability_for_irrecoverable_recovery(
|
||
allowed_operations=["gitea.read", "gitea.pr.comment"],
|
||
forbidden_operations=["gitea.pr.close"],
|
||
role_kind="author",
|
||
profile_name="prgs-author",
|
||
)
|
||
self.assertFalse(a["allowed"])
|
||
|
||
def test_f5_reconciler_equivalence_rejected(self):
|
||
"""Reconciler profile without dedicated capability cannot mint (#709 F5)."""
|
||
a = irp.assess_capability_for_irrecoverable_recovery(
|
||
allowed_operations=RECONCILER_OPS,
|
||
forbidden_operations=[
|
||
"gitea.pr.approve",
|
||
"gitea.pr.merge",
|
||
"gitea.pr.review",
|
||
],
|
||
role_kind="reconciler",
|
||
profile_name="prgs-reconciler",
|
||
)
|
||
self.assertFalse(a["allowed"], a)
|
||
self.assertTrue(
|
||
any("dedicated" in r for r in a["reasons"]),
|
||
msg=a["reasons"],
|
||
)
|
||
|
||
def test_f5_dedicated_capability_allowed(self):
|
||
a = irp.assess_capability_for_irrecoverable_recovery(
|
||
allowed_operations=DEDICATED_RECOVERY_OPS,
|
||
forbidden_operations=[
|
||
"gitea.pr.approve",
|
||
"gitea.pr.merge",
|
||
"gitea.pr.review",
|
||
],
|
||
role_kind="reconciler",
|
||
profile_name="prgs-reconciler",
|
||
)
|
||
self.assertTrue(a["allowed"], a)
|
||
self.assertEqual(a["via"], "dedicated_capability")
|
||
|
||
|
||
class TestF5AuthoritativeIncidentEvidence(unittest.TestCase):
|
||
def test_any_nonempty_body_rejected(self):
|
||
g = irp.assess_incident_evidence(
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
comment_payload={
|
||
"id": 11489,
|
||
"body": "random forensic note without canonical fields",
|
||
"user": {"login": "controller-ops"},
|
||
},
|
||
expected_remote="prgs",
|
||
expected_org="Scaled-Tech-Consulting",
|
||
expected_repo="Gitea-Tools",
|
||
expected_pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
)
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_missing_author_rejected(self):
|
||
body = _canonical_incident_body()
|
||
g = irp.assess_incident_evidence(
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
comment_payload={"id": 11489, "body": body, "user": {}},
|
||
expected_remote="prgs",
|
||
expected_org="Scaled-Tech-Consulting",
|
||
expected_repo="Gitea-Tools",
|
||
expected_pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(any("author" in r for r in g["reasons"]), g["reasons"])
|
||
|
||
def test_self_authored_rejected(self):
|
||
g = irp.assess_incident_evidence(
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
comment_payload=_incident_comment_payload(author="sysadmin"),
|
||
expected_remote="prgs",
|
||
expected_org="Scaled-Tech-Consulting",
|
||
expected_repo="Gitea-Tools",
|
||
expected_pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
mint_actor_username="sysadmin",
|
||
reject_self_authored=True,
|
||
)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("self-authored" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_canonical_body_with_independent_author_accepted(self):
|
||
g = irp.assess_incident_evidence(
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
comment_payload=_incident_comment_payload(author="controller-ops"),
|
||
expected_remote="prgs",
|
||
expected_org="Scaled-Tech-Consulting",
|
||
expected_repo="Gitea-Tools",
|
||
expected_pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
mint_actor_username="sysadmin",
|
||
reject_self_authored=True,
|
||
)
|
||
self.assertTrue(g["valid"], g)
|
||
|
||
def test_tampered_content_digest_rejected(self):
|
||
payload = _incident_comment_payload()
|
||
payload["body"] = payload["body"].replace(
|
||
"content_digest: ", "content_digest: " + "f" * 64 + "x"
|
||
)
|
||
# Force bad digest line
|
||
lines = []
|
||
for line in payload["body"].splitlines():
|
||
if line.startswith("content_digest:"):
|
||
lines.append("content_digest: " + "0" * 64)
|
||
else:
|
||
lines.append(line)
|
||
payload["body"] = "\n".join(lines)
|
||
g = irp.assess_incident_evidence(
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
comment_payload=payload,
|
||
expected_remote="prgs",
|
||
expected_org="Scaled-Tech-Consulting",
|
||
expected_repo="Gitea-Tools",
|
||
expected_pr_number=42,
|
||
expected_head_sha=HEAD_A,
|
||
mint_actor_username="sysadmin",
|
||
)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("content_digest" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
|
||
class TestF4DurableHmacKey(unittest.TestCase):
|
||
def tearDown(self):
|
||
os.environ.pop(irp.ENV_AUTH_HMAC_KEY, None)
|
||
os.environ.pop(irp.ENV_AUTH_HMAC_KEY_VERSION, None)
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_key_version_bound_into_artifact(self):
|
||
irp.reset_process_auth_secret_for_tests()
|
||
auth = _mint_auth()
|
||
self.assertIn("key_version", auth)
|
||
self.assertTrue(auth["key_version"])
|
||
self.assertNotIn("server_secret", auth)
|
||
self.assertNotIn("hmac_key", auth)
|
||
|
||
def test_production_fails_closed_without_durable_key(self):
|
||
"""Non-pytest process without env key must not generate ephemeral secret."""
|
||
script = (
|
||
"import os, sys\n"
|
||
"os.environ.pop('PYTEST_CURRENT_TEST', None)\n"
|
||
"os.environ.pop('GITEA_IRRECOVERABLE_AUTH_HMAC_KEY', None)\n"
|
||
# Force non-pytest path by patching guard after import.
|
||
"import mcp_daemon_guard as g\n"
|
||
"g.is_pytest_runtime = lambda: False\n"
|
||
"import irrecoverable_provenance as irp\n"
|
||
"irp.reset_process_auth_secret_for_tests()\n"
|
||
"try:\n"
|
||
" irp._process_secret()\n"
|
||
" print('UNEXPECTED_OK')\n"
|
||
"except irp.AuthSecretError as e:\n"
|
||
" print('FAIL_CLOSED', 'ephemeral' in str(e).lower() or 'required' in str(e).lower())\n"
|
||
)
|
||
env = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST")}
|
||
env.pop("PYTEST_CURRENT_TEST", None)
|
||
env.pop(irp.ENV_AUTH_HMAC_KEY, None)
|
||
proc = subprocess.run(
|
||
[sys.executable, "-c", script],
|
||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
capture_output=True,
|
||
text=True,
|
||
env=env,
|
||
timeout=30,
|
||
)
|
||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||
out = (proc.stdout or "").strip()
|
||
self.assertTrue(out.startswith("FAIL_CLOSED"), msg=out)
|
||
|
||
def test_cross_process_verify_with_durable_key(self):
|
||
"""Mint in process A, verify in process B with same durable key (#709 F4)."""
|
||
import json as _json
|
||
|
||
mint_script = (
|
||
"import json, os, irrecoverable_provenance as irp\n"
|
||
"irp.reset_process_auth_secret_for_tests()\n"
|
||
"auth = irp.build_authorization_artifact(\n"
|
||
" remote='prgs', org='o', repo='r', pr_number=10,\n"
|
||
f" expected_head_sha={HEAD_A!r}, incident_issue=1, incident_comment_id=2,\n"
|
||
" destroyed_subject=None, issuer_username='sysadmin',\n"
|
||
" issuer_profile='prgs-reconciler',\n"
|
||
" native_provenance={'native_mcp_transport': True, 'pytest': True,\n"
|
||
" 'token_fingerprint': 'fp', 'entrypoint': 'pytest', 'pid': 1})\n"
|
||
"print(json.dumps(auth))\n"
|
||
)
|
||
env = dict(os.environ)
|
||
env[irp.ENV_AUTH_HMAC_KEY] = DURABLE_TEST_HMAC_KEY
|
||
env[irp.ENV_AUTH_HMAC_KEY_VERSION] = "test-v1"
|
||
mint = subprocess.run(
|
||
[sys.executable, "-c", mint_script],
|
||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
capture_output=True,
|
||
text=True,
|
||
env=env,
|
||
timeout=30,
|
||
)
|
||
self.assertEqual(mint.returncode, 0, mint.stderr)
|
||
auth = _json.loads(mint.stdout.strip())
|
||
self.assertEqual(auth.get("key_version"), "test-v1")
|
||
|
||
verify_script = (
|
||
"import json, sys, irrecoverable_provenance as irp\n"
|
||
"irp.reset_process_auth_secret_for_tests()\n"
|
||
"auth = json.loads(sys.stdin.read())\n"
|
||
"v = irp.verify_authorization_artifact(\n"
|
||
" auth, remote='prgs', org='o', repo='r', pr_number=10,\n"
|
||
f" expected_head_sha={HEAD_A!r}, incident_issue=1, incident_comment_id=2)\n"
|
||
"print(v.get('valid'), v.get('reasons'))\n"
|
||
)
|
||
verify = subprocess.run(
|
||
[sys.executable, "-c", verify_script],
|
||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
input=_json.dumps(auth),
|
||
capture_output=True,
|
||
text=True,
|
||
env=env,
|
||
timeout=30,
|
||
)
|
||
self.assertEqual(verify.returncode, 0, verify.stderr)
|
||
self.assertTrue(
|
||
(verify.stdout or "").strip().startswith("True"),
|
||
msg=verify.stdout + verify.stderr,
|
||
)
|
||
|
||
# Different durable key must fail verification (cross-process mismatch).
|
||
env_bad = dict(env)
|
||
env_bad[irp.ENV_AUTH_HMAC_KEY] = "1" * 64
|
||
verify_bad = subprocess.run(
|
||
[sys.executable, "-c", verify_script],
|
||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
input=_json.dumps(auth),
|
||
capture_output=True,
|
||
text=True,
|
||
env=env_bad,
|
||
timeout=30,
|
||
)
|
||
self.assertEqual(verify_bad.returncode, 0, verify_bad.stderr)
|
||
self.assertTrue(
|
||
(verify_bad.stdout or "").strip().startswith("False"),
|
||
msg=verify_bad.stdout,
|
||
)
|
||
|
||
|
||
class TestF2MergerConsumer(unittest.TestCase):
|
||
def test_merger_rejects_without_valid_auth(self):
|
||
rec = srdl.build_irrecoverable_provenance_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="x",
|
||
operator_authorized=True,
|
||
)
|
||
a = irp.assess_merger_consumption(
|
||
rec,
|
||
None,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
pr_number=10,
|
||
live_head_sha=HEAD_A,
|
||
approval_at_current_head=True,
|
||
has_blocking_change_requests=False,
|
||
)
|
||
self.assertFalse(a["allowed"])
|
||
|
||
def test_merger_rejects_wrong_head(self):
|
||
auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r")
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="x",
|
||
incident_issue=700,
|
||
incident_comment_id=1,
|
||
authorization=auth,
|
||
)
|
||
a = irp.assess_merger_consumption(
|
||
rec,
|
||
auth,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
pr_number=10,
|
||
live_head_sha=HEAD_B,
|
||
approval_at_current_head=True,
|
||
has_blocking_change_requests=False,
|
||
)
|
||
self.assertFalse(a["allowed"])
|
||
|
||
def test_merger_rejects_replayed_auth(self):
|
||
auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1)
|
||
consumed = irp.mark_consumed(auth, consumer_username="m", consumer_profile="merger")
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="x",
|
||
incident_issue=700,
|
||
incident_comment_id=1,
|
||
authorization=auth, # original unconsumed for record build
|
||
)
|
||
a = irp.assess_merger_consumption(
|
||
rec,
|
||
consumed,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
pr_number=10,
|
||
live_head_sha=HEAD_A,
|
||
approval_at_current_head=True,
|
||
has_blocking_change_requests=False,
|
||
)
|
||
self.assertFalse(a["allowed"])
|
||
|
||
def test_recovery_cannot_bypass_missing_approval(self):
|
||
auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1)
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="x",
|
||
incident_issue=700,
|
||
incident_comment_id=1,
|
||
authorization=auth,
|
||
)
|
||
a = irp.assess_merger_consumption(
|
||
rec,
|
||
auth,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
pr_number=10,
|
||
live_head_sha=HEAD_A,
|
||
approval_at_current_head=False,
|
||
has_blocking_change_requests=False,
|
||
)
|
||
self.assertFalse(a["allowed"])
|
||
self.assertTrue(any("approval" in r for r in a["reasons"]))
|
||
|
||
def test_recovery_cannot_bypass_blocking_crs(self):
|
||
auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1)
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="x",
|
||
incident_issue=700,
|
||
incident_comment_id=1,
|
||
authorization=auth,
|
||
)
|
||
a = irp.assess_merger_consumption(
|
||
rec,
|
||
auth,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
pr_number=10,
|
||
live_head_sha=HEAD_A,
|
||
approval_at_current_head=True,
|
||
has_blocking_change_requests=True,
|
||
)
|
||
self.assertFalse(a["allowed"])
|
||
|
||
def test_valid_recovery_resolves_only_prior_provenance(self):
|
||
auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1)
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="x",
|
||
incident_issue=700,
|
||
incident_comment_id=1,
|
||
authorization=auth,
|
||
)
|
||
a = irp.assess_merger_consumption(
|
||
rec,
|
||
auth,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
pr_number=10,
|
||
live_head_sha=HEAD_A,
|
||
approval_at_current_head=True,
|
||
has_blocking_change_requests=False,
|
||
mergeable=True,
|
||
lease_ok=True,
|
||
runtime_ok=True,
|
||
workspace_ok=True,
|
||
anti_stomp_ok=True,
|
||
)
|
||
self.assertTrue(a["allowed"], a)
|
||
self.assertTrue(a["resolves_prior_provenance_blocker"])
|
||
self.assertFalse(a["historical_cleanup_proven"])
|
||
|
||
def test_duplicate_consume_idempotent(self):
|
||
auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1)
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="x",
|
||
incident_issue=700,
|
||
incident_comment_id=1,
|
||
authorization=auth,
|
||
)
|
||
consumed_auth = irp.mark_consumed(auth, consumer_username="m", consumer_profile="mer")
|
||
consumed_rec = irp.mark_consumed(rec, consumer_username="m", consumer_profile="mer")
|
||
a = irp.assess_merger_consumption(
|
||
consumed_rec,
|
||
consumed_auth,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
pr_number=10,
|
||
live_head_sha=HEAD_A,
|
||
approval_at_current_head=True,
|
||
has_blocking_change_requests=False,
|
||
)
|
||
self.assertTrue(a["allowed"], a)
|
||
self.assertTrue(a["recovery_record_consumed"])
|
||
|
||
|
||
class TestF3ExactScopeEnforcement(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp = tempfile.TemporaryDirectory()
|
||
self.state_dir = self._tmp.name
|
||
os.chmod(self.state_dir, 0o700)
|
||
|
||
def tearDown(self):
|
||
self._tmp.cleanup()
|
||
|
||
def test_same_pr_other_remote_not_loaded(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([APPROVE], profile="prgs-reviewer", remote="other"),
|
||
remote="other",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self.state_dir,
|
||
)
|
||
loaded = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
state_dir=self.state_dir,
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNone(loaded)
|
||
|
||
def test_same_pr_other_org_not_loaded(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock(
|
||
[APPROVE],
|
||
profile="prgs-reviewer",
|
||
org="Other-Org",
|
||
),
|
||
remote="prgs",
|
||
org="Other-Org",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self.state_dir,
|
||
)
|
||
loaded = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
state_dir=self.state_dir,
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNone(loaded)
|
||
|
||
def test_same_pr_other_repo_not_loaded(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock(
|
||
[APPROVE],
|
||
profile="prgs-reviewer",
|
||
repo="Other-Repo",
|
||
),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Other-Repo",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self.state_dir,
|
||
)
|
||
loaded = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
state_dir=self.state_dir,
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNone(loaded)
|
||
|
||
def test_path_traversal_profile_fails(self):
|
||
g = irp.assess_profile_path_identity("../evil")
|
||
self.assertFalse(g["valid"])
|
||
loaded = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="../evil",
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
state_dir=self.state_dir,
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNone(loaded)
|
||
|
||
def test_malformed_legacy_missing_repo_fails_closed(self):
|
||
# Record without repo identity when caller requires repo.
|
||
payload = _lock([APPROVE], profile="prgs-reviewer")
|
||
del payload["repo"]
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=payload,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo=None,
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self.state_dir,
|
||
)
|
||
loaded = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
state_dir=self.state_dir,
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNone(loaded)
|
||
|
||
def test_list_and_load_foreign_profile_lock(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([APPROVE], profile="prgs-reviewer"),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self.state_dir,
|
||
)
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([], profile="prgs-merger"),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-merger",
|
||
state_dir=self.state_dir,
|
||
)
|
||
foreign = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
state_dir=self.state_dir,
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNotNone(foreign)
|
||
self.assertTrue(
|
||
srdl.lock_targets_merged_pr_approval(foreign, pr_number=100)
|
||
)
|
||
|
||
|
||
class TestAC3PostMergeRecoveryRecord(unittest.TestCase):
|
||
def test_recovery_record_is_not_applied_cleanup(self):
|
||
rec = srdl.build_post_merge_recovery_record(
|
||
pr_number=10,
|
||
head_sha=HEAD_A,
|
||
merge_commit_sha="m" * 40,
|
||
target_profile_identity="prgs-reviewer",
|
||
failed_step="audit_comment_publish",
|
||
error="timeout",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
actor_username="sysadmin",
|
||
profile_name="prgs-merger",
|
||
)
|
||
self.assertEqual(rec["status"], "recovery_required")
|
||
self.assertFalse(rec["applied"])
|
||
self.assertTrue(rec["recovery_critical"])
|
||
|
||
|
||
class TestSessionStateTTL(unittest.TestCase):
|
||
def test_recovery_critical_kinds_ttl_exempt(self):
|
||
auth = _mint_auth(pr_number=1)
|
||
rec = irp.build_irrecoverable_provenance_record(
|
||
pr_number=1,
|
||
head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="o",
|
||
repo="r",
|
||
actor_username="a",
|
||
profile_name="p",
|
||
reason="gone",
|
||
incident_issue=700,
|
||
incident_comment_id=1,
|
||
authorization=auth,
|
||
)
|
||
rec["kind"] = ss.KIND_IRRECOVERABLE_DECISION_PROVENANCE
|
||
rec["recorded_at"] = "2000-01-01T00:00:00Z"
|
||
rec["updated_at"] = rec["recorded_at"]
|
||
rec["profile_identity"] = "prgs-reconciler"
|
||
rec["session_profile_lock"] = "prgs-reconciler"
|
||
reasons = ss.identity_match_reasons(
|
||
rec, profile_identity="prgs-reconciler"
|
||
)
|
||
self.assertFalse(any("expired" in r for r in reasons), msg=reasons)
|
||
|
||
|
||
class TestInitReviewDecisionLockIntegration(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp = tempfile.TemporaryDirectory()
|
||
self.env = patch.dict(
|
||
os.environ,
|
||
{
|
||
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
|
||
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
|
||
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||
},
|
||
clear=False,
|
||
)
|
||
self.env.start()
|
||
import mcp_server
|
||
|
||
self.mcp = mcp_server
|
||
self.mcp._REVIEW_DECISION_LOCK = None
|
||
|
||
def tearDown(self):
|
||
self.mcp._REVIEW_DECISION_LOCK = None
|
||
self.env.stop()
|
||
self._tmp.cleanup()
|
||
|
||
def test_init_does_not_wipe_terminal_ledger(self):
|
||
self.mcp._save_review_decision_lock(
|
||
_lock([APPROVE], profile="prgs-reviewer")
|
||
)
|
||
self.mcp.init_review_decision_lock("prgs", "review_pr", force=True)
|
||
loaded = self.mcp._load_review_decision_lock()
|
||
self.assertIsNotNone(loaded)
|
||
last = srdl.last_terminal_mutation(loaded)
|
||
self.assertIsNotNone(last)
|
||
self.assertEqual(last.get("pr_number"), 100)
|
||
|
||
def test_init_creates_empty_when_no_terminal(self):
|
||
self.mcp._save_review_decision_lock(None)
|
||
self.mcp.init_review_decision_lock("prgs", "review_pr", force=True)
|
||
loaded = self.mcp._load_review_decision_lock()
|
||
self.assertIsNotNone(loaded)
|
||
self.assertEqual(loaded.get("live_mutations"), [])
|
||
|
||
|
||
class TestIrrecoverableToolF1(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp = tempfile.TemporaryDirectory()
|
||
self.env = patch.dict(
|
||
os.environ,
|
||
{
|
||
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
|
||
"GITEA_SESSION_PROFILE_LOCK": "prgs-reconciler",
|
||
"GITEA_PROFILE_NAME": "prgs-reconciler",
|
||
"GITEA_ALLOWED_OPERATIONS": ",".join(DEDICATED_RECOVERY_OPS),
|
||
},
|
||
clear=False,
|
||
)
|
||
self.env.start()
|
||
import mcp_server
|
||
|
||
self.mcp = mcp_server
|
||
|
||
def tearDown(self):
|
||
self.env.stop()
|
||
self._tmp.cleanup()
|
||
|
||
def _profile(self):
|
||
return {
|
||
"profile_name": "prgs-reconciler",
|
||
"role": "reconciler",
|
||
"allowed_operations": DEDICATED_RECOVERY_OPS,
|
||
"forbidden_operations": [
|
||
"gitea.pr.approve",
|
||
"gitea.pr.merge",
|
||
"gitea.pr.review",
|
||
],
|
||
}
|
||
|
||
def test_operator_authorized_true_rejected(self):
|
||
with patch.object(self.mcp, "get_profile", return_value=self._profile()):
|
||
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||
pr_number=50,
|
||
reason="lost",
|
||
confirmation=irp.expected_confirmation(50),
|
||
operator_authorized=True,
|
||
expected_head_sha=HEAD_A,
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
post_audit_comment=False,
|
||
)
|
||
self.assertFalse(r["success"])
|
||
self.assertTrue(any("operator_authorized" in x for x in r["reasons"]))
|
||
|
||
def test_missing_expected_head_fails(self):
|
||
with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object(
|
||
self.mcp, "_authenticated_username", return_value="sysadmin"
|
||
), patch.object(
|
||
self.mcp, "_irrecoverable_capability_gate", return_value=None
|
||
), patch.object(
|
||
irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []}
|
||
), patch.object(
|
||
self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools")
|
||
):
|
||
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||
pr_number=50,
|
||
reason="lost",
|
||
confirmation=irp.expected_confirmation(50),
|
||
expected_head_sha=None,
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
post_audit_comment=False,
|
||
)
|
||
self.assertFalse(r["success"])
|
||
|
||
def test_wrong_confirmation_fails(self):
|
||
with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object(
|
||
self.mcp, "_irrecoverable_capability_gate", return_value=None
|
||
), patch.object(
|
||
irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []}
|
||
), patch.object(
|
||
self.mcp, "_resolve", return_value=("h", "o", "r")
|
||
):
|
||
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||
pr_number=50,
|
||
reason="x",
|
||
confirmation=irp.expected_confirmation(51),
|
||
expected_head_sha=HEAD_A,
|
||
incident_issue=1,
|
||
incident_comment_id=2,
|
||
remote="prgs",
|
||
post_audit_comment=False,
|
||
)
|
||
self.assertFalse(r["success"])
|
||
|
||
def test_records_with_server_auth(self):
|
||
auth = _mint_auth(
|
||
pr_number=50,
|
||
head=HEAD_A,
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
)
|
||
auth_profile = irp.auth_state_profile_identity(
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
pr_number=50,
|
||
expected_head_sha=HEAD_A,
|
||
)
|
||
ss.save_state(
|
||
kind=ss.KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
||
payload=auth,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity=auth_profile,
|
||
state_dir=self._tmp.name,
|
||
)
|
||
|
||
def _api(method, url, auth=None, data=None, **kwargs):
|
||
if "/pulls/" in str(url):
|
||
return {"head": {"sha": HEAD_A}, "state": "open"}
|
||
if "/issues/comments/" in str(url):
|
||
return _incident_comment_payload(
|
||
comment_id=11489,
|
||
author="controller-ops",
|
||
pr_number=50,
|
||
head=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
incident_issue=700,
|
||
)
|
||
raise AssertionError(f"unexpected API {method} {url}")
|
||
|
||
with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object(
|
||
self.mcp, "_authenticated_username", return_value="sysadmin"
|
||
), patch.object(
|
||
self.mcp, "_irrecoverable_capability_gate", return_value=None
|
||
), patch.object(
|
||
irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []}
|
||
), patch.object(
|
||
self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools")
|
||
), patch.object(
|
||
self.mcp, "_auth", return_value={"Authorization": "token test"}
|
||
), patch.object(
|
||
self.mcp, "api_request", side_effect=_api
|
||
), patch.object(
|
||
self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r"
|
||
):
|
||
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||
pr_number=50,
|
||
reason="terminal evidence overwritten",
|
||
confirmation=irp.expected_confirmation(50),
|
||
expected_head_sha=HEAD_A,
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
post_audit_comment=False,
|
||
)
|
||
self.assertTrue(r["success"], r)
|
||
self.assertFalse(r["applied"])
|
||
self.assertFalse(r["historical_cleanup_proven"])
|
||
self.assertTrue(r["merger_may_accept"])
|
||
self.assertEqual(r["record"]["status"], "provenance_irrecoverable")
|
||
|
||
# Idempotent
|
||
with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object(
|
||
self.mcp, "_authenticated_username", return_value="sysadmin"
|
||
), patch.object(
|
||
self.mcp, "_irrecoverable_capability_gate", return_value=None
|
||
), patch.object(
|
||
irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []}
|
||
), patch.object(
|
||
self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools")
|
||
), patch.object(
|
||
self.mcp, "_auth", return_value={"Authorization": "token test"}
|
||
), patch.object(
|
||
self.mcp, "api_request", side_effect=_api
|
||
), patch.object(
|
||
self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r"
|
||
):
|
||
r2 = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||
pr_number=50,
|
||
reason="terminal evidence overwritten",
|
||
confirmation=irp.expected_confirmation(50),
|
||
expected_head_sha=HEAD_A,
|
||
incident_issue=700,
|
||
incident_comment_id=11489,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
post_audit_comment=False,
|
||
)
|
||
self.assertTrue(r2["success"], r2)
|
||
self.assertFalse(r2["performed"])
|
||
|
||
|
||
class TestClearProfileHelperF3(unittest.TestCase):
|
||
def setUp(self):
|
||
self._tmp = tempfile.TemporaryDirectory()
|
||
self.env = patch.dict(
|
||
os.environ,
|
||
{
|
||
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
|
||
"GITEA_SESSION_PROFILE_LOCK": "prgs-merger",
|
||
},
|
||
clear=False,
|
||
)
|
||
self.env.start()
|
||
import mcp_server
|
||
|
||
self.mcp = mcp_server
|
||
self.mcp._REVIEW_DECISION_LOCK = None
|
||
|
||
def tearDown(self):
|
||
self.mcp._REVIEW_DECISION_LOCK = None
|
||
self.env.stop()
|
||
self._tmp.cleanup()
|
||
|
||
def test_clear_only_matching_reviewer_approve(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self._tmp.name,
|
||
)
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([], profile="prgs-merger"),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-merger",
|
||
state_dir=self._tmp.name,
|
||
)
|
||
out = self.mcp._clear_decision_lock_for_profile(
|
||
profile_identity="prgs-reviewer",
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
)
|
||
self.assertTrue(out["cleared"], out)
|
||
skip = self.mcp._clear_decision_lock_for_profile(
|
||
profile_identity="prgs-merger",
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
)
|
||
self.assertFalse(skip["cleared"])
|
||
|
||
def test_pr_number_only_fallback_impossible(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self._tmp.name,
|
||
)
|
||
# Missing expected_head_sha
|
||
out = self.mcp._clear_decision_lock_for_profile(
|
||
profile_identity="prgs-reviewer",
|
||
pr_number=100,
|
||
expected_head_sha=None,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
)
|
||
self.assertFalse(out["cleared"])
|
||
self.assertIn("PR-number-only", out["reason"])
|
||
|
||
def test_wrong_head_not_cleared(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self._tmp.name,
|
||
)
|
||
out = self.mcp._clear_decision_lock_for_profile(
|
||
profile_identity="prgs-reviewer",
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_B,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
)
|
||
self.assertFalse(out["cleared"])
|
||
|
||
def test_cross_repo_same_pr_number_not_cleared(self):
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock(
|
||
[APPROVE],
|
||
profile="prgs-reviewer",
|
||
head=HEAD_A,
|
||
repo="Other-Repo",
|
||
),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Other-Repo",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self._tmp.name,
|
||
)
|
||
out = self.mcp._clear_decision_lock_for_profile(
|
||
profile_identity="prgs-reviewer",
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
)
|
||
self.assertFalse(out["cleared"])
|
||
# Original lock still present under Other-Repo scope
|
||
still = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Other-Repo",
|
||
state_dir=self._tmp.name,
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNotNone(still)
|
||
|
||
|
||
def _reorder_canonical_lines(body, first, second):
|
||
"""Swap two canonical field lines, leaving the digest untouched."""
|
||
lines = body.split("\n")
|
||
i = next(i for i, l in enumerate(lines) if l.startswith(f"{first}: "))
|
||
j = next(i for i, l in enumerate(lines) if l.startswith(f"{second}: "))
|
||
lines[i], lines[j] = lines[j], lines[i]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _insert_after_canonical_line(body, after_field, extra_line):
|
||
lines = body.split("\n")
|
||
i = next(i for i, l in enumerate(lines) if l.startswith(f"{after_field}: "))
|
||
lines.insert(i + 1, extra_line)
|
||
return "\n".join(lines)
|
||
|
||
|
||
class TestF6KeyVersionFailsClosed(unittest.TestCase):
|
||
"""#709 F6 (review 438): key-version validation must fail closed."""
|
||
|
||
def _verify(self, auth, **kwargs):
|
||
params = {
|
||
"remote": "prgs",
|
||
"org": "Scaled-Tech-Consulting",
|
||
"repo": "Gitea-Tools",
|
||
"pr_number": 42,
|
||
"expected_head_sha": HEAD_A,
|
||
}
|
||
params.update(kwargs)
|
||
return irp.verify_authorization_artifact(auth, **params)
|
||
|
||
def test_valid_artifact_verifies(self):
|
||
self.assertTrue(self._verify(_mint_auth())["valid"])
|
||
|
||
def test_missing_key_version_rejected(self):
|
||
auth = _mint_auth()
|
||
del auth["key_version"]
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(
|
||
any("missing key_version" in r for r in v["reasons"]), v["reasons"]
|
||
)
|
||
|
||
def test_empty_key_version_rejected(self):
|
||
auth = _mint_auth()
|
||
auth["key_version"] = ""
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(any("empty" in r for r in v["reasons"]), v["reasons"])
|
||
|
||
def test_unknown_key_version_rejected(self):
|
||
auth = _mint_auth()
|
||
auth["key_version"] = "totally-unknown-version"
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(
|
||
any("unknown or does not match" in r for r in v["reasons"]), v["reasons"]
|
||
)
|
||
|
||
def test_malformed_key_version_rejected(self):
|
||
auth = _mint_auth()
|
||
auth["key_version"] = "bad version!"
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(any("malformed" in r for r in v["reasons"]), v["reasons"])
|
||
|
||
def test_non_string_key_version_rejected(self):
|
||
auth = _mint_auth()
|
||
auth["key_version"] = ["v1", "v2"]
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
|
||
def test_duplicate_key_version_fields_rejected(self):
|
||
auth = _mint_auth()
|
||
auth["keyVersion"] = auth["key_version"] # identical value, still duplicate
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(
|
||
any("duplicate key-version" in r for r in v["reasons"]), v["reasons"]
|
||
)
|
||
|
||
def test_duplicate_conflicting_key_version_fields_rejected(self):
|
||
auth = _mint_auth()
|
||
auth["auth_key_version"] = "v9"
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(
|
||
any("duplicate key-version" in r for r in v["reasons"]), v["reasons"]
|
||
)
|
||
|
||
def test_nested_key_version_counts_as_duplicate(self):
|
||
auth = _mint_auth()
|
||
auth["native_provenance"] = dict(auth["native_provenance"])
|
||
auth["native_provenance"]["key_version"] = auth["key_version"]
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
|
||
def test_rotation_invalidates_prior_version_artifact(self):
|
||
"""Rotating the configured version must reject artifacts minted under the old one."""
|
||
auth = _mint_auth()
|
||
self.assertTrue(self._verify(auth)["valid"])
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v2",
|
||
},
|
||
clear=False,
|
||
):
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(
|
||
any("does not match the configured" in r for r in v["reasons"]),
|
||
v["reasons"],
|
||
)
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_wrong_version_fails_even_when_key_unchanged(self):
|
||
"""Version mismatch alone fails: the durable key staying the same is not enough."""
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
|
||
},
|
||
clear=False,
|
||
):
|
||
auth = _mint_auth()
|
||
self.assertEqual(auth["key_version"], "v1")
|
||
self.assertTrue(self._verify(auth)["valid"])
|
||
irp.reset_process_auth_secret_for_tests()
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, # same key
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v2", # rotated version
|
||
},
|
||
clear=False,
|
||
):
|
||
self.assertFalse(self._verify(auth)["valid"])
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_wrong_key_fails_after_restart(self):
|
||
"""A different durable key must reject the artifact even at the same version."""
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
|
||
},
|
||
clear=False,
|
||
):
|
||
auth = _mint_auth()
|
||
irp.reset_process_auth_secret_for_tests() # simulate restart
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: "1" * 64, # different durable key
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", # same version
|
||
},
|
||
clear=False,
|
||
):
|
||
v = self._verify(auth)
|
||
self.assertFalse(v["valid"], v)
|
||
self.assertTrue(
|
||
any("server_signature invalid" in r for r in v["reasons"]),
|
||
v["reasons"],
|
||
)
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_same_durable_key_verifies_after_restart(self):
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
env = {
|
||
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
|
||
}
|
||
with patch.dict(os.environ, env, clear=False):
|
||
auth = _mint_auth()
|
||
irp.reset_process_auth_secret_for_tests() # simulate restart
|
||
with patch.dict(os.environ, env, clear=False):
|
||
self.assertTrue(self._verify(auth)["valid"])
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_key_version_is_inside_authenticated_data(self):
|
||
"""Editing key_version must break the MAC, not just the version check."""
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
|
||
},
|
||
clear=False,
|
||
):
|
||
auth = _mint_auth()
|
||
signature_v1 = auth["server_signature"]
|
||
irp.reset_process_auth_secret_for_tests()
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v2",
|
||
},
|
||
clear=False,
|
||
):
|
||
rotated = _mint_auth()
|
||
# Same key + same scope, different version => different MAC.
|
||
self.assertNotEqual(signature_v1, rotated["server_signature"])
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_production_requires_configured_key_version(self):
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
with patch.object(
|
||
irp.mcp_daemon_guard, "is_pytest_runtime", return_value=False
|
||
), patch.dict(
|
||
os.environ, {irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY}, clear=False
|
||
):
|
||
os.environ.pop(irp.ENV_AUTH_HMAC_KEY_VERSION, None)
|
||
with self.assertRaises(irp.AuthSecretError) as ctx:
|
||
irp.auth_key_version()
|
||
self.assertIn(irp.ENV_AUTH_HMAC_KEY_VERSION, str(ctx.exception))
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_production_requires_durable_key(self):
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
with patch.object(
|
||
irp.mcp_daemon_guard, "is_pytest_runtime", return_value=False
|
||
), patch.dict(os.environ, {}, clear=False):
|
||
os.environ.pop(irp.ENV_AUTH_HMAC_KEY, None)
|
||
with self.assertRaises(irp.AuthSecretError):
|
||
irp.auth_key_version()
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
def test_errors_never_leak_key_material(self):
|
||
irp.reset_process_auth_secret_for_tests()
|
||
try:
|
||
secret = "s3cr3t" + "9" * 58
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
irp.ENV_AUTH_HMAC_KEY: secret,
|
||
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
|
||
},
|
||
clear=False,
|
||
):
|
||
auth = _mint_auth()
|
||
self.assertNotIn("key", {k.lower(): 1 for k in ()}) # no-op guard
|
||
blob = json.dumps(auth)
|
||
self.assertNotIn(secret, blob)
|
||
auth["key_version"] = "nope"
|
||
v = self._verify(auth)
|
||
self.assertNotIn(secret, json.dumps(v["reasons"]))
|
||
finally:
|
||
irp.reset_process_auth_secret_for_tests()
|
||
|
||
|
||
class TestF7StrictCanonicalIncidentEvidence(unittest.TestCase):
|
||
"""#709 F7 (review 438): only the exact canonical representation is accepted."""
|
||
|
||
def _assess(self, payload, **kwargs):
|
||
params = {
|
||
"incident_issue": 700,
|
||
"incident_comment_id": 11489,
|
||
"comment_payload": payload,
|
||
"expected_remote": "prgs",
|
||
"expected_org": "Scaled-Tech-Consulting",
|
||
"expected_repo": "Gitea-Tools",
|
||
"expected_pr_number": 42,
|
||
"expected_head_sha": HEAD_A,
|
||
"expected_decision_lock_id": DECISION_LOCK_ID,
|
||
"expected_recovery_action": RECOVERY_ACTION,
|
||
"mint_actor_id": MINT_ACTOR_ID,
|
||
"mint_actor_username": MINT_ACTOR_LOGIN,
|
||
}
|
||
params.update(kwargs)
|
||
return irp.assess_incident_evidence(**params)
|
||
|
||
def test_canonical_evidence_accepted(self):
|
||
g = self._assess(_incident_comment_payload())
|
||
self.assertTrue(g["valid"], g)
|
||
|
||
def test_reordered_fields_rejected(self):
|
||
body = _reorder_canonical_lines(_canonical_incident_body(), "org", "repo")
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("canonical order" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_duplicate_identical_field_rejected(self):
|
||
body = _canonical_incident_body()
|
||
body = _insert_after_canonical_line(body, "repo", "repo: Gitea-Tools")
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_duplicate_conflicting_field_rejected(self):
|
||
body = _canonical_incident_body()
|
||
body = _insert_after_canonical_line(body, "repo", "repo: Other-Repo")
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_conflicting_field_in_narrative_rejected(self):
|
||
body = _canonical_incident_body(narrative="context") + "\nrepo: Other-Repo"
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("outside the signed block" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_second_marker_rejected(self):
|
||
body = _canonical_incident_body(narrative="context")
|
||
body = f"{body}\n\n{irp.INCIDENT_MARKER}"
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_marker_not_first_rejected(self):
|
||
body = "preamble\n" + _canonical_incident_body()
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("marker position" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_unknown_extra_field_rejected(self):
|
||
body = _insert_after_canonical_line(
|
||
_canonical_incident_body(), "repo", "sneaky: value"
|
||
)
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_missing_field_rejected(self):
|
||
body = "\n".join(
|
||
l
|
||
for l in _canonical_incident_body().split("\n")
|
||
if not l.startswith("nonce: ")
|
||
)
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_empty_field_rejected(self):
|
||
body = _canonical_incident_body().replace(
|
||
f"decision_lock_id: {DECISION_LOCK_ID}", "decision_lock_id: "
|
||
)
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_conflicting_actor_logins_rejected(self):
|
||
payload = _incident_comment_payload()
|
||
payload["user"] = {"id": AUTHOR_ID, "login": AUTHOR_LOGIN, "username": "someone-else"}
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("conflicting logins" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_conflicting_actor_ids_rejected(self):
|
||
payload = _incident_comment_payload()
|
||
payload["user"] = {"id": AUTHOR_ID, "user_id": 999, "login": AUTHOR_LOGIN}
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("conflicting user ids" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_display_name_only_actor_rejected(self):
|
||
payload = _incident_comment_payload()
|
||
payload["user"] = {"login": AUTHOR_LOGIN} # no stable id
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("stable user id" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_author_id_substitution_rejected(self):
|
||
"""Body claims one author id, live comment is authored by another."""
|
||
payload = _incident_comment_payload()
|
||
payload["user"] = {"id": 5150, "login": AUTHOR_LOGIN}
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("evidence_author_id" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_edited_comment_rejected(self):
|
||
payload = _incident_comment_payload()
|
||
payload["updated_at"] = "2026-07-14T00:00:00Z"
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(any("edited" in r for r in g["reasons"]), g["reasons"])
|
||
|
||
def test_self_authored_by_stable_id_rejected(self):
|
||
payload = _incident_comment_payload(author=MINT_ACTOR_LOGIN, author_id=MINT_ACTOR_ID)
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("self-authored" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_mint_actor_substitution_rejected(self):
|
||
g = self._assess(_incident_comment_payload(), mint_actor_id=999, mint_actor_username="someone")
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("mint_actor" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_decision_lock_substitution_rejected(self):
|
||
payload = _incident_comment_payload(decision_lock_id="review_decision_lock-prgs-merger")
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("decision_lock_id" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_recovery_action_substitution_rejected(self):
|
||
body = _canonical_incident_body().replace(
|
||
f"recovery_action: {RECOVERY_ACTION}", "recovery_action: clear_decision_lock"
|
||
)
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_unsupported_recovery_action_cannot_be_built(self):
|
||
with self.assertRaises(ValueError):
|
||
_canonical_incident_body(recovery_action="merge_pr")
|
||
|
||
def test_cross_pr_replay_rejected(self):
|
||
payload = _incident_comment_payload(pr_number=99)
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("pr_number" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_cross_repository_replay_rejected(self):
|
||
payload = _incident_comment_payload(repo="Other-Repo")
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_cross_org_replay_rejected(self):
|
||
payload = _incident_comment_payload(org="Other-Org")
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_cross_remote_replay_rejected(self):
|
||
payload = _incident_comment_payload(remote="dadeschools")
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_cross_head_replay_rejected(self):
|
||
payload = _incident_comment_payload(head=HEAD_B)
|
||
g = self._assess(payload)
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_recorded_head_substitution_rejected(self):
|
||
payload = _incident_comment_payload(recorded_head_sha=HEAD_B)
|
||
g = self._assess(payload, expected_recorded_head_sha=HEAD_A)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("recorded_head_sha" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_key_version_substitution_rejected(self):
|
||
payload = _incident_comment_payload(key_version="v9")
|
||
g = self._assess(payload, expected_key_version=irp.auth_key_version())
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_digest_preserving_substitution_rejected(self):
|
||
"""Swap a field *and* its digest from another scope: still refused.
|
||
|
||
The attacker mints a fully valid canonical body for a different PR (so
|
||
the digest is internally consistent) and presents it for this scope.
|
||
"""
|
||
foreign = _canonical_incident_body(pr_number=99)
|
||
parsed = irp.parse_canonical_incident_body(foreign)
|
||
self.assertTrue(parsed["valid"], parsed) # internally consistent
|
||
g = self._assess(_incident_comment_payload(body=foreign))
|
||
self.assertFalse(g["valid"], g)
|
||
|
||
def test_field_swap_without_digest_update_rejected(self):
|
||
body = _canonical_incident_body().replace(
|
||
"repo: Gitea-Tools", "repo: Other-Repo"
|
||
)
|
||
g = self._assess(
|
||
_incident_comment_payload(body=body), expected_repo="Other-Repo"
|
||
)
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("content_digest" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_nonce_binds_digest(self):
|
||
body = _canonical_incident_body().replace(
|
||
f"nonce: {INCIDENT_NONCE}", "nonce: 00000000-0000-0000-0000-000000000000"
|
||
)
|
||
g = self._assess(_incident_comment_payload(body=body))
|
||
self.assertFalse(g["valid"], g)
|
||
self.assertTrue(
|
||
any("content_digest" in r for r in g["reasons"]), g["reasons"]
|
||
)
|
||
|
||
def test_builder_refuses_ambiguous_narrative(self):
|
||
with self.assertRaises(ValueError):
|
||
_canonical_incident_body(narrative=f"{irp.INCIDENT_MARKER}\nrepo: evil")
|
||
|
||
def test_builder_refuses_multiline_field(self):
|
||
with self.assertRaises(ValueError):
|
||
_canonical_incident_body(destroyed_subject="line1\nrepo: evil")
|
||
|
||
def test_builder_refuses_empty_field(self):
|
||
with self.assertRaises(ValueError):
|
||
_canonical_incident_body(decision_lock_id="")
|
||
|
||
def test_builder_output_is_the_accepted_format(self):
|
||
body = _canonical_incident_body()
|
||
parsed = irp.parse_canonical_incident_body(body)
|
||
self.assertTrue(parsed["valid"], parsed)
|
||
self.assertEqual(
|
||
parsed["canonical_block"],
|
||
irp.render_canonical_incident_block(parsed["fields"]),
|
||
)
|
||
|
||
|
||
class TestF8ArchivePrerequisiteForClear(unittest.TestCase):
|
||
"""#709 F8 (review 438): never clear terminal evidence without a durable archive."""
|
||
|
||
def setUp(self):
|
||
self._tmp = tempfile.TemporaryDirectory()
|
||
self.env = patch.dict(
|
||
os.environ,
|
||
{
|
||
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
|
||
"GITEA_SESSION_PROFILE_LOCK": "prgs-merger",
|
||
},
|
||
clear=False,
|
||
)
|
||
self.env.start()
|
||
import mcp_server
|
||
|
||
self.mcp = mcp_server
|
||
self.mcp._REVIEW_DECISION_LOCK = None
|
||
ss.save_state(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A),
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
profile_identity="prgs-reviewer",
|
||
state_dir=self._tmp.name,
|
||
)
|
||
|
||
def tearDown(self):
|
||
self.mcp._REVIEW_DECISION_LOCK = None
|
||
self.env.stop()
|
||
self._tmp.cleanup()
|
||
|
||
def _clear(self):
|
||
return self.mcp._clear_decision_lock_for_profile(
|
||
profile_identity="prgs-reviewer",
|
||
pr_number=100,
|
||
expected_head_sha=HEAD_A,
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
)
|
||
|
||
def _lock_still_present(self):
|
||
return ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK,
|
||
profile_identity="prgs-reviewer",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
skip_identity_match=True,
|
||
)
|
||
|
||
def _fail_archive_only(self, behavior):
|
||
"""Patch save_state so archive writes fail but other writes pass through."""
|
||
real = ss.save_state
|
||
|
||
def _fake(**kwargs):
|
||
if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE:
|
||
return behavior()
|
||
return real(**kwargs)
|
||
|
||
return patch.object(self.mcp.mcp_session_state, "save_state", side_effect=_fake)
|
||
|
||
def test_archive_exception_retains_lock(self):
|
||
def _boom():
|
||
raise OSError("disk failure")
|
||
|
||
with self._fail_archive_only(_boom):
|
||
out = self._clear()
|
||
self.assertFalse(out["cleared"], out)
|
||
self.assertTrue(out["terminal_lock_retained"], out)
|
||
self.assertTrue(out["recovery_required"], out)
|
||
self.assertEqual(out["archive_failed_step"], "archive_save_state")
|
||
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
|
||
|
||
def test_archive_false_response_retains_lock(self):
|
||
with self._fail_archive_only(lambda: False):
|
||
out = self._clear()
|
||
self.assertFalse(out["cleared"], out)
|
||
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
|
||
|
||
def test_archive_empty_response_retains_lock(self):
|
||
with self._fail_archive_only(lambda: {}):
|
||
out = self._clear()
|
||
self.assertFalse(out["cleared"], out)
|
||
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
|
||
|
||
def test_archive_timeout_retains_lock(self):
|
||
def _timeout():
|
||
raise TimeoutError("session state write timed out")
|
||
|
||
with self._fail_archive_only(_timeout):
|
||
out = self._clear()
|
||
self.assertFalse(out["cleared"], out)
|
||
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
|
||
|
||
def test_archive_unreadable_retains_lock(self):
|
||
"""Write claims success but read-back finds nothing: still refuse to clear."""
|
||
real = ss.load_state_for_profile
|
||
|
||
def _fake(**kwargs):
|
||
if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE:
|
||
return None
|
||
return real(**kwargs)
|
||
|
||
with patch.object(
|
||
self.mcp.mcp_session_state, "load_state_for_profile", side_effect=_fake
|
||
):
|
||
out = self._clear()
|
||
self.assertFalse(out["cleared"], out)
|
||
self.assertEqual(out["archive_failed_step"], "archive_readback")
|
||
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
|
||
|
||
def test_partial_archive_readback_retains_lock(self):
|
||
"""Read-back returns a record for a different PR/head: refuse to clear."""
|
||
real = ss.load_state_for_profile
|
||
|
||
def _fake(**kwargs):
|
||
if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE:
|
||
return {"archived_for_pr": 999, "archived_for_head": HEAD_B}
|
||
return real(**kwargs)
|
||
|
||
with patch.object(
|
||
self.mcp.mcp_session_state, "load_state_for_profile", side_effect=_fake
|
||
):
|
||
out = self._clear()
|
||
self.assertFalse(out["cleared"], out)
|
||
self.assertEqual(out["archive_failed_step"], "archive_readback")
|
||
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
|
||
|
||
def test_archive_failure_records_actionable_recovery_evidence(self):
|
||
with self._fail_archive_only(lambda: False):
|
||
out = self._clear()
|
||
self.assertFalse(out["cleared"], out)
|
||
self.assertTrue(out["retry_safe"], out)
|
||
self.assertIn("archival failed", out["reason"])
|
||
self.assertIsNotNone(out.get("prior_summary"), out)
|
||
# The recovery row is keyed by the active (merging) session profile.
|
||
recovery = ss.load_state_for_profile(
|
||
kind=ss.KIND_POST_MERGE_DECISION_RECOVERY,
|
||
profile_identity="prgs-merger",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNotNone(recovery, "no durable recovery evidence recorded")
|
||
self.assertEqual(recovery["failed_step"], "archive_save_state")
|
||
self.assertEqual(recovery["target_profile_identity"], "prgs-reviewer")
|
||
|
||
def test_successful_archive_clears_exactly_once(self):
|
||
out = self._clear()
|
||
self.assertTrue(out["cleared"], out)
|
||
self.assertTrue(out["archive_ok"], out)
|
||
self.assertIsNone(self._lock_still_present(), "lock should be cleared")
|
||
archived = ss.load_state_for_profile(
|
||
kind=ss.KIND_DECISION_LOCK_ARCHIVE,
|
||
profile_identity="prgs-reviewer-archive-pr100",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
skip_identity_match=True,
|
||
)
|
||
self.assertIsNotNone(archived, "archive not durable")
|
||
self.assertEqual(archived["archived_for_pr"], 100)
|
||
|
||
# A second clear is a no-op, not a duplicate clear.
|
||
again = self._clear()
|
||
self.assertFalse(again["cleared"], again)
|
||
|
||
def test_retry_after_archive_failure_succeeds(self):
|
||
with self._fail_archive_only(lambda: False):
|
||
first = self._clear()
|
||
self.assertFalse(first["cleared"], first)
|
||
self.assertIsNotNone(self._lock_still_present())
|
||
|
||
retry = self._clear()
|
||
self.assertTrue(retry["cleared"], retry)
|
||
self.assertIsNone(self._lock_still_present())
|
||
|
||
def test_no_alternate_path_clears_after_archive_failure(self):
|
||
"""The post-merge reconciler must not clear the lock when archival failed."""
|
||
with self._fail_archive_only(lambda: False), patch.object(
|
||
self.mcp, "api_request", return_value={}
|
||
), patch.object(
|
||
self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r"
|
||
):
|
||
report = self.mcp._reconcile_decision_locks_after_merge(
|
||
pr_number=100,
|
||
head_sha=HEAD_A,
|
||
merge_commit_sha="c" * 40,
|
||
remote="prgs",
|
||
host="h",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
auth={"Authorization": "token test"},
|
||
)
|
||
self.assertFalse(report.get("cleared_any"), report)
|
||
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|