Files
codex-app-server/cloud/tests/test_server_oauth_push.py
T
host fb3a311da8 推送真发送:webhook/slack 内置真实发送端 + worker 终态自动通知
此前订阅与分发框架都在,但分发端点传 sender=None,
webhook/slack 永远报无 sender、从未真发过。本轮:
- push:send_webhook/send_slack(标准库 urllib POST,
  单次 10 秒超时,不重试,失败记 errors);sender=None
  时用内置端,显式 sender 优先;webpush/apns/fcm 仍报
  缺凭证(原因写明:webpush 需浏览器端三字段订阅与标准库
  没有的 P-256 ECDH + AES-GCM;apns 需 HTTP/2;fcm 需
  Firebase 服务账号,三者均无现成接收方,不造假发送)
- worker:run_one 终态(completed/failed,缺 key 与仓库
  失败两条早退路径也在内)后 best-effort 调分发,通知
  失败只记日志、绝不影响任务状态;取消(interrupted)
  不在事件集合里,不通知
- 测试:真实发送端 6 个(本地回环服务器)、worker 终态
  通知 2 个,旧无 sender 断言按新语义改写
2026-10-26 17:31:30 +08:00

180 lines
6.9 KiB
Python

"""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": "http://127.0.0.1:1/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)
# 内置真实发送端会真 POST:目标不可达时分发应报投递失败
# (不是“无 sender”,也不是静默成功),sent 为空。
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(body["sent"], [])
self.assertEqual(len(body["errors"]), 1)
self.assertIn("delivery failed", 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()