- Docker quota watchdog + fail-closed quota checks, system tasks need real owner - max_cost_usd actually accumulated, cancel records cost, estimated rows excluded from unpriced cap, cache_write declared non-billed - goal-resume keeps original verify on breakpoint resume, goal retry falls back to resume, goal_max_rounds capped at 100 at create and execution - coordinator children inherit parent model/effort, orphans marked interrupted with audit - PR jobs on docker workers return explicit needs_local_worker instead of silent empty - worker code signature covers 6 files and is checked before claim, fail-closed - sub2api lazy provisioning with rotation, backup whitelist + 0600, push SSRF block, repos/workers desensitized, key/project audits and limits, console buttons/quota/status wording, coordinator/automation quota reserve
459 lines
18 KiB
Python
459 lines
18 KiB
Python
"""push.py 单测:订阅增删查、偏好过滤、分发错误不静默丢。
|
||
|
||
不连外网、不向真实推送服务发任何请求:注入 sender 的测试
|
||
用假 sender;真实发送端的测试用本地回环 HTTP 服务器
|
||
(127.0.0.1 临时端口)收 POST,断言方法、头与 body。
|
||
本文件不含任何真实密钥、token、证书或私钥。
|
||
"""
|
||
|
||
import http.server
|
||
import json
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
import threading
|
||
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,
|
||
send_webhook,
|
||
)
|
||
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,
|
||
send_webhook,
|
||
)
|
||
|
||
|
||
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_explicit_sender_missing_channel_returns_no_sender_error_not_silent_ok(self):
|
||
# sender=None 时内置真实发送端会真 POST(见 TestRealSenders),
|
||
# “无 sender”只发生在显式 sender 里没有该通道时,仍不静默成功。
|
||
self._sub(channel="webhook")
|
||
result = dispatch_event(
|
||
self.conn, "completed", "proj-a", {"task_id": "t-1"},
|
||
sender={})
|
||
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)
|
||
|
||
|
||
class _RecordingHandler(http.server.BaseHTTPRequestHandler):
|
||
"""本地回环接收器:记录每次 POST 的头与 body,按类属性回状态。"""
|
||
requests = []
|
||
status = 200
|
||
|
||
def do_POST(self):
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
body = self.rfile.read(length) if length else b""
|
||
_RecordingHandler.requests.append({
|
||
"path": self.path,
|
||
"content_type": self.headers.get("Content-Type"),
|
||
"body": body,
|
||
})
|
||
self.send_response(_RecordingHandler.status)
|
||
self.send_header("Content-Length", "2")
|
||
self.end_headers()
|
||
self.wfile.write(b"{}")
|
||
|
||
def log_message(self, *args):
|
||
pass
|
||
|
||
|
||
class TestRealSenders(PushTestCase):
|
||
"""内置真实发送端:对本地回环服务器真 POST,断言方法/头/body;
|
||
不连外网。"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
# P1 SSRF 门默认挡 loopback;本地录制服务器测试显式放行,
|
||
# 生产默认值仍关闭(不要把本开关当成生产默认)。
|
||
self._old_push_allow = os.environ.get(
|
||
"CODEX_PUSH_ALLOW_PRIVATE_TARGETS")
|
||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = "1"
|
||
_RecordingHandler.requests = []
|
||
_RecordingHandler.status = 200
|
||
self.server = http.server.ThreadingHTTPServer(
|
||
("127.0.0.1", 0), _RecordingHandler)
|
||
self.port = self.server.server_address[1]
|
||
self.thread = threading.Thread(
|
||
target=self.server.serve_forever, daemon=True)
|
||
self.thread.start()
|
||
|
||
def tearDown(self):
|
||
self.server.shutdown()
|
||
self.server.server_close()
|
||
self.thread.join(timeout=5)
|
||
if getattr(self, "_old_push_allow", None) is None:
|
||
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
|
||
else:
|
||
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = (
|
||
self._old_push_allow)
|
||
super().tearDown()
|
||
|
||
def _url(self, path="/hook"):
|
||
return "http://127.0.0.1:%d%s" % (self.port, path)
|
||
|
||
def test_webhook_posts_json_via_default_sender(self):
|
||
create_subscription(
|
||
self.conn, "proj-a", "user-1", "webhook", self._url(),
|
||
"instant")
|
||
result = dispatch_event(
|
||
self.conn, "completed", "proj-a", {"task_id": "t-1"},
|
||
sender=None)
|
||
self.assertEqual(len(result["sent"]), 1)
|
||
self.assertEqual(result["errors"], [])
|
||
self.assertEqual(len(_RecordingHandler.requests), 1)
|
||
req = _RecordingHandler.requests[0]
|
||
self.assertEqual(req["content_type"], "application/json")
|
||
body = json.loads(req["body"].decode("utf-8"))
|
||
self.assertEqual(body["event_type"], "completed")
|
||
self.assertEqual(body["project_id"], "proj-a")
|
||
self.assertEqual(body["task_id"], "t-1")
|
||
self.assertEqual(body["payload"], {"task_id": "t-1"})
|
||
|
||
def test_webhook_non_2xx_is_error_not_sent(self):
|
||
_RecordingHandler.status = 500
|
||
create_subscription(
|
||
self.conn, "proj-a", "user-1", "webhook", self._url(),
|
||
"instant")
|
||
result = dispatch_event(
|
||
self.conn, "failed", "proj-a", {"task_id": "t-1"},
|
||
sender=None)
|
||
self.assertEqual(result["sent"], [])
|
||
self.assertEqual(len(result["errors"]), 1)
|
||
self.assertIn("delivery failed", result["errors"][0]["reason"])
|
||
self.assertEqual(len(_RecordingHandler.requests), 1)
|
||
|
||
def test_webhook_non_http_target_is_error(self):
|
||
create_subscription(
|
||
self.conn, "proj-a", "user-1", "webhook",
|
||
"ftp://example.invalid/hook", "instant")
|
||
with self.assertRaises(ValueError):
|
||
send_webhook({
|
||
"event_type": "completed", "project_id": "proj-a",
|
||
"payload": {"task_id": "t-1"},
|
||
"channel": "webhook",
|
||
"target": "ftp://example.invalid/hook",
|
||
"user_id": "user-1", "subscription_id": "s-1"})
|
||
result = dispatch_event(
|
||
self.conn, "completed", "proj-a", {"task_id": "t-1"},
|
||
sender=None)
|
||
self.assertEqual(result["sent"], [])
|
||
self.assertIn("http(s) URL", result["errors"][0]["reason"])
|
||
|
||
def test_slack_posts_text_with_task_and_error(self):
|
||
create_subscription(
|
||
self.conn, "proj-a", "user-1", "slack", self._url(),
|
||
"instant")
|
||
result = dispatch_event(
|
||
self.conn, "failed", "proj-a",
|
||
{"task_id": "t-9", "error": "boom happened"},
|
||
sender=None)
|
||
self.assertEqual(len(result["sent"]), 1)
|
||
body = json.loads(_RecordingHandler.requests[0]["body"].decode())
|
||
self.assertIn("t-9", body["text"])
|
||
self.assertIn("failed", body["text"])
|
||
self.assertIn("boom happened", body["text"])
|
||
|
||
def test_webpush_still_missing_credential_with_default_sender(self):
|
||
create_subscription(
|
||
self.conn, "proj-a", "user-1", "webpush",
|
||
"https://example.invalid/endpoint", "instant")
|
||
result = dispatch_event(
|
||
self.conn, "failed", "proj-a", {"task_id": "t-1"},
|
||
sender=None)
|
||
self.assertEqual(result["sent"], [])
|
||
self.assertIn("missing credential", result["errors"][0]["reason"])
|
||
self.assertEqual(_RecordingHandler.requests, [])
|
||
|
||
def test_explicit_sender_wins_over_default(self):
|
||
create_subscription(
|
||
self.conn, "proj-a", "user-1", "webhook", self._url(),
|
||
"instant")
|
||
calls = []
|
||
|
||
def fake(message):
|
||
calls.append(message)
|
||
|
||
result = dispatch_event(
|
||
self.conn, "completed", "proj-a", {"task_id": "t-1"},
|
||
sender={"webhook": fake})
|
||
self.assertEqual(len(calls), 1)
|
||
self.assertEqual(len(result["sent"]), 1)
|
||
self.assertEqual(_RecordingHandler.requests, [])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|