Entry point B of #812 is the state where an author's work has already advanced to a local commit: the worktree is registered, clean, on the issue branch, and carries the only copy of the implementation, but the branch has never been published. Two individually correct predicates close a cycle around it: exact-owner lease renewal refuses without an observable remote head, and every publication path is lock-derived under #618, so nothing can create that remote head without first holding the lock renewal would grant.
This adds the missing operation. gitea_publish_unpublished_issue_branch publishes an already-committed local head to its remote branch, so exact-owner renewal has the evidence its model requires. Publication is the whole of its authority: it renews, reclaims, rebinds, and clears nothing.
Why this is not a lock bypass: the operation can only publish a branch whose durable issue-lock record already names the caller as claimant. Ownership is read from the lock file, never asserted by the caller. Guard strictness is unchanged (AC15): a dirty tree, an untracked file the commit does not carry, an unregistered worktree, a non-issue or stable branch, a changed local HEAD, a remote head that is not an ancestor of the commit, a competing open PR for the same issue on another branch, and any declared-hash mismatch each fail closed. The refspec names the commit SHA explicitly and never forces.
Record separation (AC23): the durable issue-lock file and the control-plane workflow lease are distinct records. This reads the former as ownership evidence and writes neither.
Truthful process evidence (AC24): the recorded owner pid's liveness is never consulted or asserted. A regression pins the recorded pid to a live process, proves publication still succeeds, and proves expired-lock reclaim still refuses for that same pid.
Scope is AC20 only. AC21 cannot unblock on its own, so the two are separable and only the smaller one is implemented here.
Tests: 36 new cases against synthetic fixtures only, using a real git repository with a real local bare remote so publication and read-after-write verification are genuinely executed rather than mocked. AC17 is honoured and a regression asserts the protected worktree is never referenced.
Full suite: 11 failed, 4354 passed, 6 skipped, 533 subtests passed. The 11 failures are the documented pre-existing drift baseline at 9eb0f29, unchanged in count and identity.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
275 lines
9.6 KiB
Python
275 lines
9.6 KiB
Python
"""Invariant tests pinning task_capability_map role assignments (#722/#723).
|
|
|
|
Added with the operator-authorized break-glass repair for incident #722:
|
|
commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every
|
|
configured merger profile forbids the review permissions, so no configured
|
|
profile could resolve any formal review task (``matching_configured_profile``
|
|
was empty repository-wide). These tests fail loudly if that class of
|
|
regression recurs:
|
|
|
|
- every role-exclusive formal-review task must be satisfiable by at least one
|
|
canonical role profile (permission AND role together);
|
|
- the capability map must agree with ``role_session_router`` task sets;
|
|
- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of
|
|
970e68b, preserved by the repair);
|
|
- merger profiles must not be able to resolve review_pr/approve_pr.
|
|
"""
|
|
|
|
import unittest
|
|
|
|
import gitea_config
|
|
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
|
from task_capability_map import (
|
|
ROLE_EXCLUSIVE_TASKS,
|
|
TASK_CAPABILITY_MAP,
|
|
required_permission,
|
|
required_role,
|
|
)
|
|
|
|
# Canonical role-profile permission shape. Mirrors the configured
|
|
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
|
# reviewers review/approve/request changes but never merge; mergers merge but
|
|
# never review/approve/request changes.
|
|
CANONICAL_ROLE_PROFILES = {
|
|
"author": {
|
|
"allowed": [
|
|
"gitea.read",
|
|
"gitea.branch.create",
|
|
"gitea.branch.push",
|
|
"gitea.repo.commit",
|
|
"gitea.pr.create",
|
|
"gitea.pr.comment",
|
|
"gitea.issue.create",
|
|
"gitea.issue.comment",
|
|
"gitea.issue.close",
|
|
],
|
|
"forbidden": [
|
|
"gitea.pr.approve",
|
|
"gitea.pr.request_changes",
|
|
"gitea.pr.merge",
|
|
],
|
|
},
|
|
"reviewer": {
|
|
"allowed": [
|
|
"gitea.read",
|
|
"gitea.pr.review",
|
|
"gitea.pr.approve",
|
|
"gitea.pr.request_changes",
|
|
"gitea.pr.comment",
|
|
"gitea.issue.comment",
|
|
],
|
|
"forbidden": [
|
|
"gitea.branch.create",
|
|
"gitea.branch.push",
|
|
"gitea.repo.commit",
|
|
"gitea.pr.create",
|
|
"gitea.pr.merge",
|
|
],
|
|
},
|
|
"merger": {
|
|
"allowed": [
|
|
"gitea.read",
|
|
"gitea.pr.merge",
|
|
"gitea.pr.comment",
|
|
"gitea.issue.comment",
|
|
],
|
|
"forbidden": [
|
|
"gitea.branch.create",
|
|
"gitea.branch.push",
|
|
"gitea.repo.commit",
|
|
"gitea.pr.create",
|
|
"gitea.pr.approve",
|
|
"gitea.pr.review",
|
|
"gitea.pr.request_changes",
|
|
],
|
|
},
|
|
"reconciler": {
|
|
"allowed": [
|
|
"gitea.read",
|
|
"gitea.pr.close",
|
|
"gitea.pr.comment",
|
|
"gitea.issue.comment",
|
|
"gitea.branch.delete",
|
|
"gitea.decision_lock.irrecoverable_recovery",
|
|
],
|
|
"forbidden": [
|
|
"gitea.pr.approve",
|
|
"gitea.pr.merge",
|
|
"gitea.pr.review",
|
|
"gitea.pr.request_changes",
|
|
"gitea.pr.create",
|
|
"gitea.branch.create",
|
|
"gitea.branch.push",
|
|
"gitea.repo.commit",
|
|
],
|
|
},
|
|
}
|
|
|
|
# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive
|
|
# handling for review work): permission alone is not enough — the profile's
|
|
# role kind must also match, so both dimensions are pinned here.
|
|
FORMAL_REVIEW_TASKS = (
|
|
"review_pr",
|
|
"approve_pr",
|
|
"request_changes_pr",
|
|
"blind_pr_queue_review",
|
|
"pr_queue_cleanup",
|
|
"pr-queue-cleanup",
|
|
)
|
|
|
|
# Complete resolver role-exclusive set on master when #723 was reconstructed.
|
|
# The shared constant must replace this exact inline authority without dropping
|
|
# later lease and PR-sync aliases added after the preserved source commits.
|
|
EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
|
|
{
|
|
"acquire_reviewer_pr_lease",
|
|
"gitea_acquire_reviewer_pr_lease",
|
|
"review_pr",
|
|
"approve_pr",
|
|
"request_changes_pr",
|
|
"blind_pr_queue_review",
|
|
"pr_queue_cleanup",
|
|
"pr-queue-cleanup",
|
|
"merge_pr",
|
|
"acquire_merger_pr_lease",
|
|
"gitea_acquire_merger_pr_lease",
|
|
"adopt_merger_pr_lease",
|
|
"gitea_adopt_merger_pr_lease",
|
|
"release_merger_pr_lease",
|
|
"gitea_release_merger_pr_lease",
|
|
"create_branch",
|
|
"push_branch",
|
|
# #812 AC20: publishing an unpublished local head is author-only for the
|
|
# same reason every other push is — it writes a branch to the remote.
|
|
"publish_unpublished_branch",
|
|
"create_pr",
|
|
"commit_files",
|
|
"gitea_commit_files",
|
|
"address_pr_change_requests",
|
|
"update_pr_branch_by_merge",
|
|
"gitea_update_pr_branch_by_merge",
|
|
"delete_branch",
|
|
"cleanup_merged_pr_branch",
|
|
"reconciliation_cleanup",
|
|
"work_issue",
|
|
"work-issue",
|
|
}
|
|
)
|
|
|
|
|
|
def _profile_satisfies(role_name, task):
|
|
"""True when the canonical *role_name* profile can perform *task*."""
|
|
profile = CANONICAL_ROLE_PROFILES[role_name]
|
|
ok, _reason = gitea_config.check_operation(
|
|
required_permission(task), profile["allowed"], profile["forbidden"]
|
|
)
|
|
return ok and role_name == required_role(task)
|
|
|
|
|
|
class TestFormalReviewProfileCoverage(unittest.TestCase):
|
|
"""#722: some configured profile must be able to formally review."""
|
|
|
|
def test_every_formal_review_task_has_a_satisfying_role_profile(self):
|
|
for task in FORMAL_REVIEW_TASKS:
|
|
with self.subTest(task=task):
|
|
satisfying = [
|
|
role
|
|
for role in CANONICAL_ROLE_PROFILES
|
|
if _profile_satisfies(role, task)
|
|
]
|
|
self.assertTrue(
|
|
satisfying,
|
|
f"no canonical role profile satisfies both permission "
|
|
f"{required_permission(task)!r} and role "
|
|
f"{required_role(task)!r} for task {task!r} — formal "
|
|
f"review would be impossible for every configured "
|
|
f"profile (incident #722)",
|
|
)
|
|
|
|
def test_formal_review_tasks_are_reviewer_role(self):
|
|
for task in FORMAL_REVIEW_TASKS:
|
|
with self.subTest(task=task):
|
|
self.assertEqual(required_role(task), "reviewer")
|
|
|
|
|
|
class TestMapRouterAgreement(unittest.TestCase):
|
|
"""#723 AC2: the map and the role session router must not drift."""
|
|
|
|
def test_reviewer_tasks_map_to_reviewer_role(self):
|
|
for task in sorted(REVIEWER_TASKS):
|
|
with self.subTest(task=task):
|
|
self.assertEqual(
|
|
required_role(task),
|
|
"reviewer",
|
|
f"router classifies {task!r} as a reviewer task but the "
|
|
f"capability map assigns role {required_role(task)!r}",
|
|
)
|
|
|
|
def test_merger_tasks_map_to_merger_role(self):
|
|
for task in sorted(MERGER_TASKS):
|
|
with self.subTest(task=task):
|
|
self.assertEqual(
|
|
required_role(task),
|
|
"merger",
|
|
f"router classifies {task!r} as a merger task but the "
|
|
f"capability map assigns role {required_role(task)!r}",
|
|
)
|
|
|
|
|
|
class TestMergerBoundary(unittest.TestCase):
|
|
"""Preserve the legitimate hunk of 970e68b and the merger fence."""
|
|
|
|
def test_adopt_merger_pr_lease_requires_merger_role(self):
|
|
self.assertEqual(required_role("adopt_merger_pr_lease"), "merger")
|
|
self.assertEqual(
|
|
required_permission("adopt_merger_pr_lease"), "gitea.pr.comment"
|
|
)
|
|
|
|
def test_merge_pr_requires_merger_role(self):
|
|
self.assertEqual(required_role("merge_pr"), "merger")
|
|
self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge")
|
|
|
|
def test_merger_profile_cannot_resolve_formal_review_tasks(self):
|
|
merger = CANONICAL_ROLE_PROFILES["merger"]
|
|
for task in ("review_pr", "approve_pr", "request_changes_pr"):
|
|
with self.subTest(task=task):
|
|
ok, reason = gitea_config.check_operation(
|
|
required_permission(task),
|
|
merger["allowed"],
|
|
merger["forbidden"],
|
|
)
|
|
self.assertFalse(
|
|
ok,
|
|
f"merger profile must not hold {task!r} permission "
|
|
f"(got reason {reason!r})",
|
|
)
|
|
|
|
|
|
class TestRoleExclusiveSetIntegrity(unittest.TestCase):
|
|
"""#723: the shared set is complete, mapped, and role-satisfiable."""
|
|
|
|
def test_complete_current_role_exclusive_set(self):
|
|
self.assertEqual(ROLE_EXCLUSIVE_TASKS, EXPECTED_ROLE_EXCLUSIVE_TASKS)
|
|
|
|
def test_every_role_exclusive_task_exists_in_capability_map(self):
|
|
for task in sorted(ROLE_EXCLUSIVE_TASKS):
|
|
with self.subTest(task=task):
|
|
self.assertIn(task, TASK_CAPABILITY_MAP)
|
|
|
|
def test_formal_review_tasks_are_role_exclusive(self):
|
|
self.assertTrue(set(FORMAL_REVIEW_TASKS) <= ROLE_EXCLUSIVE_TASKS)
|
|
|
|
def test_every_role_exclusive_task_has_a_satisfying_profile(self):
|
|
for task in sorted(ROLE_EXCLUSIVE_TASKS):
|
|
with self.subTest(task=task):
|
|
role = required_role(task)
|
|
self.assertIn(role, CANONICAL_ROLE_PROFILES)
|
|
self.assertTrue(
|
|
_profile_satisfies(role, task),
|
|
f"canonical {role!r} profile cannot satisfy {task!r}",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|