Files
codex-app-server/cloud/tests/test_coverage85_server3.py

615 lines
27 KiB
Python
Raw Permalink 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% 第 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()