From 3b7d45c9a2b31f788f7cbaa20a93eae75951200b Mon Sep 17 00:00:00 2001 From: host Date: Sat, 31 Oct 2026 09:28:06 +0000 Subject: [PATCH] =?UTF-8?q?coverage85-step4c:=20server=20=E9=95=BF?= =?UTF-8?q?=E5=B0=BE=E7=AB=AF=E7=82=B9=E4=B8=8E=E5=AD=90=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88+19=20=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloud/tests/test_coverage85_server3.py | 614 +++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 cloud/tests/test_coverage85_server3.py diff --git a/cloud/tests/test_coverage85_server3.py b/cloud/tests/test_coverage85_server3.py new file mode 100644 index 0000000..a7bb40c --- /dev/null +++ b/cloud/tests/test_coverage85_server3.py @@ -0,0 +1,614 @@ +"""覆盖率 78%->85% 第 4 步:cloud/server.py 剩余缺口的真实 HTTP 测试。 + +沿用 test_coverage85_server2 的 create_server + http.client 模式: +每个测试用独立临时 SQLite 与临时端口,不跑模型、不联网。 +重点:_dispatch 的 404/限流分支、_dispatch_project 各子路由的 +权限拒绝与错方法分支、identity/memory/kb/cost 等子路由、 +continue/wait/followup/result-full 的校验分支、create_task 的 +幂等与附件校验分支。 +""" + +import http.client +import json +import os +import sqlite3 +import tempfile +import threading +import unittest + +from cloud.auth import AuthStore +from cloud.server import create_server + + +class Server3TestCase(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 _pid(self): + return self.project["id"] + + def _create_task(self, key, **extra): + body = {"project_id": self._pid(), "prompt": "做一个加法函数"} + body.update(extra) + status, task = self._req("POST", "/tasks", body, key) + self.assertEqual(status, 201, task) + return task + + def _set_status(self, tid, status): + conn = sqlite3.connect(self.db_path) + try: + conn.execute("UPDATE tasks SET status = ? WHERE id = ?", + (status, tid)) + conn.commit() + finally: + conn.close() + + +class DispatchBranchesTest(Server3TestCase): + def test_oauth_and_keys_and_webhook_404s(self): + pid = self._pid() + cases = [ + ("POST", "/oauth/github/login", None, 404), + ("POST", "/oauth/github/callback", {}, 400), + ("GET", "/oauth/github/bindings/extra", None, 404), + ("DELETE", "/users/me/keys/a/b", None, 404), + ("POST", "/users/me/keys/bogus-action", {}, 404), + ("GET", "/github-app/nope", None, 404), + ("GET", "/webhooks/github", None, 404), + ("POST", "/webhooks/nope", {}, 404), + ("GET", "/users/me/memories/abc", None, 404), + ("GET", "/tasks/nope/bogus-suffix", None, 404), + ("PUT", "/projects/%s/members" % pid, {}, 404), + ("GET", "/projects/%s/bogus" % pid, None, 404), + ] + for method, path, body, want in cases: + with self.subTest(path=path): + status, _ = self._req(method, path, body, self.owner_key) + self.assertEqual(status, want) + + def test_rate_limiter_recreated_when_missing(self): + # server.rate_limiter 置空后下一请求重建限流器,不 500。 + self.server.rate_limiter = None + status, body = self._req("GET", "/projects", key=self.owner_key) + self.assertEqual(status, 200) + self.assertIsNotNone(self.server.rate_limiter) + + def test_unauthenticated_and_rate_limited(self): + status, body = self._req("GET", "/projects") + self.assertEqual(status, 401) + # 新用户的新 key:setUp 的请求不计入它的窗口。 + _fresh_id, fresh_key = self.auth.create_user("fresh") + self.server.rate_limit_per_minute = 1 + try: + status, _ = self._req("GET", "/projects", key=fresh_key) + self.assertEqual(status, 200) + status, body = self._req("GET", "/projects", + key=fresh_key) + self.assertEqual(status, 429) + self.assertEqual(body["error"], "rate_limited") + finally: + self.server.rate_limit_per_minute = None + + def test_health_with_broken_db(self): + # 第二台 server:建好后把库文件换成目录,_connect 失败, + # health 仍 200 但 db_ok 为 False(不炸 500)。 + db2 = os.path.join(self.tmp.name, "db2.sqlite") + bad = create_server(db2, "127.0.0.1", 0) + port = bad.server_address[1] + th = threading.Thread(target=bad.serve_forever, daemon=True) + th.start() + try: + os.remove(db2) + os.mkdir(db2) + conn = http.client.HTTPConnection( + "127.0.0.1", port, timeout=10) + try: + conn.request("GET", "/health") + resp = conn.getresponse() + data = json.loads(resp.read().decode("utf-8")) + finally: + conn.close() + self.assertEqual(resp.status, 200) + self.assertFalse(data["db_ok"]) + finally: + bad.shutdown() + bad.server_close() + th.join(timeout=5) + + def test_result_full_variants(self): + task = self._create_task(self.owner_key) + tid = task["id"] + status, body = self._req("GET", "/tasks/%s/result-full" % tid, + key=self.owner_key) + self.assertEqual(status, 404, body) + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE tasks SET result_text_full = ? WHERE id = ?", + ("full-result-text", tid)) + conn.commit() + finally: + conn.close() + status, body = self._req("GET", "/tasks/%s/result-full" % tid, + key=self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["result_text_full"], "full-result-text") + status, _ = self._req("GET", "/tasks/%s/result-full" % tid, + key=self.outsider_key) + self.assertEqual(status, 403) + status, _ = self._req("GET", "/tasks/nope/result-full", + key=self.owner_key) + self.assertEqual(status, 404) + + +class ProjectSubrouteSweepTest(Server3TestCase): + def test_outsider_403_sweep(self): + pid = self._pid() + routes = [ + ("POST", "/projects/%s/identity-memories" % pid, + {"kind": "fact", "content": "x"}), + ("GET", "/projects/%s/identity-memories" % pid, None), + ("DELETE", "/projects/%s/identity-memories/m1" % pid, None), + ("POST", "/projects/%s/identity-memories/m1/supersede" % pid, + {}), + ("POST", "/projects/%s/memory" % pid, + {"goal": "g", "conclusion": "c"}), + ("GET", "/projects/%s/memory" % pid, None), + ("POST", "/projects/%s/memory/1/supersede" % pid, {}), + ("POST", "/projects/%s/kb/index" % pid, + {"path": "a.md", "content": "hi"}), + ("GET", "/projects/%s/kb/search?q=hi" % pid, None), + ("GET", "/projects/%s/cost" % pid, None), + ("POST", "/projects/%s/push/subscriptions" % pid, {}), + ("GET", "/projects/%s/push/subscriptions" % pid, None), + ("DELETE", "/projects/%s/push/subscriptions/s1" % pid, None), + ("POST", "/projects/%s/automations" % pid, {}), + ("GET", "/projects/%s/automations" % pid, None), + ("GET", "/projects/%s/automations/a1" % pid, None), + ("PATCH", "/projects/%s/automations/a1" % pid, {}), + ("POST", "/projects/%s/automations/a1/cancel" % pid, {}), + ("GET", "/projects/%s/automations/a1/bind" % pid, None), + ("POST", "/projects/%s/automations/a1/bind" % pid, {}), + ("DELETE", "/projects/%s/automations/a1/bind" % pid, None), + ("POST", "/projects/%s/automations/a1/heartbeat" % pid, {}), + ("GET", "/projects/%s/triage" % pid, None), + ("GET", "/projects/%s/todos" % pid, None), + ("POST", "/projects/%s/todos" % pid, {"title": "t"}), + ("GET", "/projects/%s/todos/t1" % pid, None), + ("PATCH", "/projects/%s/todos/t1" % pid, {}), + ("DELETE", "/projects/%s/todos/t1" % pid, None), + ("GET", "/projects/%s/skills" % pid, None), + ("GET", "/projects/%s/apps" % pid, None), + ("GET", "/projects/%s/mcp-servers" % pid, None), + ("POST", "/projects/%s/mcp-servers/reload" % pid, {}), + ("POST", "/projects/%s/mcp-servers/s1/oauth-login" % pid, {}), + ] + for method, path, body in routes: + with self.subTest(path=path): + status, resp = self._req(method, path, body, + self.outsider_key) + self.assertEqual(status, 403, resp) + + def test_wrong_method_404_sweep(self): + pid = self._pid() + routes = [ + ("PUT", "/projects/%s/identity-memories" % pid, {}), + ("PUT", "/projects/%s/memory" % pid, {}), + ("GET", "/projects/%s/kb/index" % pid, None), + ("POST", "/projects/%s/kb/search" % pid, {}), + ("POST", "/projects/%s/cost" % pid, None), + ("PUT", "/projects/%s/automations" % pid, {}), + ("DELETE", "/projects/%s/automations/a1" % pid, None), + ("GET", "/projects/%s/automations/a1/cancel" % pid, None), + ("PUT", "/projects/%s/automations/a1/bind" % pid, {}), + ("GET", "/projects/%s/automations/a1/heartbeat" % pid, None), + ("POST", "/projects/%s/triage" % pid, {}), + ("PUT", "/projects/%s/todos" % pid, {}), + ("POST", "/projects/%s/skills" % pid, {}), + ("POST", "/projects/%s/apps" % pid, {}), + ("GET", "/projects/%s/mcp-servers/reload" % pid, None), + ("GET", "/projects/%s/mcp-servers/s1/oauth-login" % pid, + None), + ("POST", "/projects/%s/mcp-servers/../oauth-login" % pid, + {}), + ] + for method, path, body in routes: + with self.subTest(path=path): + status, _ = self._req(method, path, body, self.owner_key) + self.assertEqual(status, 404) + + def test_owner_ok_read_routes(self): + pid = self._pid() + for path in ["/projects/%s/skills" % pid, + "/projects/%s/apps" % pid, + "/projects/%s/mcp-servers" % pid, + "/projects/%s/triage" % pid, + "/projects/%s/cost" % pid, + "/projects/%s/automations" % pid, + "/projects/%s/todos" % pid, + "/projects/%s/identity-memories" % pid, + "/projects/%s/memory" % pid, + "/projects/%s/kb/search?q=hi" % pid, + "/projects/%s/push/subscriptions" % pid]: + with self.subTest(path=path): + status, body = self._req("GET", path, key=self.owner_key) + self.assertEqual(status, 200, body) + + +class IdentityMemoryFlowTest(Server3TestCase): + def test_identity_memory_create_supersede_delete(self): + pid = self._pid() + base = "/projects/%s/identity-memories" % pid + status, body = self._req("POST", base, + {"kind": "fact", "content": "喜欢深色"}, + self.owner_key) + self.assertEqual(status, 201, body) + mid = body["id"] + status, body = self._req("GET", base, key=self.owner_key) + self.assertEqual(status, 200) + self.assertTrue(any(m["id"] == mid for m in body["memories"])) + # 坏 kind 400;不存在的记忆 supersede/delete 404。 + status, body = self._req("POST", base, + {"kind": "bogus-kind", + "content": "x"}, self.owner_key) + self.assertEqual(status, 400) + status, _ = self._req("POST", base + "/nope/supersede", {}, + self.owner_key) + self.assertEqual(status, 404) + status, body = self._req("POST", base + "/%s/supersede" % mid, + {"replacement_content": "喜欢浅色"}, + self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["status"], "superseded") + status, body = self._req("DELETE", base + "/%s" % mid, + key=self.owner_key) + self.assertEqual(status, 200) + status, _ = self._req("DELETE", base + "/%s" % mid, + key=self.owner_key) + self.assertEqual(status, 404) + + def test_project_memory_create_list_supersede(self): + pid = self._pid() + base = "/projects/%s/memory" % pid + status, body = self._req("POST", base, + {"goal": "g"}, self.owner_key) + self.assertEqual(status, 400) + status, body = self._req("POST", base, + {"goal": "做周报", "conclusion": "已做"}, + self.owner_key) + self.assertEqual(status, 201, body) + mid = body["id"] + status, body = self._req("GET", base + "?top_k=abc", + key=self.owner_key) + self.assertEqual(status, 400) + status, body = self._req("GET", base, key=self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(len(body["summaries"]), 1) + status, body = self._req("POST", base + "/abc/supersede", {}, + self.owner_key) + self.assertEqual(status, 400) + status, body = self._req("POST", base + "/99999/supersede", {}, + self.owner_key) + self.assertEqual(status, 404) + status, body = self._req("POST", base + "/%s/supersede" % mid, + {}, self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(body["status"], "deprecated") + + def test_kb_index_search_validation(self): + pid = self._pid() + base = "/projects/%s/kb" % pid + status, _ = self._req("POST", base + "/index", + {"content": "hi"}, self.owner_key) + self.assertEqual(status, 400) + status, body = self._req("POST", base + "/index", + {"path": "a.md", "content": "hello"}, + self.owner_key) + self.assertEqual(status, 201, body) + status, body = self._req("GET", base + "/search?q=hello", + key=self.owner_key) + self.assertEqual(status, 200, body) + + +class ContinueTaskTest(Server3TestCase): + def test_continue_validation_and_success(self): + task = self._create_task(self.owner_key) + tid = task["id"] + # 进行中任务 409。 + status, body = self._req("POST", "/tasks/%s/continue" % tid, + {"message": "继续"}, self.owner_key) + self.assertEqual(status, 409) + self._set_status(tid, "completed") + cases = [ + ({"message": ""}, 400), + ({"message": "x" * 20001}, 400), + ({"message": "/goal "}, 400), + ({"message": "继续", "effort": "ultra"}, 400), + ({"message": "继续", "model": 123}, 400), + ({"message": "继续", "approval": "sometimes"}, 400), + ({"message": "继续", "child_model": 123}, 400), + ({"message": "继续", "child_effort": "ultra"}, 400), + ({"message": "继续", "plan": True, "approval": "full"}, + 400), + ({"message": "继续", "plan": True, "goal_mode": True}, + 400), + ] + for body, want in cases: + with self.subTest(body=body): + status, _ = self._req("POST", + "/tasks/%s/continue" % tid, + body, self.owner_key) + self.assertEqual(status, want) + status, body = self._req("POST", "/tasks/%s/continue" % tid, + {"message": "继续做"}, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["status"], "queued") + self.assertEqual(body["resume_message"], "继续做") + # 外人 403;不存在 404;merged 409。 + status, _ = self._req("POST", "/tasks/%s/continue" % tid, + {"message": "继续"}, self.outsider_key) + self.assertEqual(status, 403) + status, _ = self._req("POST", "/tasks/nope/continue", + {"message": "继续"}, self.owner_key) + self.assertEqual(status, 404) + self._set_status(tid, "merged") + status, _ = self._req("POST", "/tasks/%s/continue" % tid, + {"message": "继续"}, self.owner_key) + self.assertEqual(status, 409) + + def test_continue_goal_message_and_quota_402(self): + task = self._create_task(self.owner_key) + tid = task["id"] + self._set_status(tid, "completed") + status, body = self._req("POST", "/tasks/%s/continue" % tid, + {"message": "/goal 收齐周报"}, + self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["goal_mode"], 1) + self.assertEqual(body["goal"], "收齐周报") + # 配额 0 的属主:校验通过后 402,不改状态(配额行 + # 由 init_db 回填默认额度,已存在时 UPDATE 而非 INSERT)。 + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE user_quotas SET quota_usd_micros = 0" + " WHERE user_id = ?", (self.owner_id,)) + conn.commit() + finally: + conn.close() + status, body = self._req("POST", "/tasks/%s/continue" % tid, + {"message": "再继续"}, self.owner_key) + self.assertEqual(status, 402, body) + + +class TaskWaitTest(Server3TestCase): + def test_wait_validation_and_immediate(self): + parent = self._create_task(self.owner_key) + child = self._create_task(self.owner_key) + # 子任务挂到父任务下(coordinator 语义外的直接写库, + # 只为测 wait 的缺省子任务路径)。 + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE tasks SET parent_task_id = ? WHERE id = ?", + (parent["id"], child["id"])) + conn.commit() + finally: + conn.close() + self._set_status(child["id"], "completed") + base = "/tasks/%s/wait" % parent["id"] + cases = [ + ({"timeout_seconds": "x"}, 400), + ({"timeout_seconds": 121}, 400), + ({"task_ids": "not-a-list"}, 400), + ({"task_ids": []}, 400), + ({"task_ids": ["a"] * 9}, 400), + ({"task_ids": ["ghost"]}, 404), + ({"task_ids": [child["id"]], "after_cursor": "bogus"}, + 400), + ] + for body, want in cases: + with self.subTest(body=body): + status, _ = self._req("POST", base, body, self.owner_key) + self.assertEqual(status, want) + # 已终态子任务:缺省路径立即返回,不等超时。 + status, body = self._req("POST", base, + {"timeout_seconds": 5}, self.owner_key) + self.assertEqual(status, 200, body) + self.assertFalse(body["timed_out"]) + # 跨项目子任务 403。 + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "INSERT INTO tasks(id, project_id, prompt, status," + " created_by, created_at, updated_at) VALUES(" + "'alien', 'other-proj', 'x', 'completed'," + " 'someone', 1000, 1000)") + conn.commit() + finally: + conn.close() + status, _ = self._req("POST", base, + {"task_ids": ["alien"]}, self.owner_key) + self.assertEqual(status, 403) + + def test_wait_too_many_children_default(self): + parent = self._create_task(self.owner_key) + conn = sqlite3.connect(self.db_path) + try: + for i in range(9): + conn.execute( + "INSERT INTO tasks(id, project_id, prompt, status," + " parent_task_id, created_by, created_at," + " updated_at) VALUES(?, ?, 'x', 'queued', ?, ?," + " 1000, 1000)", + ("kid-%d" % i, self._pid(), parent["id"], + self.owner_id)) + conn.commit() + finally: + conn.close() + status, body = self._req("POST", "/tasks/%s/wait" % parent["id"], + {"timeout_seconds": 5}, self.owner_key) + self.assertEqual(status, 400) + self.assertEqual(body["count"], 9) + + +class FollowupTest(Server3TestCase): + def test_followup_create_list_validation(self): + task = self._create_task(self.owner_key) + tid = task["id"] + base = "/tasks/%s/followups" % tid + status, body = self._req("POST", base, {"body": ""}, + self.owner_key) + self.assertEqual(status, 400) + status, body = self._req("POST", base, + {"body": "请继续"}, self.owner_key) + self.assertEqual(status, 201, body) + status, body = self._req("GET", base, key=self.owner_key) + self.assertEqual(status, 200) + self.assertEqual(len(body["followups"]), 1) + status, _ = self._req("POST", base, {"body": "x"}, + self.outsider_key) + self.assertEqual(status, 403) + status, _ = self._req("POST", "/tasks/nope/followups", + {"body": "x"}, self.owner_key) + self.assertEqual(status, 404) + # queued 任务的 /goal 消息:原地转 goal,返回 200。 + status, body = self._req("POST", base, + {"body": "/goal 收邮件"}, + self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["delivery"], "goal_converted") + status, body = self._req("GET", "/tasks/%s" % tid, + key=self.owner_key) + self.assertEqual(body["goal_mode"], 1) + self.assertEqual(body["goal"], "收邮件") + + +class CreateTaskVariantsTest(Server3TestCase): + def test_idempotency_replay_returns_existing(self): + task = self._create_task(self.owner_key, + idempotency_key="idem-1") + status, body = self._req( + "POST", "/tasks", + {"project_id": self._pid(), "prompt": "另一段话", + "idempotency_key": "idem-1"}, self.owner_key) + self.assertEqual(status, 200, body) + self.assertEqual(body["id"], task["id"]) + + def test_no_time_limit_flag(self): + task = self._create_task(self.owner_key, no_time_limit=True) + self.assertEqual(task["no_time_limit"], 1) + task = self._create_task(self.owner_key) + self.assertEqual(task["no_time_limit"], 0) + + def test_upload_id_validation_errors(self): + base = {"project_id": self._pid(), "prompt": "x"} + cases = [ + (dict(base, upload_id=123), 400), + (dict(base, upload_id="ghost", executor="cloud"), 400), + (dict(base, upload_id="ghost"), 400), + ] + for body, want in cases: + with self.subTest(body=body): + status, _ = self._req("POST", "/tasks", body, + self.owner_key) + self.assertEqual(status, want) + # 他人的包 403;已用 409;过期 400;目录缺失 400;空包 400。 + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "INSERT INTO uploads(id, created_by, status," + " created_at) VALUES('up-other', ?, 'ready', ?)", + (self.outsider_id, 1000000000)) + conn.execute( + "INSERT INTO uploads(id, created_by, status," + " created_at) VALUES('up-used', ?, 'used', ?)", + (self.owner_id, 1000000000)) + conn.execute( + "INSERT INTO uploads(id, created_by, status," + " created_at) VALUES('up-old', ?, 'ready', 1000)", + (self.owner_id,)) + conn.commit() + finally: + conn.close() + # created_at 用当前时间戳,避免“过期”分支被其它分支抢先: + # 上面 up-other/up-used 的 created_at 需改回现在。 + conn = sqlite3.connect(self.db_path) + try: + import time as _time + now = _time.time() + conn.execute( + "UPDATE uploads SET created_at = ? WHERE id IN" + " ('up-other', 'up-used')", (now,)) + conn.commit() + finally: + conn.close() + for uid, want in [("up-other", 403), ("up-used", 409), + ("up-old", 400)]: + with self.subTest(uid=uid): + status, _ = self._req("POST", "/tasks", + dict(base, upload_id=uid), + self.owner_key) + self.assertEqual(status, want) + + +if __name__ == "__main__": + unittest.main()