Server POST /tasks/{id}/merge no longer has record/enforce/off:
hard_mode_from_env and HARD_MODES deleted (not re-defaulted),
handler always evaluates the hard gate, env CLOUD_MERGE_GATE_MODE
and body merge_gate_mode are ignored server-side. The record
'no-evidence passes' and 'unprotected+confirm passes with
merge_gate_risk' server paths are gone with the dead risk audit;
no production caller justified keeping them (only production
caller of the non-hard evaluator was the merge handler itself;
audit_loop_wiring already used the hard gate). cloud/merge_gate
pure functions keep their #209 semantics but are documented as
non-server: they are no longer wired to any endpoint.
Server-level record/enforce/off tests rewritten to the new
behavior (renamed, not deleted silently); pure-function tests
unchanged.
(cherry picked from commit 08e75edbd6d04701965c5db951fa054cfbb75bb0)
Reconcile-port: handler_lifecycle.py hunks redirected into cloud/server.py _merge_task (target keeps 6-mixin split; mixin file does not exist here); handler_support.py kept at HEAD (its A1 hunk is a docstring of _validate_hard_gate_body, ported to server.py copy); test mock paths cloud.handler_lifecycle.* rewritten to cloud.server.*.
199 lines
7.6 KiB
Python
199 lines
7.6 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 下的“无证据放行”已随 A1 删模式消失,不再覆盖。
|
|
"""
|
|
|
|
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_no_longer_passes_no_evidence_call(self):
|
|
# A1:record 环境变量在服务端被忽略,无证据调用按 hard 409。
|
|
os.environ["CLOUD_MERGE_GATE_MODE"] = "record"
|
|
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_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()
|