Files
codex-app-server/cloud/tests/test_server_merge.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

198 lines
7.5 KiB
Python

"""云端合并状态端点测试:POST /tasks/{id}/merge。
只测状态同步本身,不执行真实合并:合并仍由 host.py merge
(本地或 Docker 容器内)执行,成功后再调本接口把云端
任务从 completed 改为 merged。规则:404/401/403/409
语义、maintainer 以上才能合并、审计里有 merge_task。
门禁注意:默认模式已是 hard(见 test_merge_gate_hard.py),
所以本文件里期望 200 的合并调用必须带完整硬门禁证据;
无证据调用在默认下 409(本文件末尾两项新用例钉住它),
record 下的旧行为由硬门禁测试文件覆盖。
"""
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.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server
class MergeTestCase(unittest.TestCase):
def _hard_body(self):
return {
"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,
}
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
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)
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.maint_id, self.maint_key = self.auth.create_user("maint")
self.contrib_id, self.contrib_key = self.auth.create_user("contrib")
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)
self.auth.set_member(
self.project["id"], self.owner_id, self.maint_id, "maintainer")
self.auth.set_member(
self.project["id"], self.owner_id, self.contrib_id, "contributor")
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 test_merge_completed_by_owner(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")
status, got = self._req(
"GET", "/tasks/%s" % task["id"], key=self.owner_key)
self.assertEqual(merged["status"], got["status"])
audit = self.auth.list_audit(self.project["id"])
self.assertTrue(any(a["action"] == "merge_task" for a in audit))
def test_merge_by_maintainer_ok_contributor_forbidden(self):
task = self._completed_task()
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"], {}, self.contrib_key)
self.assertEqual(status, 403)
status, merged = self._req(
"POST", "/tasks/%s/merge" % task["id"],
self._hard_body(), self.maint_key)
self.assertEqual(status, 200)
self.assertEqual(merged["status"], "merged")
def test_merge_non_completed_409_and_repeat_409(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x"},
self.owner_key)
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key)
self.assertEqual(status, 409)
self.assertEqual(body["status"], "queued")
done = self._completed_task()
status, _ = self._req(
"POST", "/tasks/%s/merge" % done["id"],
self._hard_body(), self.owner_key)
self.assertEqual(status, 200)
status, body = self._req(
"POST", "/tasks/%s/merge" % done["id"], {}, self.owner_key)
self.assertEqual(status, 409)
self.assertEqual(body["status"], "merged")
def test_merge_default_hard_blocks_no_evidence_call(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")
status, got = self._req(
"GET", "/tasks/%s" % task["id"], key=self.owner_key)
self.assertEqual(got["status"], "completed")
def test_merge_record_env_preserves_old_no_evidence_call(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_merge_missing_and_no_key(self):
status, _ = self._req(
"POST", "/tasks/nope/merge", {}, self.owner_key)
self.assertEqual(status, 404)
task = self._completed_task()
status, _ = self._req("POST", "/tasks/%s/merge" % task["id"], {})
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()