Files
codex-app-server/cloud/tests/test_coverage85_server2.py
T

420 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""覆盖率 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()