GitHub webhook: delivery-id dedup persisted in webhook_deliveries, /fix-review creates a queued task with audit (no model run in the webhook itself), check_run/check_suite parsing without mutating task state (CI backfill still via pr-checks). Control plane: Postgres pg_dump backup branch + POST /admin/backup (maintainer+, password only via PGPASSWORD env), per-key/IP rate limit (default 120/min, 429), health returns backend + db_ok, metrics by_status always has all seven status keys. Worker: per-task timeout (default 1800s, --task-timeout), timeout is failed not interrupted, limited retries (default 1, backoff, fallback failures not re-retried), stale codex- container cleanup on loop/CLI start. Tests: cloud 271 (267 pass, 4 skipped are pre-existing real- Postgres tests), host 27 pass.
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""生产级 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()
|