cloud: fill gaps (memory/KB/cost/models endpoints, WAL concurrency, OAuth scaffold, push subscriptions)

This commit is contained in:
host committed 2026-10-26 01:32:57 +08:00
1 parent b74d99e959
commit 076ba01c74
11 files changed
+2831 -28

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
"""cloud/db.py 单测:SQLite 并发 helper。
只测 db.py,不改 server.py / host.py / 其它模块。
重试逻辑的覆盖方式(本文件写清楚,供协调员核对):
- 确定性 fake 方式:FakeConn 前两次抛 database is locked、第三次
成功,断言重试后成功且退避序列为 0.05/0.1/0.2s;另测重试耗尽
抛原始错误、非锁错误一次都不重试。
- 真实并发方式:8 线程各写 20 条(server.py 同款 tasks 表),
断言无错误、最终 160 条、id 无重复。
两者都测了,前者证重试逻辑本身正确,后者证真实写冲突不再直接炸掉。
"""
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud import db as db_module
from cloud.db import (
connect,
execute_with_retry,
is_locked_error,
read_all,
transaction,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud import db as db_module
from cloud.db import (
connect,
execute_with_retry,
is_locked_error,
read_all,
transaction,
)
# 与 server.py init_db 的 tasks 表定义逐字段一致(db.py 本身不建表,
# 这里复刻定义只是为了让并发测试写进同款表,证明 schema 兼容)。
TASKS_DDL = (
"CREATE TABLE IF NOT EXISTS tasks("
"id TEXT PRIMARY KEY, "
"project_id TEXT, "
"prompt TEXT, "
"status TEXT, "
"idempotency_key TEXT, "
"depends_on TEXT, "
"priority INTEGER, "
"created_by TEXT, "
"created_at REAL, "
"updated_at REAL, "
"last_heartbeat REAL, "
"UNIQUE(project_id, idempotency_key))"
)
class FakeConn:
"""受控 fake:前 fail_times 次 execute 抛锁错误,之后返回哨兵。"""
def __init__(self, fail_times, error_text="database is locked"):
self.fail_times = fail_times
self.error_text = error_text
self.calls = 0
def execute(self, sql, params=()):
self.calls += 1
if self.calls <= self.fail_times:
raise sqlite3.OperationalError(self.error_text)
return "ok-sentinel"
class DbHelperTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "concurrency.db")
conn = connect(self.db_path)
try:
conn.execute(TASKS_DDL)
conn.commit()
finally:
conn.close()
def tearDown(self):
self.tmp.cleanup()
# -- 连接属性:WAL + busy_timeout + Row ------------------------------
def test_connect_sets_wal_busy_timeout_row_factory(self):
conn = connect(self.db_path)
try:
self.assertEqual(conn.row_factory, sqlite3.Row)
journal = conn.execute(
"PRAGMA journal_mode").fetchone()[0]
self.assertEqual(journal, "wal")
busy = conn.execute("PRAGMA busy_timeout").fetchone()[0]
self.assertEqual(busy, 5000)
# Row 行为:按列名访问,与 server.py 的 _task_to_dict 兼容。
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-row", "p1", "hello"),
)
conn.commit()
row = read_all(
conn, "SELECT * FROM tasks WHERE id = ?", ("t-row",))[0]
self.assertEqual(row["id"], "t-row")
self.assertEqual(row["prompt"], "hello")
finally:
conn.close()
def test_connect_custom_busy_timeout(self):
conn = connect(self.db_path, busy_timeout_ms=1234)
try:
busy = conn.execute("PRAGMA busy_timeout").fetchone()[0]
self.assertEqual(busy, 1234)
finally:
conn.close()
# -- 锁判定 ---------------------------------------------------------
def test_is_locked_error_only_for_locked_operational_error(self):
self.assertTrue(is_locked_error(
sqlite3.OperationalError("database is locked")))
self.assertTrue(is_locked_error(
sqlite3.OperationalError("Database Is Locked (retry)")))
self.assertFalse(is_locked_error(
sqlite3.OperationalError("no such table: tasks")))
self.assertFalse(is_locked_error(ValueError("database is locked")))
# -- 重试逻辑:fake 受控方式(确定性)--------------------------------
def test_retry_succeeds_after_two_locked_failures(self):
fake = FakeConn(fail_times=2)
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
result = execute_with_retry(fake, "INSERT ...", ())
self.assertEqual(result, "ok-sentinel")
self.assertEqual(fake.calls, 3) # 1 次初始 + 2 次重试
self.assertEqual(
[c.args[0] for c in sleep_mock.call_args_list],
[0.05, 0.1, 0.2][:2])
def test_retry_backoff_sequence_is_exponential(self):
fake = FakeConn(fail_times=3)
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
execute_with_retry(fake, "INSERT ...", ())
self.assertEqual(
[c.args[0] for c in sleep_mock.call_args_list],
[0.05, 0.1, 0.2])
def test_retry_exhausted_reraises_original_error(self):
fake = FakeConn(fail_times=99)
with mock.patch.object(db_module.time, "sleep"):
with self.assertRaises(sqlite3.OperationalError) as ctx:
execute_with_retry(fake, "INSERT ...", ())
self.assertIn("database is locked", str(ctx.exception))
# 默认 max_retries=3:1 次初始 + 3 次重试 = 4 次尝试,不多不少。
self.assertEqual(fake.calls, 4)
def test_non_locked_error_is_not_retried(self):
fake = FakeConn(fail_times=99, error_text="no such table: tasks")
with mock.patch.object(db_module.time, "sleep") as sleep_mock:
with self.assertRaises(sqlite3.OperationalError):
execute_with_retry(fake, "SELECT ...", ())
self.assertEqual(fake.calls, 1)
sleep_mock.assert_not_called()
# -- 事务:成功提交 / 失败回滚 ---------------------------------------
def test_transaction_commits_and_rolls_back(self):
conn = connect(self.db_path)
try:
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-ok", "p1", "ok"),
)
self.assertEqual(
len(read_all(conn, "SELECT * FROM tasks")), 1)
with self.assertRaises(sqlite3.IntegrityError):
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt, status)"
" VALUES(?, ?, ?, 'queued')",
("t-ok", "p1", "dup"),
)
# 冲突行回滚后仍只有原来 1 条,不留半写。
self.assertEqual(
len(read_all(conn, "SELECT * FROM tasks")), 1)
finally:
conn.close()
# -- 真实并发:8 线程 x 20 条 ----------------------------------------
def test_concurrent_writes_8_threads_x_20_no_loss(self):
errors = []
barrier = threading.Barrier(8)
def worker(thread_idx):
try:
barrier.wait(timeout=10)
conn = connect(self.db_path)
try:
for i in range(20):
with transaction(conn):
execute_with_retry(
conn,
"INSERT INTO tasks(id, project_id, prompt,"
" status, idempotency_key, depends_on,"
" priority, created_by, created_at,"
" updated_at, last_heartbeat)"
" VALUES(?, ?, ?, 'queued', ?, '[]',"
" 0, 'worker', ?, ?, NULL)",
("t-%d-%d" % (thread_idx, i),
"p-concurrent",
"prompt %d-%d" % (thread_idx, i),
"key-%d-%d" % (thread_idx, i),
time.time(), time.time()),
)
finally:
conn.close()
except Exception as exc: # noqa: BLE001 - 汇总后统一断言
errors.append(exc)
threads = [threading.Thread(target=worker, args=(n,))
for n in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=60)
self.assertFalse([t for t in threads if t.is_alive()],
"worker 线程未在 60s 内结束")
self.assertEqual(errors, [], "并发写不应有任何异常: %r" % (errors,))
conn = connect(self.db_path)
try:
rows = read_all(
conn,
"SELECT id FROM tasks WHERE project_id = ?",
("p-concurrent",))
finally:
conn.close()
self.assertEqual(len(rows), 160)
self.assertEqual(len({r["id"] for r in rows}), 160)
if __name__ == "__main__":
unittest.main()
+275
View File
@@ -0,0 +1,275 @@
"""oauth 脚手架单测:state、授权 URL、回调、换 token 失败。
全部外部调用均为注入的 fake http 客户端,本文件不向真实
GitHub / Google 发任何请求;出现的 client_id / secret 哨兵值
均为测试用的假值(形如 test- / fake- 前缀),不是真实凭证。
"""
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from urllib.parse import parse_qs, urlparse
try:
from cloud.auth import AuthStore
from cloud.oauth import (
OAuthError,
OAuthStateStore,
build_authorization_url,
exchange_code_for_token,
handle_oauth_callback,
map_external_identity_to_local_user,
start_oauth_login,
)
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.oauth import (
OAuthError,
OAuthStateStore,
build_authorization_url,
exchange_code_for_token,
handle_oauth_callback,
map_external_identity_to_local_user,
start_oauth_login,
)
PROVIDER = "github"
CLIENT_ID = "test-client-id-not-real"
REDIRECT_URI = "https://example.invalid/oauth/callback"
FAKE_SECRET_SENTINEL = "fake-secret-sentinel-not-real"
def _fake_token_client(token_result=None, error=None):
"""返回 fake http_client:记录调用,不联网。"""
calls = []
def fake(token_url, payload):
calls.append((token_url, dict(payload)))
if error is not None:
raise RuntimeError(error)
return dict(token_result or {"access_token": "fake-token-not-real"})
fake.calls = calls
return fake
class TestOAuthState(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "oauth.db")
self.store = OAuthStateStore(self.db_path)
def tearDown(self):
self.tmp.cleanup()
def test_state_create_then_validate_ok(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
self.assertTrue(state)
record = self.store.validate_state(state, PROVIDER, REDIRECT_URI)
self.assertIsNotNone(record)
self.assertEqual(record["provider"], PROVIDER)
self.assertEqual(record["redirect_uri"], REDIRECT_URI)
# 一次性:第二次校验必然失败(防重放)
self.assertIsNone(self.store.validate_state(state, PROVIDER))
def test_state_expired_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
expired_now = time.time() + 601 # 默认 TTL 600 秒
self.assertIsNone(self.store.validate_state(
state, PROVIDER, now=expired_now))
# 过期行已被删除,不会残留
row = self.store.conn.execute(
"SELECT state FROM oauth_states WHERE state = ?", (state,)
).fetchone()
self.assertIsNone(row)
def test_state_mismatch_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
# 不存在的 state
self.assertIsNone(self.store.validate_state(
"no-such-state", PROVIDER))
# provider 不匹配:拒绝,且不消耗合法 state
self.assertIsNone(self.store.validate_state(state, "google"))
# redirect_uri 不匹配:同样拒绝且不消耗
self.assertIsNone(self.store.validate_state(
state, PROVIDER, "https://example.invalid/other"))
# 合法组合仍可通过(前两次不匹配没有烧掉它)
self.assertIsNotNone(self.store.validate_state(
state, PROVIDER, REDIRECT_URI))
class TestAuthorizationUrl(unittest.TestCase):
def test_url_contains_params_and_no_secret(self):
state = "test-state-abc"
url = build_authorization_url(
PROVIDER, CLIENT_ID, REDIRECT_URI,
scope="read:user", state=state)
parsed = urlparse(url)
self.assertEqual(parsed.netloc, "github.com")
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
self.assertEqual(params["client_id"], CLIENT_ID)
self.assertEqual(params["redirect_uri"], REDIRECT_URI)
self.assertEqual(params["scope"], "read:user")
self.assertEqual(params["state"], state)
self.assertNotIn("secret", url.lower())
self.assertNotIn(FAKE_SECRET_SENTINEL, url)
def test_url_default_scope_and_google(self):
url = build_authorization_url(
"github", CLIENT_ID, REDIRECT_URI, state="s1")
self.assertIn("read%3Auser", url) # 默认 scope read:user
url = build_authorization_url(
"google", CLIENT_ID, REDIRECT_URI, state="s2")
params = {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
self.assertEqual(params["scope"], "openid email profile")
self.assertEqual(params["response_type"], "code")
self.assertNotIn("secret", url.lower())
def test_start_login_stores_state(self):
tmp = tempfile.TemporaryDirectory()
try:
store = OAuthStateStore(os.path.join(tmp.name, "s.db"))
state, url = start_oauth_login(
store, PROVIDER, CLIENT_ID, REDIRECT_URI)
self.assertIn("state=" + state, url)
row = store.conn.execute(
"SELECT provider FROM oauth_states WHERE state = ?",
(state,),
).fetchone()
self.assertEqual(row[0], PROVIDER)
finally:
tmp.cleanup()
def test_unknown_provider_rejected(self):
with self.assertRaises(OAuthError):
build_authorization_url(
"not-a-provider", CLIENT_ID, REDIRECT_URI)
class TestOAuthCallback(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "oauth.db")
self.store = OAuthStateStore(self.db_path)
# 同库上的 AuthStore:用于断言“失败不建用户/不记审计”。
self.auth = AuthStore(self.db_path)
def tearDown(self):
self.tmp.cleanup()
def _login(self, fake=None, **kwargs):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
return handle_oauth_callback(
self.store, PROVIDER, "test-code-not-real", state,
CLIENT_ID, REDIRECT_URI,
http_client=fake or _fake_token_client(
{"access_token": "fake-token-not-real",
"id": 12345, "login": "fake-user"}),
**kwargs)
def _count(self, table):
return self.store.conn.execute(
"SELECT COUNT(*) FROM %s" % table).fetchone()[0]
def test_callback_success_returns_identity(self):
identity = self._login()
self.assertEqual(identity["provider"], PROVIDER)
self.assertEqual(identity["external_id"], "12345")
self.assertEqual(identity["login"], "fake-user")
# access_token 不返回、不入库:回调只交身份
self.assertNotIn("access_token", identity)
def test_callback_maps_identity_to_local_user(self):
identity = self._login()
user_id, created = map_external_identity_to_local_user(
self.auth, PROVIDER, identity)
self.assertTrue(created)
user_id2, created2 = map_external_identity_to_local_user(
self.auth, PROVIDER, identity)
self.assertEqual(user_id, user_id2)
self.assertFalse(created2)
def test_callback_state_expired_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, PROVIDER, "code", state, CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client(),
now=time.time() + 601)
self.assertEqual(ctx.exception.reason, "state_expired")
def test_callback_state_mismatch_rejected(self):
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, "google", "code", state, CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client())
self.assertEqual(ctx.exception.reason, "state_mismatch")
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, PROVIDER, "code", "missing-state",
CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client())
self.assertEqual(ctx.exception.reason, "state_not_found")
def test_token_exchange_failure_creates_no_session_or_user(self):
fake = _fake_token_client(error="provider refused code")
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError) as ctx:
handle_oauth_callback(
self.store, PROVIDER, "bad-code", state,
CLIENT_ID, REDIRECT_URI, http_client=fake)
self.assertEqual(ctx.exception.reason, "token_exchange_failed")
# 换 token 失败:无用户、无审计(本模块本来就不建会话,
# 这里断言失败路径也没有任何副作用写入同库的 auth 表)。
self.assertEqual(self._count("users"), 0)
self.assertEqual(self._count("audit_log"), 0)
def test_token_error_dict_creates_nothing(self):
fake = _fake_token_client(
{"error": "bad_verification_code"})
state = self.store.create_state(PROVIDER, REDIRECT_URI)
with self.assertRaises(OAuthError):
handle_oauth_callback(
self.store, PROVIDER, "bad-code", state,
CLIENT_ID, REDIRECT_URI, http_client=fake)
self.assertEqual(self._count("users"), 0)
def test_real_path_without_secret_refuses_before_network(self):
# http_client=None 且环境变量无 secret:必须在联网前拒绝。
# 用 patch 清空两个 secret 环境变量,并断言 urlopen 未被调用。
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
os.environ.pop("GOOGLE_OAUTH_CLIENT_SECRET", None)
with mock.patch("cloud.oauth._urllib_post") as mocked:
with self.assertRaises(OAuthError) as ctx:
exchange_code_for_token(
PROVIDER, "code", CLIENT_ID, REDIRECT_URI,
http_client=None)
self.assertEqual(
ctx.exception.reason, "client_secret_missing")
mocked.assert_not_called()
def test_userinfo_client_used_when_token_has_no_identity(self):
def userinfo(access_token):
self.assertEqual(access_token, "fake-token-not-real")
return {"id": 999, "login": "via-userinfo"}
state = self.store.create_state(PROVIDER, REDIRECT_URI)
identity = handle_oauth_callback(
self.store, PROVIDER, "code", state, CLIENT_ID, REDIRECT_URI,
http_client=_fake_token_client(
{"access_token": "fake-token-not-real"}),
userinfo_client=userinfo)
self.assertEqual(identity["external_id"], "999")
self.assertEqual(identity["login"], "via-userinfo")
if __name__ == "__main__":
unittest.main()
+304
View File
@@ -0,0 +1,304 @@
"""push.py 单测:订阅增删查、偏好过滤、分发错误不静默丢。
只用标准库,不联网、不向真实推送服务发任何请求:所有发送都用
假 sender(可调用对象或按通道分的 dict)注入;webpush/apns/fcm
只断言缺凭证报错,不调用任何 sender。
本文件不含任何真实密钥、token、证书或私钥。
"""
import os
import sqlite3
import sys
import unittest
from pathlib import Path
try:
from cloud.push import (
ALLOWED_PUSH_EVENTS,
CHANNELS,
FAILURE_EVENTS,
PREFERENCES,
create_subscription,
delete_subscription,
dispatch_event,
list_subscriptions,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.push import (
ALLOWED_PUSH_EVENTS,
CHANNELS,
FAILURE_EVENTS,
PREFERENCES,
create_subscription,
delete_subscription,
dispatch_event,
list_subscriptions,
)
class PushTestCase(unittest.TestCase):
def setUp(self):
# 内存库 + 单一连接:init 与查询共用同一连接,避免 :memory: 换连接丢表。
self.conn = sqlite3.connect(":memory:")
def tearDown(self):
self.conn.close()
def _sub(self, project="proj-a", user="user-1", channel="webhook",
target="https://example.invalid/hook", preference="instant"):
return create_subscription(
self.conn, project, user, channel, target, preference)
def _calls(self):
calls = []
def sender(message):
calls.append(message)
return calls, sender
class TestSubscriptionCrud(PushTestCase):
def test_create_and_list(self):
sub = self._sub()
self.assertEqual(sub["project_id"], "proj-a")
self.assertEqual(sub["user_id"], "user-1")
self.assertEqual(sub["channel"], "webhook")
self.assertEqual(sub["preference"], "instant")
listed = list_subscriptions(self.conn, "proj-a", "user-1")
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0]["id"], sub["id"])
def test_create_duplicate_returns_existing_not_duplicate_row(self):
first = self._sub()
second = self._sub()
self.assertEqual(first["id"], second["id"])
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 1)
def test_create_invalid_channel_or_preference_rejected(self):
with self.assertRaises(ValueError):
self._sub(channel="sms")
with self.assertRaises(ValueError):
self._sub(preference="weekly")
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 0)
def test_delete_by_id_then_no_longer_listed(self):
sub = self._sub()
self.assertTrue(delete_subscription(self.conn, sub["id"]))
self.assertEqual(
list_subscriptions(self.conn, "proj-a", "user-1"), [])
# 再次删除无匹配,返回 False,不报错。
self.assertFalse(delete_subscription(self.conn, sub["id"]))
def test_constants_cover_task_contract(self):
self.assertEqual(
set(CHANNELS), {"webhook", "slack", "webpush", "apns", "fcm"})
self.assertEqual(set(PREFERENCES), {"instant", "digest", "silent"})
self.assertEqual(
ALLOWED_PUSH_EVENTS,
{"completed", "failed", "goal-unmet", "merge-failed",
"review-requested"},
)
self.assertEqual(FAILURE_EVENTS, {"failed", "goal-unmet", "merge-failed"})
class TestDispatchPreferences(PushTestCase):
def test_silent_preference_never_sends(self):
self._sub(preference="silent")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["skipped_silent"]), 1)
self.assertEqual(result["skipped_silent"][0]["preference"], "silent")
def test_failure_event_sends_immediately_even_with_digest(self):
# 失败类:digest 也即时发,不进 skipped_for_digest。
for event_type in ("failed", "goal-unmet", "merge-failed"):
with self.subTest(event_type=event_type):
conn = sqlite3.connect(":memory:")
try:
create_subscription(
conn, "proj-a", "user-1", "webhook",
"https://example.invalid/hook", "digest")
calls, sender = self._calls()
result = dispatch_event(
conn, event_type, "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["event_type"], event_type)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["skipped_for_digest"], [])
finally:
conn.close()
def test_completed_event_filtered_by_digest_returns_pending_list(self):
self._sub(preference="digest")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=sender)
# 不立即发,但不是静默丢弃:明确列出待摘要。
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["skipped_for_digest"]), 1)
self.assertEqual(
result["skipped_for_digest"][0]["preference"], "digest")
self.assertEqual(result["errors"], [])
def test_review_requested_digest_also_deferred(self):
self._sub(preference="digest")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "review-requested", "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(calls, [])
self.assertEqual(len(result["skipped_for_digest"]), 1)
def test_instant_completed_sends(self):
self._sub(preference="instant")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["channel"], "webhook")
self.assertEqual(calls[0]["payload"], {"task_id": "t-1"})
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["errors"], [])
def test_invalid_event_rejected_and_sender_not_called(self):
self._sub(preference="instant")
calls, sender = self._calls()
result = dispatch_event(
self.conn, "running", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(result["reason"], "event not allowed")
self.assertTrue(
any("event not allowed" in e["reason"] for e in result["errors"]))
class TestDispatchSendersAndCredentials(PushTestCase):
def test_webhook_without_sender_returns_no_sender_error_not_silent_ok(self):
self._sub(channel="webhook")
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"}, sender=None)
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("no sender", result["errors"][0]["reason"])
def test_slack_via_channel_dict_sender(self):
create_subscription(
self.conn, "proj-a", "user-1", "slack", "C123", "instant")
slack_calls = []
def slack_sender(message):
slack_calls.append(message)
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender={"slack": slack_sender})
self.assertEqual(len(slack_calls), 1)
self.assertEqual(slack_calls[0]["channel"], "slack")
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(result["errors"], [])
def test_webpush_apns_fcm_missing_credential_error_not_silent_drop(self):
cases = {
"webpush": "VAPID",
"apns": "APNs",
"fcm": "FCM",
}
for channel, keyword in cases.items():
with self.subTest(channel=channel):
conn = sqlite3.connect(":memory:")
try:
create_subscription(
conn, "proj-a", "user-1", channel,
"device-or-endpoint", "instant")
calls, sender = self._calls()
result = dispatch_event(
conn, "failed", "proj-a", {"task_id": "t-1"},
sender=sender)
# 绝不走假 sender、绝不假装成功:明确报错。
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(len(result["errors"]), 1)
self.assertIn("missing credential", result["errors"][0]["reason"])
self.assertIn(keyword, result["errors"][0]["reason"])
finally:
conn.close()
def test_sender_exception_recorded_not_raised_and_others_continue(self):
self._sub(user="user-1", target="https://example.invalid/hook-1")
def flaky(message):
if message["target"] == "https://example.invalid/hook-1":
raise RuntimeError("fake send failure")
calls.append(message)
calls = []
# 第二个订阅用另一个 target,同一 sender 只对第一个抛错。
create_subscription(
self.conn, "proj-a", "user-2", "webhook",
"https://example.invalid/hook-2", "instant")
result = dispatch_event(
self.conn, "completed", "proj-a", {"task_id": "t-1"},
sender=flaky)
self.assertEqual(len(result["sent"]), 1)
self.assertEqual(len(result["errors"]), 1)
self.assertIn("sender failed", result["errors"][0]["reason"])
self.assertEqual(len(calls), 1)
class TestDispatchIsolation(PushTestCase):
def test_deleted_subscription_no_longer_receives(self):
sub = self._sub()
self.assertTrue(delete_subscription(self.conn, sub["id"]))
calls, sender = self._calls()
result = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(calls, [])
self.assertEqual(result["sent"], [])
self.assertEqual(result["errors"], [])
def test_cross_project_subscriptions_do_not_mix(self):
self._sub(project="proj-a", user="user-1")
self._sub(project="proj-b", user="user-1",
target="https://example.invalid/hook-b")
calls, sender = self._calls()
# B 项目事件只发给 B 的订阅,A 的订阅收不到。
result_b = dispatch_event(
self.conn, "failed", "proj-b", {"task_id": "t-1"}, sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["target"], "https://example.invalid/hook-b")
self.assertEqual(len(result_b["sent"]), 1)
# A 项目事件只发给 A 的订阅。
calls.clear()
result_a = dispatch_event(
self.conn, "failed", "proj-a", {"task_id": "t-1"}, sender=sender)
self.assertEqual(len(calls), 1)
self.assertEqual(
calls[0]["target"], "https://example.invalid/hook")
self.assertEqual(len(result_a["sent"]), 1)
def test_list_subscriptions_scoped_by_project_and_user(self):
self._sub(project="proj-a", user="user-1")
self._sub(project="proj-a", user="user-2",
target="https://example.invalid/hook-2")
self._sub(project="proj-b", user="user-1",
target="https://example.invalid/hook-b")
self.assertEqual(len(list_subscriptions(self.conn, "proj-a")), 2)
self.assertEqual(
len(list_subscriptions(self.conn, "proj-a", "user-1")), 1)
self.assertEqual(
len(list_subscriptions(self.conn, "proj-b", "user-1")), 1)
if __name__ == "__main__":
unittest.main()
+225
View File
@@ -0,0 +1,225 @@
"""新端点测试:记忆 / 知识库 / 成本 / 模型总览。
只用标准库 http.client,不跑真实模型,不联网。
每个测试用独立临时 SQLite 与临时端口。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from urllib.parse import quote
try:
from cloud.auth import AuthStore
from cloud.cost import CostStore
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.cost import CostStore
from cloud.server import create_server
class EndpointsTestCase(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.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")
# outsider 自己的项目,用于跨项目隔离测试。
status, self.other = self._req(
"POST", "/projects", {"name": "other"}, self.outsider_key)
self.assertEqual(status, 201)
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()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
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 test_memory_create_list_supersede(self):
status, created = self._req(
"POST", "/projects/%s/memory" % self._pid(),
{"thread_id": "t1", "goal": "加法", "conclusion": "已加 add2",
"files_changed": ["mod.py"]},
self.owner_key)
self.assertEqual(status, 201, created)
status, body = self._req(
"GET", "/projects/%s/memory" % self._pid(), key=self.viewer_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["summaries"]), 1)
self.assertIn("add2", body["prompt_context"])
status, body = self._req(
"POST", "/projects/%s/memory/%s/supersede"
% (self._pid(), created["id"]), {}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["status"], "deprecated")
status, body = self._req(
"GET", "/projects/%s/memory" % self._pid(), key=self.owner_key)
self.assertEqual(body["summaries"], [])
def test_memory_no_key_401_and_outsider_403(self):
status, _ = self._req(
"GET", "/projects/%s/memory" % self._pid())
self.assertEqual(status, 401)
status, _ = self._req(
"GET", "/projects/%s/memory" % self._pid(),
key=self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"POST", "/projects/%s/memory" % self._pid(),
{"goal": "g", "conclusion": "c"}, self.viewer_key)
self.assertEqual(status, 403)
def test_memory_cross_project_supersede_404(self):
status, created = self._req(
"POST", "/projects/%s/memory" % self._pid(),
{"goal": "g", "conclusion": "c"}, self.owner_key)
self.assertEqual(status, 201)
# outsider 拿自己项目的路径去废弃别人的摘要:应 403/404,不成功。
status, _ = self._req(
"POST", "/projects/%s/memory/%s/supersede"
% (self.other["id"], created["id"]), {}, self.outsider_key)
self.assertIn(status, (403, 404))
def test_memory_unknown_project_404(self):
status, _ = self._req(
"GET", "/projects/nope/memory", key=self.owner_key)
self.assertEqual(status, 404)
# -- 知识库 ---------------------------------------------------------
def test_kb_index_search_and_isolation(self):
status, _ = self._req(
"POST", "/projects/%s/kb/index" % self._pid(),
{"path": "README.md", "content": "用户登录流程说明", "kind": "readme"},
self.owner_key)
self.assertEqual(status, 201)
status, body = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self._pid(), quote("登录")),
key=self.viewer_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["results"]), 1)
# outsider 在自己项目里搜不到别人的文档。
status, body = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self.other["id"], quote("登录")),
key=self.outsider_key)
self.assertEqual(status, 200)
self.assertEqual(body["results"], [])
# 无 key 401,越权 403。
status, _ = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self._pid(), quote("登录")))
self.assertEqual(status, 401)
status, _ = self._req(
"GET", "/projects/%s/kb/search?q=%s"
% (self._pid(), quote("登录")),
key=self.outsider_key)
self.assertEqual(status, 403)
# -- 成本 -----------------------------------------------------------
def test_task_and_project_cost(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self._pid(), "prompt": "做加法"}, self.owner_key)
self.assertEqual(status, 201)
store = CostStore(self.db_path)
try:
store.record(task["id"], self._pid(), "gpt-6.1-sol",
input_tokens=100, output_tokens=50,
usd_micros=1234, source="actual", fallback_count=1)
store.record_estimated(task["id"], self._pid(), "gpt-6.1-sol",
"prompt" * 10, "output" * 10)
finally:
store._conn.close()
status, body = self._req(
"GET", "/tasks/%s/cost" % task["id"], key=self.viewer_key)
self.assertEqual(status, 200)
self.assertEqual(body["actual_tokens"], 150)
self.assertGreater(body["estimated_tokens"], 0)
self.assertEqual(body["fallback_count"], 1)
status, _ = self._req(
"GET", "/tasks/%s/cost" % task["id"], key=self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"GET", "/tasks/nope/cost", key=self.owner_key)
self.assertEqual(status, 404)
status, body = self._req(
"GET", "/projects/%s/cost" % self._pid(), key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["overall"]["turns"], 2)
# -- 模型总览 -------------------------------------------------------
def test_models_overview_isolated(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self._pid(), "prompt": "x"}, self.owner_key)
store = CostStore(self.db_path)
try:
store.record(task["id"], self._pid(), "gpt-6.1-sol",
input_tokens=10, output_tokens=5)
finally:
store._conn.close()
status, body = self._req("GET", "/models", key=self.owner_key)
self.assertEqual(status, 200)
self.assertIn("gpt-6.1-sol", body["priority_list"])
models = {m["model"] for m in body["model_stats"]}
self.assertIn("gpt-6.1-sol", models)
# outsider 只能看到自己项目的统计(目前为空),看不到别人的。
status, body = self._req("GET", "/models", key=self.outsider_key)
self.assertEqual(status, 200)
models = {m["model"] for m in body["model_stats"]}
self.assertNotIn("gpt-6.1-sol", models)
status, _ = self._req("GET", "/models")
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()
+177
View File
@@ -0,0 +1,177 @@
"""OAuth 与推送的 HTTP 端点测试。
不联网、不向真实 GitHub/Google/推送服务发请求;OAuth 回调成功
路径由模块级单测覆盖,这里只测 HTTP 层的失败路径与订阅分发。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
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.oauth import OAuthStateStore
from cloud.server import create_server
class OAuthPushEndpointsTestCase(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.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)
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()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
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
# -- OAuth -----------------------------------------------------------
def test_oauth_login_returns_url_without_secret(self):
status, body = self._req(
"GET",
"/oauth/github/login?client_id=test-client"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 200)
self.assertIn("github.com/login/oauth/authorize",
body["authorization_url"])
self.assertIn("test-client", body["authorization_url"])
self.assertNotIn("secret", body["authorization_url"])
self.assertTrue(body["state"])
def test_oauth_login_unknown_provider_400(self):
status, _ = self._req(
"GET",
"/oauth/nope/login?client_id=x"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 400)
def test_oauth_callback_bad_state_400_no_session(self):
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": "no-such-state",
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
self.assertEqual(status, 400)
self.assertIn("state", body["error"])
def test_oauth_callback_good_state_no_secret_400(self):
store = OAuthStateStore(self.db_path)
try:
state = store.create_state(
"github", "http://127.0.0.1/cb")
finally:
store.conn.close()
# 确保环境里没有真实 secret(本测试不依赖它)。
old = os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
try:
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": state,
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
finally:
if old is not None:
os.environ["GITHUB_OAUTH_CLIENT_SECRET"] = old
self.assertEqual(status, 400)
self.assertIn("client_secret", body["error"])
# -- 推送 -------------------------------------------------------------
def test_push_subscribe_list_dispatch_and_delete(self):
pid = self.project["id"]
status, sub = self._req(
"POST", "/projects/%s/push/subscriptions" % pid,
{"channel": "webhook", "target": "https://example.invalid/hook",
"preference": "instant"},
self.owner_key)
self.assertEqual(status, 201, sub)
status, body = self._req(
"GET", "/projects/%s/push/subscriptions" % pid,
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["subscriptions"]), 1)
# HTTP 层无注入 sender:分发应报“无 sender”,不静默成功。
status, body = self._req(
"POST", "/admin/push/dispatch",
{"event_type": "failed", "project_id": pid,
"payload": {"task_id": "t1"}},
self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["errors"]), 1)
self.assertIn("no sender", body["errors"][0]["reason"])
# outsider 不能往别人项目里订阅/分发。
status, _ = self._req(
"POST", "/projects/%s/push/subscriptions" % pid,
{"channel": "webhook", "target": "https://example.invalid/x"},
self.outsider_key)
self.assertEqual(status, 403)
# 删除:DELETE 方法须可用,且删后列表为空。
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
conn.request("DELETE",
"/projects/%s/push/subscriptions/%s" % (pid, sub["id"]),
headers={"X-API-Key": self.owner_key})
resp = conn.getresponse()
resp.read()
self.assertEqual(resp.status, 200)
finally:
conn.close()
status, body = self._req(
"GET", "/projects/%s/push/subscriptions" % pid,
key=self.owner_key)
self.assertEqual(body["subscriptions"], [])
def test_push_dispatch_requires_auth(self):
status, _ = self._req(
"POST", "/admin/push/dispatch",
{"event_type": "completed", "project_id": self.project["id"]})
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()