coverage85-step2b: server/worker 剩余缺口真实行为测试(+24 项)
- server:_dispatch_project(项目 PATCH repo_url、成员邀请/改角 色/删除与 404/403/409 族)、_create_task 校验 400 族与合法变体 (goal/协调员/plan/环境)、trace/agents/trace-stream(终态 SSE snapshot+done)、GitHub webhook 去重与坏体。 - worker:_gateway_context(xcmapi/缺 origin/缺属主/假 sub2api key 与协调员父属主回查)、_ensure_gateway 缓存、_docker_task_ context(代理/租户网络失败/搜索透传)、_deliver_marker_tools (守卫空跑、创建派生、goal 转换、轮询快照)、协调员孤儿扫尾、 run_one/run_all/run_loop/main 空库路径。 生产代码零改动。
This commit is contained in:
1 parent
576fd50ee3
commit
3d2df7dfe6
2 files changed
+807
No files matched your search
@@ -0,0 +1,420 @@
|
||||
"""覆盖率 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):
|
||||
pid = self.project["id"]
|
||||
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()
|
||||
Reference in new issue
Block a user