Files
codex-app-server/cloud/tests/test_prod_webhook_exec.py
T
host 33841355b5 Production-grade: webhook execution, Postgres backup, rate limit, worker timeout/retry
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.
2026-10-26 03:55:07 +08:00

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()