cloud: fill gaps (memory/KB/cost/models endpoints, WAL concurrency, OAuth scaffold, push subscriptions)
This commit is contained in:
1 parent
b74d99e959
commit
076ba01c74
11 files changed
+2831
-28
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user