Files
codex-app-server/cloud/tests/test_merge_gate_hard.py
T
host 61e08a1a8c feat: hard merge gate by default (CI + directory + contract + coordinator + reviews)
- cloud/merge_gate_hard.py (new): pure hard gate; mode resolution is
  env-only (unset/invalid -> hard, fail-closed); five checks plus
  branch protection must be protected; unknown/unprotected block
  even with explicit confirm; no-evidence blocks (fail-closed);
  directory check reuses #209 check_directory_isolation (v0.3 rule);
  blocks codes + merge_gate_blocked audit detail are the track-3
  pushback interface (no pushback state machine in this track).
- cloud/server.py: POST /tasks/{id}/merge uses hard mode by default;
  record/enforce/off only when explicitly set via env (preserving
  #209 semantics); body merge_gate_mode is ignored server-side so
  callers cannot downgrade the gate; every gate block writes a
  merge_gate_blocked audit before returning 409, status unchanged;
  hard-mode body type errors return 400.
- docs/merge-gate-branch-protection.md (new): protection payload
  (PR + strict status checks + no force-push + no deletion +
  enforce_admins), empty required_checks = unconfigured, Pro/public
  prerequisites, 403 causes; states protection is NOT claimed enabled.
- SKILL.md (gate-mechanism wording only): four checks -> five checks;
  body + coordinator + client appendix gate paragraphs updated to
  default hard, merge_gate_blocked audit, payload doc reference.
- tests: 31 new hard-gate tests (pure + endpoint + skill text);
  #209 endpoint tests now set record env explicitly (their intent);
  test_server_merge 200-path calls now carry full hard evidence.
2026-11-03 02:48:55 +00:00

454 lines
19 KiB
Python

"""服务端合并硬门禁(cloud/merge_gate_hard,默认 hard 模式)。
与 #209 的分工(不要把两套语义混成一套断言):
- #209 的纯函数与 record/enforce/off 语义原样保留,见
test_merge_gate_protectskill.py;本文件不重测 record 的
“无证据放行”,只测:record 仍是显式 env 才能选到的过渡模式。
- 本文件测硬模式:默认(env 未设或非法值)即 hard,五项全查、
缺一 409、状态不变、merge_gate_blocked 审计;unknown 保护
不等于 protected;显式确认在硬模式下不覆盖任何一项;
调用方请求体里的 merge_gate_mode 不能把硬门禁降级。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.merge_gate_hard import (
blocked_audit_detail, evaluate_hard_merge_gate, hard_mode_from_env)
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.merge_gate_hard import (
blocked_audit_detail, evaluate_hard_merge_gate, hard_mode_from_env)
from cloud.server import create_server
def _hard_kwargs(**overrides):
kwargs = {
"files_changed": ["apps/web/a.py"],
"owned_dir": "apps/web",
"protection": "protected",
"ci_status": "success",
"ci_checks": [{"name": "ci", "status": "success"}],
"required_checks": ["ci"],
"contract_version": "v2",
"contract_frozen": True,
"coordinator_approved": True,
"unresolved_review_comments": 0,
}
kwargs.update(overrides)
return kwargs
class HardModeEnvTestCase(unittest.TestCase):
def test_unset_and_invalid_mode_fall_to_hard(self):
self.assertEqual(hard_mode_from_env({}), "hard")
self.assertEqual(hard_mode_from_env(
{"CLOUD_MERGE_GATE_MODE": ""}), "hard")
self.assertEqual(hard_mode_from_env(
{"CLOUD_MERGE_GATE_MODE": "bogus"}), "hard")
self.assertEqual(hard_mode_from_env(
{"CLOUD_MERGE_GATE_MODE": "record"}), "record")
self.assertEqual(hard_mode_from_env(
{"CLOUD_MERGE_GATE_MODE": "HARD"}), "hard")
class HardGateUnitTestCase(unittest.TestCase):
def test_full_evidence_passes(self):
gate = evaluate_hard_merge_gate(**_hard_kwargs())
self.assertTrue(gate["allowed"])
self.assertEqual(gate["blocks"], [])
self.assertEqual(gate["mode"], "hard")
self.assertTrue(gate["ci"]["all_green"])
def test_no_evidence_blocks_every_missing_item(self):
gate = evaluate_hard_merge_gate()
self.assertFalse(gate["allowed"])
for code in ("ci_evidence_missing", "directory_evidence_missing",
"contract_version_missing", "contract_not_frozen",
"coordinator_approval_missing",
"review_evidence_missing",
"branch_protection_unknown"):
self.assertIn(code, gate["blocks"])
def test_ci_variants_block(self):
pending = evaluate_hard_merge_gate(**_hard_kwargs(ci_status="pending"))
self.assertIn("ci_not_green", pending["blocks"])
failed_check = evaluate_hard_merge_gate(**_hard_kwargs(
ci_checks=[{"name": "ci", "status": "failure"}]))
self.assertIn("ci_check_failed", failed_check["blocks"])
self.assertFalse(failed_check["ci"]["all_green"])
empty_required = evaluate_hard_merge_gate(**_hard_kwargs(
required_checks=[]))
self.assertIn("ci_required_checks_unconfigured",
empty_required["blocks"])
missing_required = evaluate_hard_merge_gate(**_hard_kwargs(
required_checks=["ci", "lint"]))
self.assertIn("ci_required_check_missing",
missing_required["blocks"])
bare_success = evaluate_hard_merge_gate(**_hard_kwargs(
ci_checks=None, required_checks=None))
self.assertIn("ci_evidence_missing", bare_success["blocks"])
def test_directory_reuses_v03_rule(self):
edge = evaluate_hard_merge_gate(**_hard_kwargs(
files_changed=["apps/web2/x.py"]))
self.assertIn("directory_isolation_violation", edge["blocks"])
escape = evaluate_hard_merge_gate(**_hard_kwargs(
files_changed=["../secret.py", "/abs.py"]))
self.assertEqual(escape["directory"]["violations"],
["../secret.py", "/abs.py"])
exc = evaluate_hard_merge_gate(**_hard_kwargs(
files_changed=["apps/web/a.py", "contracts/api.md"],
approved_exceptions=["contracts/api.md"]))
self.assertTrue(exc["allowed"])
self.assertEqual(exc["directory"]["exceptions_used"],
["contracts/api.md"])
no_dir = evaluate_hard_merge_gate(**_hard_kwargs(
files_changed=None))
self.assertIn("directory_evidence_missing", no_dir["blocks"])
no_owned = evaluate_hard_merge_gate(**_hard_kwargs(owned_dir=None))
self.assertIn("directory_owned_dir_unknown", no_owned["blocks"])
def test_contract_variants_block(self):
missing = evaluate_hard_merge_gate(**_hard_kwargs(
contract_version=None))
self.assertIn("contract_version_missing", missing["blocks"])
unfrozen = evaluate_hard_merge_gate(**_hard_kwargs(
contract_frozen=False))
self.assertIn("contract_not_frozen", unfrozen["blocks"])
mismatch = evaluate_hard_merge_gate(**_hard_kwargs(
contract_expected_version="v3"))
self.assertIn("contract_version_mismatch", mismatch["blocks"])
matched = evaluate_hard_merge_gate(**_hard_kwargs(
contract_expected_version="v2"))
self.assertTrue(matched["allowed"])
def test_coordinator_and_reviews_block(self):
missing = evaluate_hard_merge_gate(**_hard_kwargs(
coordinator_approved=None))
self.assertIn("coordinator_approval_missing", missing["blocks"])
denied = evaluate_hard_merge_gate(**_hard_kwargs(
coordinator_approved=False))
self.assertIn("coordinator_not_approved", denied["blocks"])
unresolved = evaluate_hard_merge_gate(**_hard_kwargs(
unresolved_review_comments=["c1"]))
self.assertIn("unresolved_review_comments",
unresolved["blocks"])
count = evaluate_hard_merge_gate(**_hard_kwargs(
unresolved_review_comments=2))
self.assertIn("unresolved_review_comments", count["blocks"])
missing_reviews = evaluate_hard_merge_gate(**_hard_kwargs(
unresolved_review_comments=None))
self.assertIn("review_evidence_missing",
missing_reviews["blocks"])
def test_protection_unknown_and_unprotected_block(self):
unknown = evaluate_hard_merge_gate(**_hard_kwargs(
protection="unknown"))
self.assertIn("branch_protection_unknown", unknown["blocks"])
illegal = evaluate_hard_merge_gate(**_hard_kwargs(
protection="bogus"))
self.assertEqual(illegal["protection"], "unknown")
self.assertIn("branch_protection_unknown", illegal["blocks"])
def test_unprotected_no_confirm_blocks(self):
gate = evaluate_hard_merge_gate(**_hard_kwargs(
protection="unprotected"))
self.assertFalse(gate["allowed"])
self.assertIn("branch_protection_unprotected", gate["blocks"])
def test_confirm_never_overrides_anything_in_hard_mode(self):
# 硬模式没有“确认即放行”:确认不是本函数的入参,调用方
# 即使在请求体里带 merge_gate_confirm,服务端硬路径也不读它;
# 这里钉住纯函数侧:未保护 + 目录违规同时存在时两项都拦。
gate = evaluate_hard_merge_gate(**_hard_kwargs(
protection="unprotected",
files_changed=["apps/web/a.py", "apps/ios/b.py"]))
self.assertIn("branch_protection_unprotected", gate["blocks"])
self.assertIn("directory_isolation_violation", gate["blocks"])
def test_blocked_audit_detail_format(self):
gate = evaluate_hard_merge_gate()
detail = blocked_audit_detail("task-1", gate)
self.assertTrue(detail.startswith("task-1 mode=hard blocks="))
self.assertIn("ci_evidence_missing", detail)
class HardGateEndpointTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
# 默认即 hard:每个用例开始时清掉模式/保护 env,个别用例
# 再显式设置 record/enforce/off/bogus。
self._saved_env = {k: os.environ.get(k) for k in
("CLOUD_MERGE_GATE_MODE",
"CLOUD_BRANCH_PROTECTION")}
os.environ.pop("CLOUD_MERGE_GATE_MODE", None)
os.environ.pop("CLOUD_BRANCH_PROTECTION", None)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
for key, value in self._saved_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _completed_task(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x"},
self.owner_key)
self.assertEqual(status, 201)
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET status = 'completed' WHERE id = ?",
(task["id"],))
conn.commit()
finally:
conn.close()
return task
def _hard_body(self, **overrides):
body = {
"files_changed": ["apps/web/a.py"],
"owned_dir": "apps/web",
"branch_protection": "protected",
"ci_status": "success",
"ci_checks": [{"name": "ci", "status": "success"}],
"required_checks": ["ci"],
"contract_version": "v2",
"contract_frozen": True,
"coordinator_approved": True,
"unresolved_review_comments": 0,
}
body.update(overrides)
return body
def _status_of(self, task_id):
_s, got = self._req("GET", "/tasks/%s" % task_id,
key=self.owner_key)
return got["status"]
def test_default_no_fields_blocks_status_unchanged_and_audits(self):
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key)
self.assertEqual(status, 409)
self.assertEqual(body["merge_gate"]["mode"], "hard")
self.assertIn("ci_evidence_missing",
body["merge_gate"]["blocks"])
self.assertEqual(self._status_of(task["id"]), "completed")
audit = self.auth.list_audit(self.project["id"])
blocked = [a for a in audit
if a["action"] == "merge_gate_blocked"]
self.assertTrue(blocked)
self.assertIn(task["id"], blocked[0]["detail"])
self.assertIn("ci_evidence_missing", blocked[0]["detail"])
def test_default_full_evidence_merges(self):
task = self._completed_task()
status, merged = self._req(
"POST", "/tasks/%s/merge" % task["id"],
self._hard_body(), self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(merged["status"], "merged")
self.assertEqual(merged["merge_gate"]["mode"], "hard")
self.assertEqual(merged["merge_gate"]["blocks"], [])
def test_default_missing_one_item_blocks(self):
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"],
self._hard_body(unresolved_review_comments=2),
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("unresolved_review_comments",
body["merge_gate"]["blocks"])
self.assertEqual(self._status_of(task["id"]), "completed")
def test_default_unknown_protection_blocks(self):
task = self._completed_task()
body = self._hard_body()
del body["branch_protection"]
status, resp = self._req(
"POST", "/tasks/%s/merge" % task["id"], body, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("branch_protection_unknown",
resp["merge_gate"]["blocks"])
def test_default_unprotected_blocks_even_with_confirm(self):
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"],
self._hard_body(branch_protection="unprotected",
merge_gate_confirm=True),
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("branch_protection_unprotected",
body["merge_gate"]["blocks"])
self.assertEqual(self._status_of(task["id"]), "completed")
def test_default_confirm_never_overrides_directory_violation(self):
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"],
self._hard_body(
files_changed=["apps/web/a.py", "apps/ios/b.py"],
merge_gate_confirm=True),
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("directory_isolation_violation",
body["merge_gate"]["blocks"])
def test_body_mode_cannot_downgrade_hard(self):
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"],
{"merge_gate_mode": "record"}, self.owner_key)
self.assertEqual(status, 409)
self.assertEqual(body["merge_gate"]["mode"], "hard")
def test_invalid_env_mode_falls_to_hard(self):
os.environ["CLOUD_MERGE_GATE_MODE"] = "bogus"
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key)
self.assertEqual(status, 409)
self.assertEqual(body["merge_gate"]["mode"], "hard")
def test_record_env_preserves_old_no_fields_behavior(self):
os.environ["CLOUD_MERGE_GATE_MODE"] = "record"
task = self._completed_task()
status, merged = self._req(
"POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(merged["status"], "merged")
def test_record_env_unprotected_confirm_still_passes_with_risk(self):
os.environ["CLOUD_MERGE_GATE_MODE"] = "record"
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"],
{"branch_protection": "unprotected"}, self.owner_key)
self.assertEqual(status, 409)
task2 = self._completed_task()
status, merged = self._req(
"POST", "/tasks/%s/merge" % task2["id"],
{"branch_protection": "unprotected",
"merge_gate_confirm": True},
self.owner_key)
self.assertEqual(status, 200)
audit = self.auth.list_audit(self.project["id"])
self.assertTrue(any(a["action"] == "merge_gate_risk"
for a in audit))
def test_enforce_env_blocks_unprotected_even_with_confirm(self):
os.environ["CLOUD_MERGE_GATE_MODE"] = "enforce"
os.environ["CLOUD_BRANCH_PROTECTION"] = "unprotected"
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"],
{"merge_gate_confirm": True}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("branch_protection_enforce",
body["merge_gate"]["blocks"])
def test_off_env_is_explicit_opt_down_only(self):
os.environ["CLOUD_MERGE_GATE_MODE"] = "off"
task = self._completed_task()
status, merged = self._req(
"POST", "/tasks/%s/merge" % task["id"],
{"branch_protection": "unprotected"}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(merged["merge_gate"]["mode"], "off")
def test_malformed_hard_body_returns_400(self):
task = self._completed_task()
status, _ = self._req(
"POST", "/tasks/%s/merge" % task["id"],
self._hard_body(ci_checks="ci"), self.owner_key)
self.assertEqual(status, 400)
status, _ = self._req(
"POST", "/tasks/%s/merge" % task["id"],
self._hard_body(unresolved_review_comments=True),
self.owner_key)
self.assertEqual(status, 400)
self.assertEqual(self._status_of(task["id"]), "completed")
class HardGateSkillTextTestCase(unittest.TestCase):
def test_hard_keywords_in_body_and_appendix(self):
# 正文换行会把“默认 hard”断成两行,断言前先把空白归一化。
import re
raw = (Path(__file__).resolve().parents[1] / "skills"
/ "software-engineering" / "SKILL.md").read_text(
encoding="utf-8")
text = re.sub(r"\s+", " ", raw)
main, sep, appendix = text.partition("## 附录:角色模板")
self.assertTrue(sep, "appendix marker missing")
coord = text.split("## 附录角色:00-coordinator", 1)[1].split(
"## 附录角色:01-", 1)[0]
client = text.split("## 附录角色:04-client-developer", 1)[1].split(
"## 附录角色:05-", 1)[0]
for kw in ("默认 hard", "merge_gate_blocked"):
self.assertIn(kw, main, "正文缺失:" + kw)
self.assertIn(kw, coord, "附录协调员章节缺失:" + kw)
self.assertIn(kw, client, "附录客户端章节缺失:" + kw)
self.assertIn("五项", main, "正文缺失:五项")
if __name__ == "__main__":
unittest.main()