P2 console slice A: project members, admin panel, task filters, trace download, worker liveness
- Members: GET/POST /projects/{id}/members, PATCH /projects/{id}/members/{user_id};
owner or CODEX_GLOBAL_ADMINS only, audit via set_member; invite by user_id
or username, duplicate 409, sole-owner demotion 409.
- Console admin panel reuses /admin/quotas, /admin/sweep, /admin/backup;
admin probe reuses /admin/quotas 403-vs-400 ordering, no new endpoint.
- Task list: frontend status/executor filters + id/prompt search over the
full fetch; 10s refresh unchanged.
- Trace: kind filter on render, download button fetches full /trace
(not the 1000-row render truncation); workers show relative last_seen
with the 120s alive threshold.
- Tests: cloud/tests/test_p2_console.py, 15 tests.
This commit is contained in:
1 parent
97f019d1dd
commit
154c18e25d
3 files changed
+816
-17
No files matched your search
@@ -0,0 +1,335 @@
|
||||
"""P2 切片 A(控制台管理面与筛选)单测。
|
||||
|
||||
只覆盖本切片新增或复用的服务端契约 + 控制台壳标记,不跑
|
||||
浏览器 JS(与 test_console.py 同款:壳只验标记与路由):
|
||||
- 成员端点:非 owner 邀请 403、owner 邀请成功(user_id 与
|
||||
username 两种入参)、重复邀请 409、非法角色 400、未知用户
|
||||
404、成员列表读权限、改角色权限与唯一 owner 防降级、
|
||||
非成员全局管理员可邀请/改角色。
|
||||
- 管理区复用端点:非管理员 sweep 403、管理员配额读成功、
|
||||
探针语义(非管理员 GET /admin/quotas 无参 403,管理员 400)。
|
||||
- 链路全量:写 1200 条链路文件,GET /trace 全量返回(控制台
|
||||
“下载完整链路”依赖的正是该端点,而非屏上截断的 1000 条)。
|
||||
- Workers:心跳后 /workers 返回 epoch 秒 last_seen 与 alive。
|
||||
- 控制台壳含本切片新增标记。
|
||||
|
||||
测试用的 key、用户 id 全是假值;不联网、不碰生产。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
|
||||
def _req(port, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
conn.request(method, path,
|
||||
json.dumps(body) if body is not None else None, headers)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
conn.close()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
data = None
|
||||
return resp.status, data
|
||||
|
||||
|
||||
class P2ConsoleTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.trace_dir = os.path.join(self.tmp.name, "host-traces")
|
||||
os.makedirs(self.trace_dir, exist_ok=True)
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.maint_id, self.maint_key = self.auth.create_user("maint")
|
||||
self.member_id, self.member_key = self.auth.create_user("member")
|
||||
self.admin_id, self.admin_key = self.auth.create_user("admin")
|
||||
self.outsider_id, self.out_key = self.auth.create_user("outsider")
|
||||
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
|
||||
# 全局管理员:非项目成员的 admin,验“owner 或全局管理员”双通道。
|
||||
os.environ["CODEX_GLOBAL_ADMINS"] = self.admin_id
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.trace_dir = self.trace_dir
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, proj = _req(self.port, "POST", "/projects",
|
||||
{"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201, proj)
|
||||
self.project_id = proj["id"]
|
||||
self.auth.set_member(self.project_id, self.owner_id,
|
||||
self.maint_id, "maintainer")
|
||||
|
||||
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 _members(self, key=None):
|
||||
return _req(self.port, "GET",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
key=key)
|
||||
|
||||
# -- 成员邀请 / 列表 -------------------------------------------
|
||||
def test_non_owner_invite_forbidden(self):
|
||||
# maintainer 不是 owner、不是全局管理员:邀请必须 403,
|
||||
# 且安全门不能因“项目成员”身份放行。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
|
||||
def test_outsider_invite_forbidden(self):
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.out_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
|
||||
def test_owner_invite_by_user_id_and_username(self):
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "contributor"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
self.assertEqual(data["role"], "contributor")
|
||||
# 第二个用户走 username 入参(控制台输入框两种都接受)。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"username": "outsider", "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
self.assertEqual(data["user_id"], self.outsider_id)
|
||||
|
||||
def test_invite_validation(self):
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "superuser"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": "no-such-user", "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_invite_duplicate_conflict(self):
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
# 已是成员:改角色走 PATCH,重复邀请 409,不静默覆盖。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "maintainer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 409, data)
|
||||
|
||||
def test_member_list_read_and_outsider_denied(self):
|
||||
_req(self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
status, data = self._members(self.maint_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
roles = {m["user_id"]: m["role"] for m in data["members"]}
|
||||
self.assertEqual(roles[self.owner_id], "owner")
|
||||
self.assertEqual(roles[self.maint_id], "maintainer")
|
||||
self.assertEqual(roles[self.member_id], "viewer")
|
||||
# 列表不泄露认证字段。
|
||||
for m in data["members"]:
|
||||
self.assertNotIn("api_key_hash", m)
|
||||
self.assertNotIn("key_hash", m)
|
||||
status, _ = self._members(self.out_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
def test_global_admin_can_invite_without_membership(self):
|
||||
# admin 不是项目成员,但凭 CODEX_GLOBAL_ADMINS 可邀请。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "contributor"},
|
||||
self.admin_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
|
||||
# -- 改角色 / 唯一 owner 防降级 ---------------------------------
|
||||
def test_patch_role_by_owner_and_non_owner_denied(self):
|
||||
_req(self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.member_id),
|
||||
{"role": "maintainer"}, self.maint_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.member_id),
|
||||
{"role": "maintainer"}, self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["role"], "maintainer")
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, "ghost"),
|
||||
{"role": "viewer"}, self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_cannot_demote_only_owner(self):
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "maintainer"}, self.owner_key)
|
||||
self.assertEqual(status, 409, data)
|
||||
# 先把另一位成员升为 owner,之后降原 owner 才放行。
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.maint_id),
|
||||
{"role": "owner"}, self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "maintainer"}, self.maint_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
|
||||
def test_global_admin_patch_blocked_by_sole_owner_guard(self):
|
||||
# 全局管理员也不能把唯一 owner 降掉(防项目无 owner 可管)。
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "viewer"}, self.admin_key)
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
# -- 管理区复用端点 ---------------------------------------------
|
||||
def test_admin_probe_and_quota_reuse(self):
|
||||
# 探针语义:控制台凭此区分管理员与否,不新增端点。
|
||||
status, _ = _req(self.port, "GET", "/admin/quotas",
|
||||
key=self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = _req(self.port, "GET", "/admin/quotas",
|
||||
key=self.admin_key)
|
||||
self.assertEqual(status, 400)
|
||||
status, data = _req(
|
||||
self.port, "GET",
|
||||
"/admin/quotas?user_id=%s" % self.owner_id,
|
||||
key=self.admin_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertIn("used_usd_micros", data)
|
||||
status, _ = _req(
|
||||
self.port, "GET",
|
||||
"/admin/quotas?user_id=%s" % self.owner_id,
|
||||
key=self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
def test_non_admin_sweep_forbidden(self):
|
||||
status, _ = _req(self.port, "POST", "/admin/sweep", {},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = _req(self.port, "POST", "/admin/backup", {},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
# -- 链路全量 / Workers ------------------------------------------
|
||||
def test_trace_endpoint_returns_full_entries(self):
|
||||
status, task = _req(
|
||||
self.port, "POST", "/tasks",
|
||||
{"project_id": self.project_id, "prompt": "trace probe"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
task_id = task["id"]
|
||||
path = os.path.join(self.trace_dir, task_id + ".jsonl")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for i in range(1200):
|
||||
f.write(json.dumps(
|
||||
{"id": "e%d" % i, "kind": "message",
|
||||
"text": "line %d" % i}) + "\n")
|
||||
status, data = _req(
|
||||
self.port, "GET", "/tasks/%s/trace" % task_id,
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
# 服务端不截断:控制台下载按钮拿到的就是这 1200 条全量,
|
||||
# 屏上 1000 条截断只是渲染层行为。
|
||||
self.assertEqual(len(data["entries"]), 1200)
|
||||
|
||||
def test_workers_last_seen_is_epoch_and_alive(self):
|
||||
status, data = _req(
|
||||
self.port, "POST", "/workers/heartbeat",
|
||||
{"worker_id": "w-p2", "executor": "docker",
|
||||
"capacity": 2, "running": 0},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
status, data = _req(self.port, "GET", "/workers",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
workers = data["workers"]
|
||||
self.assertEqual(len(workers), 1)
|
||||
# 控制台“x 秒/分钟前 + 120 秒在线阈值”的输入:epoch 秒。
|
||||
self.assertIsInstance(workers[0]["last_seen"], (int, float))
|
||||
self.assertLess(abs(workers[0]["last_seen"] - time.time()), 60)
|
||||
self.assertTrue(workers[0]["alive"])
|
||||
|
||||
# -- 控制台壳标记 -------------------------------------------------
|
||||
def test_console_contains_p2_markers(self):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
conn.request("GET", "/console")
|
||||
resp = conn.getresponse()
|
||||
text = resp.read().decode("utf-8")
|
||||
conn.close()
|
||||
self.assertEqual(resp.status, 200)
|
||||
for marker in ("/members", "inviteMember", "memberList",
|
||||
"adminBody", "/admin/sweep", "/admin/backup",
|
||||
"/admin/quotas", "taskStatusFilter",
|
||||
"taskExecutorFilter", "taskSearch",
|
||||
"btnDownloadTrace", "traceKindFilter",
|
||||
"workerAgeText", "last_seen"):
|
||||
self.assertIn(marker, text, marker)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user