"""覆盖率 78%->85% 第 2 步:cloud/server.py 剩余缺口的真实 HTTP 测试。 沿用 test_server.py 的 create_server + http.client 模式:每个测试 用独立临时 SQLite 与临时端口,不跑模型、不联网。重点: _dispatch_project(项目 PATCH / 成员增删改查)、_create_task 的 校验分支与合法变体、任务 trace / trace-stream、GitHub webhook 去重与坏体分支。 """ import hashlib import hmac import http.client import json import os import sqlite3 import sys import tempfile import threading import unittest from pathlib import Path from unittest import mock 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 Server2TestCase(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._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS") os.environ["CODEX_GLOBAL_ADMINS"] = self.owner_id self.viewer_id, self.viewer_key = self.auth.create_user("viewer") self.outsider_id, self.outsider_key = self.auth.create_user( "outsider") 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.viewer_id, "viewer") def tearDown(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=5) try: self.auth.conn.close() except Exception: pass self.tmp.cleanup() if self._old_admins is None: os.environ.pop("CODEX_GLOBAL_ADMINS", None) else: os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins def _req(self, method, path, body=None, key=None, headers=None, raw_body=None): conn = http.client.HTTPConnection( "127.0.0.1", self.port, timeout=10) hdrs = dict(headers or {}) if raw_body is not None: payload = raw_body elif body is not None: payload = json.dumps(body).encode("utf-8") hdrs.setdefault("Content-Type", "application/json") else: payload = None 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 _create_task(self, key, **extra): body = {"project_id": self.project["id"], "prompt": "做一个加法函数"} body.update(extra) status, task = self._req("POST", "/tasks", body, key) self.assertEqual(status, 201, task) return task def _task_count(self): conn = sqlite3.connect(self.db_path) try: return conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] finally: conn.close() def _members_path(self): return "/projects/%s/members" % self.project["id"] # -- _dispatch_project:项目 PATCH -------------------------------- def test_project_patch_repo_url(self): pid = self.project["id"] status, body = self._req( "PATCH", "/projects/" + pid, {"repo_url": "https://github.com/org/repo.git"}, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["project"]["repo_url"], "https://github.com/org/repo.git") status, body = self._req( "PATCH", "/projects/" + pid, {"repo_url": ""}, self.owner_key) self.assertEqual(status, 200) self.assertIsNone(body["project"]["repo_url"]) status, body = self._req( "PATCH", "/projects/" + pid, {}, self.owner_key) self.assertEqual(status, 400) self.assertEqual(body["error"], "repo_url required") status, body = self._req( "PATCH", "/projects/" + pid, {"repo_url": 123}, self.owner_key) self.assertEqual(status, 400) status, body = self._req( "PATCH", "/projects/" + pid, {"repo_url": "https://user:pass@github.com/org/repo.git"}, self.owner_key) self.assertEqual(status, 400) status, body = self._req( "PATCH", "/projects/" + pid, {"repo_url": "https://github.com/org/repo.git"}, self.viewer_key) self.assertEqual(status, 403) status, body = self._req( "PATCH", "/projects/nope", {"repo_url": "https://github.com/org/repo.git"}, self.owner_key) self.assertEqual(status, 404) # -- _dispatch_project:成员 -------------------------------------- def test_project_members_flow(self): path = self._members_path() status, body = self._req("GET", path, key=self.owner_key) self.assertEqual(status, 200) roles = {m["username"]: m["role"] for m in body["members"]} self.assertEqual(roles, {"owner": "owner", "viewer": "viewer"}) status, body = self._req("GET", path, key=self.outsider_key) self.assertEqual(status, 403) # 邀请:按 username、默认角色 contributor。 status, body = self._req( "POST", path, {"username": "outsider"}, self.owner_key) self.assertEqual(status, 201, body) self.assertEqual(body["role"], "contributor") self.assertEqual(body["user_id"], self.outsider_id) # 外人已是成员:再邀 409;邀不存在的用户 404;坏角色 400; # 无目标 400;viewer 邀人 403。 status, _ = self._req( "POST", path, {"username": "outsider"}, self.owner_key) self.assertEqual(status, 409) status, body = self._req( "POST", path, {"username": "ghost"}, self.owner_key) self.assertEqual(status, 404) status, body = self._req( "POST", path, {"username": "viewer", "role": "super"}, self.owner_key) self.assertEqual(status, 400) status, body = self._req("POST", path, {}, self.owner_key) self.assertEqual(status, 400) status, _ = self._req( "POST", path, {"username": "viewer"}, self.viewer_key) self.assertEqual(status, 403) # 新成员立刻能建任务(角色真实生效)。 self._create_task(self.outsider_key) # 改角色:viewer -> contributor,之后 viewer 也能建任务。 status, body = self._req( "PATCH", path + "/" + self.viewer_id, {"role": "contributor"}, self.owner_key) self.assertEqual(status, 200) self.assertEqual(body["role"], "contributor") self._create_task(self.viewer_key) # 唯一 owner 降级 409;删非成员 404;viewer 改角色 403。 status, body = self._req( "PATCH", path + "/" + self.owner_id, {"role": "viewer"}, self.owner_key) self.assertEqual(status, 409) status, _ = self._req( "PATCH", path + "/nobody", {"role": "viewer"}, self.owner_key) self.assertEqual(status, 404) status, _ = self._req( "PATCH", path + "/" + self.viewer_id, {"role": "viewer"}, self.viewer_key) self.assertEqual(status, 403) # 删成员:删后读该项目任务 403、建任务 403;删唯一 owner 409。 status, body = self._req( "DELETE", path + "/" + self.outsider_id, key=self.owner_key) self.assertEqual(status, 200) task = self._create_task(self.owner_key) status, _ = self._req( "GET", "/tasks/" + task["id"], key=self.outsider_key) self.assertEqual(status, 403) status, _ = self._req( "POST", "/tasks", {"project_id": self.project["id"], "prompt": "x"}, self.outsider_key) self.assertEqual(status, 403) status, _ = self._req( "DELETE", path + "/" + self.owner_id, key=self.owner_key) self.assertEqual(status, 409) status, _ = self._req( "DELETE", path + "/nobody", key=self.owner_key) self.assertEqual(status, 404) status, _ = self._req("PUT", path, {}, self.owner_key) self.assertEqual(status, 404) status, _ = self._req( "GET", path + "/" + self.viewer_id, key=self.owner_key) self.assertEqual(status, 404) status, _ = self._req("GET", "/projects/nope/members", key=self.owner_key) self.assertEqual(status, 404) # -- _create_task:校验分支 --------------------------------------- def test_create_task_validation_errors(self): pid = self.project["id"] cases = [ ({"prompt": "x"}, "project_id required"), ({"project_id": pid}, "prompt required"), ({"project_id": pid, "prompt": "/goal"}, "goal 目标不能为空"), ({"project_id": pid, "prompt": "x", "depends_on": {"a": 1}}, "depends_on must be a list"), ({"project_id": pid, "prompt": "x", "priority": "abc"}, "priority must be an integer"), ({"project_id": pid, "prompt": "x", "executor": "bogus"}, "executor must be local, docker or cloud"), ({"project_id": pid, "prompt": "x", "model": " "}, "model must be a non-empty string"), ({"project_id": pid, "prompt": "x", "effort": "ultra"}, "effort must be low, medium, high, xhigh or max"), ({"project_id": pid, "prompt": "x", "approval": "sometimes"}, "approval must be ask, auto or full"), ({"project_id": pid, "prompt": "x", "child_model": " "}, "child_model must be a non-empty string"), ({"project_id": pid, "prompt": "x", "child_effort": "ultra"}, "child_effort must be low, medium, high, xhigh or max"), ({"project_id": pid, "prompt": "x", "plan": True, "approval": "full"}, "plan 模式与 approval=full 冲突"), ({"project_id": pid, "prompt": "x", "goal_mode": True, "verify_command": "make test"}, "goal 模式不需要验收命令"), ({"project_id": pid, "prompt": "x", "goal_mode": True, "plan": True}, "plan 模式与 goal 模式冲突"), ({"project_id": pid, "prompt": "x", "goal_max_rounds": 0}, "goal_max_rounds must be >= 1"), ({"project_id": pid, "prompt": "x", "goal_max_rounds": 101}, "goal_max_rounds 超过上限 100 轮"), ({"project_id": pid, "prompt": "x", "goal_max_rounds": "abc"}, "goal_max_rounds must be an integer"), ({"project_id": pid, "prompt": "x", "goal": 123}, "goal must be a string"), ({"project_id": pid, "prompt": "x", "verify_command": 123}, "verify_command must be a string"), ({"project_id": pid, "prompt": "x", "plan": True, "verify_command": "make test"}, "plan 模式与 goal 模式冲突"), ({"project_id": pid, "prompt": "x", "coordinator_parts": "not-a-list"}, "coordinator_parts must be a list"), ({"project_id": pid, "prompt": "x", "coordinator_parts": []}, "coordinator_parts must not be empty"), ({"project_id": pid, "prompt": "x", "coordinator_parts": [{"name": "a", "prompt": "do a"}], "goal_mode": True}, "协调员任务不跑模型回合"), ({"project_id": pid, "prompt": "x", "idempotency_key": 123}, "idempotency_key must be a string"), ({"project_id": pid, "prompt": "x", "environment_id": " "}, "environment_id must be a non-empty string"), ] for body, want_err in cases: with self.subTest(body=body): status, resp = self._req("POST", "/tasks", body, self.owner_key) self.assertEqual(status, 400, resp) self.assertIn(want_err, resp["error"]) # environment 不存在走 404(与上面 400 族不同)。 status, resp = self._req( "POST", "/tasks", {"project_id": pid, "prompt": "x", "environment_id": "nope"}, self.owner_key) self.assertEqual(status, 404) self.assertEqual(resp["error"], "environment not found") self.assertEqual(self._task_count(), 0) # 坏 JSON 体也 400。 status, resp = self._req("POST", "/tasks", key=self.owner_key, raw_body=b"{not json") self.assertEqual(status, 400) def test_create_task_valid_variants(self): task = self._create_task(self.owner_key, depends_on="dep-1") self.assertEqual(task["depends_on"], ["dep-1"]) task = self._create_task(self.owner_key, prompt="/goal 收邮件") self.assertEqual(task["goal_mode"], 1) self.assertEqual(task["goal"], "收邮件") task = self._create_task( self.owner_key, coordinator_parts=[{"name": "a", "prompt": "do a"}, {"name": "b", "prompt": "do b"}]) self.assertTrue(task["coordinator_parts"]) task = self._create_task(self.owner_key, plan=True, approval="ask") self.assertEqual(task["plan"], 1) task = self._create_task(self.owner_key, executor="local", effort="high", model=" m1 ", child_model="cm", child_effort="low") self.assertEqual(task["model"], "m1") self.assertEqual(task["executor"], "local") # -- trace / trace-stream ----------------------------------------- def test_task_trace_and_stream(self): task = self._create_task(self.owner_key) tid = task["id"] status, body = self._req("GET", "/tasks/%s/trace" % tid, key=self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["task_id"], tid) self.assertIn("entries", body) status, body = self._req("GET", "/tasks/%s/trace" % tid, key=self.outsider_key) self.assertEqual(status, 403) status, body = self._req("GET", "/tasks/nope/trace", key=self.owner_key) self.assertEqual(status, 404) status, body = self._req("GET", "/tasks/%s/agents" % tid, key=self.owner_key) self.assertEqual(status, 200) self.assertIn("agents", body) # 终态任务的 stream 立刻以 done 收尾(不进 3600 秒轮询)。 conn = sqlite3.connect(self.db_path) try: conn.execute("UPDATE tasks SET status = 'completed' " "WHERE id = ?", (tid,)) conn.commit() finally: conn.close() conn = http.client.HTTPConnection( "127.0.0.1", self.port, timeout=10) try: conn.request("GET", "/tasks/%s/trace/stream" % tid, headers={"X-API-Key": self.owner_key}) resp = conn.getresponse() self.assertEqual(resp.status, 200) self.assertIn("text/event-stream", resp.getheader("Content-Type")) raw = resp.read().decode("utf-8") finally: conn.close() events = [json.loads(ln[6:]) for ln in raw.splitlines() if ln.startswith("data: ")] self.assertEqual(events[0]["type"], "snapshot") self.assertEqual(events[-1]["type"], "done") self.assertEqual(events[-1]["status"], "completed") status, _ = self._req("GET", "/tasks/nope/trace/stream", key=self.owner_key) self.assertEqual(status, 404) status, _ = self._req("GET", "/tasks/%s/trace/stream" % tid, key=self.outsider_key) self.assertEqual(status, 403) # -- GitHub webhook:去重与坏体 ------------------------------------ def _github_post(self, payload, raw=None, delivery="d-1", event="issue_comment"): if raw is None: raw = json.dumps(payload).encode("utf-8") sig = "sha256=" + hmac.new( b"test-secret", raw, hashlib.sha256).hexdigest() return self._req( "POST", "/webhooks/github", raw_body=raw, headers={"Content-Type": "application/json", "X-Hub-Signature-256": sig, "X-GitHub-Event": event, "X-GitHub-Delivery": delivery}) def test_github_webhook_duplicate_and_bad_bodies(self): payload = { "action": "created", "comment": {"body": "请 /fix-review 处理", "user": {"login": "alice"}}, "repository": {"full_name": "org/repo"}, "issue": {"number": 7}, } with mock.patch.dict( os.environ, {"GITHUB_WEBHOOK_SECRET": "test-secret"}): status, body = self._github_post(payload) self.assertEqual(status, 200) self.assertEqual(body["type"], "fix_review") # 无匹配项目:不建任务,task_id 为 None。 self.assertIsNone(body.get("task_id")) status, body = self._github_post(payload) self.assertEqual(status, 200) self.assertTrue(body["duplicate"]) self.assertEqual(body["delivery_id"], "d-1") status, body = self._github_post( payload, raw=b"{broken", delivery="d-2") self.assertEqual(status, 400) status, body = self._github_post( payload, raw=b"[1, 2]", delivery="d-3") self.assertEqual(status, 400) if __name__ == "__main__": unittest.main()