"""生产级 webhook / 备份 / 限流 / 指标单测。 只用标准库,不跑真实模型回合、不联网、不连真实 GitHub; webhook secret 均为单测内临时值,不是生产密钥。 """ import hashlib import hmac import http.client import json import os import sqlite3 import sys import tempfile import threading import unittest from pathlib import Path from unittest import mock try: from cloud.auth import AuthStore from cloud.server import RateLimiter, create_server from cloud.webhooks import WebhookDeliveryStore, parse_github_check_run except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.auth import AuthStore from cloud.server import RateLimiter, create_server from cloud.webhooks import WebhookDeliveryStore, parse_github_check_run def _sig(secret, raw): return "sha256=" + hmac.new( secret.encode("utf-8"), raw, hashlib.sha256).hexdigest() class WebhookExecTestCase(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.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() # webhook 任务归属按项目名 == repo full_name 匹配。 status, self.project = self._req( "POST", "/projects", {"name": "owner/repo"}, self.owner_key) self.assertEqual(status, 201, self.project) self.auth.set_member( self.project["id"], self.owner_id, self.viewer_id, "viewer") 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, headers=None, raw_body=None): conn = http.client.HTTPConnection( "127.0.0.1", self.port, timeout=10) hdrs = dict(headers or {}) if raw_body is not None: payload = raw_body elif body is not None: payload = json.dumps(body).encode("utf-8") hdrs.setdefault("Content-Type", "application/json") else: payload = None 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 _webhook(self, payload, delivery="d-1", event="issue_comment", secret="test-secret"): raw = json.dumps(payload).encode("utf-8") headers = { "Content-Type": "application/json", "X-Hub-Signature-256": _sig(secret, raw), "X-GitHub-Event": event, "X-GitHub-Delivery": delivery, } with mock.patch.dict( os.environ, {"GITHUB_WEBHOOK_SECRET": secret}): return self._req( "POST", "/webhooks/github", raw_body=raw, headers=headers) def _fix_payload(self): return { "action": "created", "comment": {"body": "请 /fix-review 看一下", "user": {"login": "alice"}}, "repository": {"full_name": "owner/repo"}, "issue": {"number": 42}, } def _tasks(self): conn = sqlite3.connect(self.db_path) try: return conn.execute( "SELECT id, prompt, status FROM tasks").fetchall() finally: conn.close() def _audit_actions(self): conn = sqlite3.connect(self.db_path) try: return [r[0] for r in conn.execute( "SELECT action FROM audit_log").fetchall()] finally: conn.close() def test_fix_review_creates_queued_task_and_audit(self): status, body = self._webhook(self._fix_payload(), delivery="d-100") self.assertEqual(status, 200, body) self.assertEqual(body["type"], "fix_review") self.assertIsNotNone(body["task_id"]) self.assertEqual(body["project_id"], self.project["id"]) tasks = self._tasks() self.assertEqual(len(tasks), 1) task_id, prompt, task_status = tasks[0] self.assertEqual(task_id, body["task_id"]) self.assertEqual(task_status, "queued") self.assertIn("owner/repo", prompt) self.assertIn("42", prompt) self.assertIn("请 /fix-review 看一下", prompt) self.assertIn("alice", prompt) self.assertIn("webhook_fix_review", self._audit_actions()) def test_duplicate_delivery_creates_no_second_task_or_audit(self): status, first = self._webhook(self._fix_payload(), delivery="d-dup") self.assertEqual(status, 200) self.assertIsNotNone(first["task_id"]) status, second = self._webhook(self._fix_payload(), delivery="d-dup") self.assertEqual(status, 200) self.assertTrue(second["duplicate"]) self.assertEqual(len(self._tasks()), 1) self.assertEqual( self._audit_actions().count("webhook_fix_review"), 1) def test_fix_review_no_matching_project_creates_no_task(self): payload = self._fix_payload() payload["repository"] = {"full_name": "other/repo"} status, body = self._webhook(payload, delivery="d-nomatch") self.assertEqual(status, 200) self.assertIsNone(body["task_id"]) self.assertEqual(self._tasks(), []) def test_check_run_success_and_failure_parsed_no_task(self): for conclusion in ("success", "failure"): payload = { "repository": {"full_name": "owner/repo"}, "check_run": {"name": "verify", "head_sha": "abc123", "conclusion": conclusion}, } status, body = self._webhook( payload, delivery="d-check-%s" % conclusion, event="check_run") self.assertEqual(status, 200, body) self.assertEqual(body["type"], "check_run") self.assertEqual(body["repo"], "owner/repo") self.assertEqual(body["head_sha"], "abc123") self.assertEqual(body["conclusion"], conclusion) self.assertEqual(body["check_name"], "verify") self.assertEqual(self._tasks(), []) def test_check_run_in_progress_returns_no_check_result(self): payload = { "repository": {"full_name": "owner/repo"}, "check_run": {"name": "verify", "head_sha": "abc123", "conclusion": None}, } status, body = self._webhook( payload, delivery="d-check-prog", event="check_run") self.assertEqual(status, 200) self.assertEqual(body, {"result": None}) def test_webhook_bad_signature_401_and_secret_missing_503(self): payload = self._fix_payload() raw = json.dumps(payload).encode("utf-8") with mock.patch.dict( os.environ, {"GITHUB_WEBHOOK_SECRET": "test-secret"}): status, _ = self._req( "POST", "/webhooks/github", raw_body=raw, headers={"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=deadbeef", "X-GitHub-Event": "issue_comment", "X-GitHub-Delivery": "d-bad"}) self.assertEqual(status, 401) with mock.patch.dict(os.environ, {}, clear=False): os.environ.pop("GITHUB_WEBHOOK_SECRET", None) status, _ = self._req( "POST", "/webhooks/github", raw_body=raw, headers={"Content-Type": "application/json", "X-Hub-Signature-256": _sig("test-secret", raw)}) self.assertEqual(status, 503) def test_admin_backup_sqlite_owner_ok_viewer_forbidden(self): out_dir = os.path.join(self.tmp.name, "backups") status, body = self._req( "POST", "/admin/backup", {"out_dir": out_dir}, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["backend"], "sqlite") self.assertTrue(os.path.exists(body["path"])) self.assertGreater(body["size_bytes"], 0) self.assertIn("task_count", body) self.assertNotIn("password", json.dumps(body)) status, _ = self._req( "POST", "/admin/backup", {"out_dir": out_dir}, self.viewer_key) self.assertEqual(status, 403) status, _ = self._req( "POST", "/admin/backup", {"out_dir": out_dir}, key=None) self.assertEqual(status, 401) def test_rate_limit_429_not_401(self): # 新限流器 + 小阈值注入:前 3 次通,第 4 次 429。 self.server.rate_limiter = RateLimiter(per_minute=3) self.server.rate_limit_per_minute = 3 for _ in range(3): status, _ = self._req( "GET", "/projects", key=self.owner_key) self.assertEqual(status, 200) status, body = self._req("GET", "/projects", key=self.owner_key) self.assertEqual(status, 429) self.assertEqual(body, {"error": "rate_limited"}) def test_health_backend_and_db_ok_and_metrics_by_status(self): status, body = self._req("GET", "/health") self.assertEqual(status, 200) self.assertTrue(body["ok"]) self.assertEqual(body["backend"], "sqlite") self.assertTrue(body["db_ok"]) status, metrics = self._req( "GET", "/metrics", key=self.owner_key) self.assertEqual(status, 200) for key in ("queued", "running", "completed", "failed", "interrupted", "merged"): self.assertIn(key, metrics["by_status"]) class WebhookUnitTestCase(unittest.TestCase): def test_parse_check_run_only_success_failure(self): base = {"repository": {"full_name": "o/r"}} ok = dict(base, check_run={"name": "v", "head_sha": "h", "conclusion": "success"}) self.assertEqual(parse_github_check_run(ok)["conclusion"], "success") bad = dict(base, check_run={"name": "v", "head_sha": "h", "conclusion": "neutral"}) self.assertIsNone(parse_github_check_run(bad)) self.assertIsNone(parse_github_check_run({})) suite = dict(base, check_suite={"head_sha": "h2", "conclusion": "failure"}) parsed = parse_github_check_run(suite) self.assertEqual(parsed["conclusion"], "failure") self.assertEqual(parsed["head_sha"], "h2") def test_delivery_store_claim_duplicate(self): tmp = tempfile.TemporaryDirectory() db_path = os.path.join(tmp.name, "c.db") store = WebhookDeliveryStore(db_path) try: self.assertTrue(store.claim("d1", event="issue_comment", repo="o/r")) self.assertFalse(store.claim("d1", event="issue_comment", repo="o/r")) self.assertTrue(store.claim("d2")) finally: store.close() tmp.cleanup() if __name__ == "__main__": unittest.main()