291 lines
10 KiB
Python
291 lines
10 KiB
Python
"""Tests for audit vs cleanup reconciliation mode (#419)."""
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import audit_reconciliation_mode as arm
|
|
import mcp_server
|
|
from audit_reconciliation_mode import (
|
|
AUDIT_FORBIDDEN_TASKS,
|
|
PHASE_AUDIT,
|
|
PHASE_CLEANUP,
|
|
assess_audit_command_allowed,
|
|
assess_audit_reconciliation_report,
|
|
authorize_cleanup_phase,
|
|
check_audit_mutation_allowed,
|
|
check_cleanup_execution_allowed,
|
|
classify_cleanup_mutation,
|
|
clear_phase,
|
|
enter_audit_phase,
|
|
)
|
|
from final_report_validator import assess_final_report_validator
|
|
from review_proofs import assess_audit_reconciliation_report as proofs_assess
|
|
from task_capability_map import required_permission, required_role
|
|
|
|
DELETE_PROFILE = {
|
|
"profile_name": "prgs-author-delete",
|
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
|
"forbidden_operations": [],
|
|
"audit_label": "prgs-author-delete",
|
|
}
|
|
|
|
READ_PROFILE = {
|
|
"profile_name": "prgs-author",
|
|
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
|
|
"forbidden_operations": ["gitea.branch.delete"],
|
|
"audit_label": "prgs-author",
|
|
}
|
|
|
|
READ_ENV = {
|
|
"GITEA_MCP_CONFIG": os.path.join(
|
|
os.path.dirname(__file__), "..", "profiles.json"
|
|
),
|
|
"GITEA_MCP_PROFILE": "prgs-author",
|
|
}
|
|
|
|
|
|
def _authorize_cleanup(**kwargs):
|
|
defaults = {
|
|
"operator_approved": True,
|
|
"delete_capability_proven": True,
|
|
"safety_proof": {"safe_to_delete_remote": True},
|
|
"before_after_snapshot": {
|
|
"before": "remote branch exists",
|
|
"after": "remote branch absent",
|
|
},
|
|
}
|
|
defaults.update(kwargs)
|
|
return authorize_cleanup_phase(**defaults)
|
|
|
|
|
|
def _cleanup_report(**overrides):
|
|
base = (
|
|
"Task mode: reconcile-landed-pr\n"
|
|
"Workflow source: workflows/reconcile-landed-pr.md\n"
|
|
"Audit phase: read-only assessment\n"
|
|
"Cleanup phase authorized: true\n"
|
|
"Delete-branch capability proven: true\n"
|
|
"Branch safe to remove: true\n"
|
|
"Before/after state snapshot: remote branch feat/x present → absent\n"
|
|
"External-state mutations: deleted remote branch feat/x\n"
|
|
"Git ref mutations: none\n"
|
|
"Cleanup mutations: removed worktree branches/feat-x\n"
|
|
)
|
|
for key, value in overrides.items():
|
|
base = base.replace(key, value)
|
|
return base
|
|
|
|
|
|
class TestAuditPhaseGates(unittest.TestCase):
|
|
def setUp(self):
|
|
clear_phase()
|
|
enter_audit_phase("reconcile-landed-pr")
|
|
|
|
def tearDown(self):
|
|
clear_phase()
|
|
|
|
def test_audit_blocks_delete_branch_task(self):
|
|
allowed, reasons = check_audit_mutation_allowed("delete_branch")
|
|
self.assertFalse(allowed)
|
|
self.assertTrue(reasons)
|
|
|
|
def test_audit_blocks_worktree_shell_commands(self):
|
|
allowed, reasons = assess_audit_command_allowed(
|
|
"git worktree remove branches/feat-x"
|
|
)
|
|
self.assertFalse(allowed)
|
|
self.assertTrue(reasons)
|
|
|
|
def test_audit_blocks_local_branch_delete_command(self):
|
|
allowed, reasons = assess_audit_command_allowed("git branch -D feat/x")
|
|
self.assertFalse(allowed)
|
|
|
|
def test_audit_allows_read_tasks(self):
|
|
allowed, _ = check_audit_mutation_allowed("reconcile_landed_pr")
|
|
self.assertTrue(allowed)
|
|
|
|
def test_forbidden_set_covers_issue_and_pr_mutations(self):
|
|
for task in ("close_pr", "comment_issue", "commit_files", "push_branch"):
|
|
self.assertIn(task, AUDIT_FORBIDDEN_TASKS)
|
|
|
|
|
|
class TestCleanupAuthorization(unittest.TestCase):
|
|
def setUp(self):
|
|
clear_phase()
|
|
enter_audit_phase("reconcile_merged_cleanups")
|
|
|
|
def tearDown(self):
|
|
clear_phase()
|
|
|
|
def test_cleanup_without_approval_blocked(self):
|
|
result = authorize_cleanup_phase(
|
|
delete_capability_proven=True,
|
|
safety_proof={"safe_to_delete_remote": True},
|
|
before_after_snapshot={"before": "a", "after": "b"},
|
|
)
|
|
self.assertFalse(result["authorized"])
|
|
|
|
def test_cleanup_without_capability_proof_blocked(self):
|
|
result = _authorize_cleanup(delete_capability_proven=False)
|
|
self.assertFalse(result["authorized"])
|
|
|
|
def test_cleanup_without_snapshot_blocked(self):
|
|
result = _authorize_cleanup(before_after_snapshot={"before": "", "after": ""})
|
|
self.assertFalse(result["authorized"])
|
|
|
|
def test_authorized_cleanup_switches_phase(self):
|
|
result = _authorize_cleanup()
|
|
self.assertTrue(result["authorized"])
|
|
self.assertEqual(result["phase"], PHASE_CLEANUP)
|
|
|
|
def test_cleanup_execution_allowed_only_after_authorization(self):
|
|
self.assertFalse(check_cleanup_execution_allowed()[0])
|
|
_authorize_cleanup()
|
|
self.assertTrue(check_cleanup_execution_allowed()[0])
|
|
|
|
|
|
class TestReportVerifier(unittest.TestCase):
|
|
def test_false_no_mutations_after_cleanup_blocked(self):
|
|
report = (
|
|
"Task mode: reconcile-landed-pr\n"
|
|
"No mutations performed.\n"
|
|
"delete_remote_branch feat/dup\n"
|
|
)
|
|
result = assess_audit_reconciliation_report(report)
|
|
self.assertFalse(result["proven"])
|
|
self.assertIn("no mutations", result["reasons"][0].lower())
|
|
|
|
def test_cleanup_without_authorization_fields_blocked(self):
|
|
report = (
|
|
"Task mode: reconcile-landed-pr\n"
|
|
"remove_local_worktree branches/feat-x\n"
|
|
)
|
|
result = assess_audit_reconciliation_report(report)
|
|
self.assertFalse(result["proven"])
|
|
|
|
def test_authorized_cleanup_report_passes(self):
|
|
result = assess_audit_reconciliation_report(_cleanup_report())
|
|
self.assertTrue(result["proven"])
|
|
|
|
def test_mutation_classification_enforced(self):
|
|
report = (
|
|
"Task mode: reconcile-landed-pr\n"
|
|
"Cleanup phase authorized: true\n"
|
|
"Delete-branch capability proven: true\n"
|
|
"Branch safe to remove: true\n"
|
|
"Before/after state snapshot: present\n"
|
|
"remove_local_worktree branches/feat-x\n"
|
|
)
|
|
result = assess_audit_reconciliation_report(report)
|
|
self.assertFalse(result["proven"])
|
|
|
|
def test_proofs_export_matches_module(self):
|
|
report = "No mutations performed.\ndelete_remote_branch feat/dup"
|
|
self.assertEqual(
|
|
proofs_assess(report)["proven"],
|
|
assess_audit_reconciliation_report(report)["proven"],
|
|
)
|
|
|
|
def test_final_report_validator_includes_boundary_rule(self):
|
|
report = "No mutations performed.\ndelete_remote_branch feat/dup"
|
|
result = assess_final_report_validator(
|
|
report_text=report,
|
|
task_kind="reconcile_already_landed",
|
|
)
|
|
self.assertTrue(result["blocked"])
|
|
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
self.assertIn("reconcile.audit_cleanup_boundary", rule_ids)
|
|
|
|
|
|
class TestMutationClassification(unittest.TestCase):
|
|
def test_remote_delete_is_external_state(self):
|
|
self.assertEqual(
|
|
classify_cleanup_mutation("delete_remote_branch"),
|
|
"external-state",
|
|
)
|
|
|
|
def test_worktree_remove_is_cleanup(self):
|
|
self.assertEqual(
|
|
classify_cleanup_mutation("remove_local_worktree"),
|
|
"cleanup",
|
|
)
|
|
|
|
|
|
class TestMcpGates(unittest.TestCase):
|
|
def setUp(self):
|
|
clear_phase()
|
|
enter_audit_phase("reconcile_merged_cleanups")
|
|
self.mock_api = patch("mcp_server.api_request").start()
|
|
self.mock_auth = patch(
|
|
"mcp_server.get_auth_header", return_value="token test"
|
|
).start()
|
|
|
|
def tearDown(self):
|
|
patch.stopall()
|
|
clear_phase()
|
|
mcp_server._IDENTITY_CACHE.clear()
|
|
|
|
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
|
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
|
mcp_server.record_preflight_check("whoami")
|
|
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
|
self.mock_api.assert_not_called()
|
|
|
|
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
|
def test_reconcile_execute_blocked_without_cleanup_auth(self, _profile):
|
|
with self.assertRaises(ValueError):
|
|
mcp_server.gitea_reconcile_merged_cleanups(
|
|
dry_run=False,
|
|
execute_confirmed=False,
|
|
remote="prgs",
|
|
)
|
|
|
|
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
@patch("mcp_server.get_profile", return_value=READ_PROFILE)
|
|
def test_cleanup_auth_fails_without_delete_capability(self, _profile):
|
|
result = mcp_server.gitea_authorize_reconciliation_cleanup_phase(
|
|
operator_approved=True,
|
|
delete_capability_proven=True,
|
|
safe_to_delete_remote=True,
|
|
before_state="exists",
|
|
after_state="gone",
|
|
)
|
|
self.assertFalse(result["authorized"])
|
|
self.assertFalse(result["delete_capability_verified"])
|
|
|
|
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
|
def test_delete_branch_allowed_after_cleanup_authorization(self, _profile):
|
|
mcp_server.gitea_authorize_reconciliation_cleanup_phase(
|
|
operator_approved=True,
|
|
delete_capability_proven=True,
|
|
safe_to_delete_remote=True,
|
|
before_state="exists",
|
|
after_state="gone",
|
|
)
|
|
mcp_server.record_preflight_check("whoami")
|
|
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
self.mock_api.return_value = {}
|
|
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
|
self.assertTrue(result["success"])
|
|
|
|
|
|
class TestTaskCapabilityMap(unittest.TestCase):
|
|
def test_reconciliation_cleanup_maps_delete_permission(self):
|
|
self.assertEqual(
|
|
required_permission("reconciliation_cleanup"),
|
|
"gitea.branch.delete",
|
|
)
|
|
self.assertEqual(required_role("reconciliation_cleanup"), "reconciler")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main() |