GitHub App 一键安装:manifest 建 App、安装记录、双 secret 验签、/fix-review 内容去重

This commit is contained in:
host committed 2026-10-26 16:02:45 +08:00
1 parent ecf4b5c715
commit 32059178bd
5 files changed
+1210 -14

No files matched your search

+439
View File
@@ -0,0 +1,439 @@
"""GitHub App 一键安装单测:manifest、setup state、换 code、
配置、安装记录、内容去重、双 secret 验签、HTTP 端点。
不联网:换 code 挂 server.github_app_exchange_client 的 fake;
测试里出现的 secret、pem、key 一律是 fake 占位,绝无真实
secret、真实 GitHub 账号。
"""
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
try:
from cloud.auth import AuthStore
from cloud.db import connect as _db_connect
from cloud.github_app import (
AppError,
SetupStateStore,
build_app_manifest,
claim_fix_review,
exchange_manifest_code,
get_app_config,
list_installations,
parse_installation_event,
public_app_status,
record_installation_repos,
remove_installation,
remove_installation_repos,
save_app_config,
set_fix_review_task_id,
)
from cloud.server import create_server
from cloud.webhooks import verify_github_signature_any
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.db import connect as _db_connect
from cloud.github_app import (
AppError,
SetupStateStore,
build_app_manifest,
claim_fix_review,
exchange_manifest_code,
get_app_config,
list_installations,
parse_installation_event,
public_app_status,
record_installation_repos,
remove_installation,
remove_installation_repos,
save_app_config,
set_fix_review_task_id,
)
from cloud.server import create_server
from cloud.webhooks import verify_github_signature_any
BASE = "https://example.invalid"
def _fake_exchange(result=None):
def fake(url, headers):
assert url.startswith(
"https://api.github.com/app-manifests/"), url
return dict(result or {
"id": 424242, "slug": "fake-codex-app",
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
"pem": "-----BEGIN RSA PRIVATE KEY-----\nfake-pem\n"
"-----END RSA PRIVATE KEY-----\n",
"html_url": "https://github.com/apps/fake-codex-app"})
return fake
class ManifestTestCase(unittest.TestCase):
def test_manifest_urls_and_permissions(self):
m = build_app_manifest(BASE)
self.assertEqual(m["hook_attributes"]["url"],
BASE + "/webhooks/github")
self.assertEqual(m["redirect_url"], BASE + "/github-app/callback")
self.assertEqual(m["default_permissions"]["contents"], "write")
self.assertEqual(m["default_permissions"]["pull_requests"], "write")
self.assertEqual(m["default_permissions"]["issues"], "read")
self.assertIn("issue_comment", m["default_events"])
self.assertIn("pull_request", m["default_events"])
self.assertFalse(m["public"])
def test_manifest_requires_base(self):
with self.assertRaises(ValueError):
build_app_manifest("")
class SetupStateTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
def tearDown(self):
self.tmp.cleanup()
def test_consume_once_and_expired(self):
store = SetupStateStore(self.db_path)
state = store.create_state()
self.assertTrue(store.consume_state(state))
self.assertFalse(store.consume_state(state))
store.conn.close()
def test_expired_state_rejected(self):
store = SetupStateStore(self.db_path)
state = store.create_state()
# 注入“1 小时之后”的 now:state 应过期且被删。
self.assertFalse(store.consume_state(
state, now=9999999999.0))
self.assertFalse(store.consume_state(state))
store.conn.close()
class ExchangeTestCase(unittest.TestCase):
def test_exchange_ok(self):
config = exchange_manifest_code(
"fake-code", http_client=_fake_exchange())
self.assertEqual(config["app_id"], 424242)
self.assertEqual(config["slug"], "fake-codex-app")
self.assertEqual(config["webhook_secret"], "fake-app-secret")
def test_exchange_missing_fields(self):
with self.assertRaises(AppError) as ctx:
exchange_manifest_code(
"fake-code",
http_client=_fake_exchange({"id": 1, "pem": "x"}))
self.assertEqual(ctx.exception.reason, "config_incomplete")
def test_exchange_http_error(self):
def boom(url, headers):
raise RuntimeError("boom")
with self.assertRaises(AppError) as ctx:
exchange_manifest_code("fake-code", http_client=boom)
self.assertEqual(ctx.exception.reason, "exchange_failed")
class ConfigAndInstallationTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.conn = _db_connect(self.db_path)
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_config_roundtrip_and_public_status(self):
self.assertIsNone(get_app_config(self.conn))
save_app_config(self.conn, {
"app_id": 7, "slug": "fake-app", "client_id": "Iv1.x",
"webhook_secret": "fake-secret", "pem": "fake-pem",
"html_url": "https://github.com/apps/fake-app"})
config = get_app_config(self.conn)
self.assertEqual(config["slug"], "fake-app")
status = public_app_status(self.conn)
self.assertTrue(status["configured"])
self.assertNotIn("webhook_secret", status)
self.assertNotIn("pem", status)
def test_installations_created_added_removed_deleted(self):
record_installation_repos(self.conn, 11, "octo",
["octo/a", "octo/b"])
record_installation_repos(self.conn, 11, "octo", ["octo/a"])
rows = list_installations(self.conn)
self.assertEqual(len(rows), 2) # 重复同步不翻倍
record_installation_repos(self.conn, 11, "octo", ["octo/c"])
remove_installation_repos(self.conn, 11, ["octo/b"])
repos = [r["repo_full_name"] for r in list_installations(self.conn)]
self.assertEqual(sorted(repos), ["octo/a", "octo/c"])
removed = remove_installation(self.conn, 11)
self.assertEqual(removed, 2)
self.assertEqual(list_installations(self.conn), [])
def test_parse_installation_event(self):
payload = {"action": "created",
"installation": {"id": 11, "account": {"login": "octo"}},
"repositories": [{"full_name": "octo/a"}]}
inst = parse_installation_event(payload)
self.assertEqual(inst["installation_id"], 11)
self.assertEqual(inst["repos"], ["octo/a"])
added = dict(payload, action="added", repositories_added=[
{"full_name": "octo/b"}])
self.assertEqual(parse_installation_event(added)["repos"],
["octo/b"])
self.assertIsNone(parse_installation_event(
{"action": "created"}))
self.assertIsNone(parse_installation_event(
{"action": "opened", "installation": {"id": 1}}))
class DedupTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.conn = _db_connect(self.db_path)
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def test_content_dedup(self):
is_new, task_id = claim_fix_review(
self.conn, "octo/a", 3, 99)
self.assertTrue(is_new)
self.assertIsNone(task_id)
set_fix_review_task_id(self.conn, "octo/a", 3, 99, "task-1")
is_new, task_id = claim_fix_review(
self.conn, "octo/a", 3, 99)
self.assertFalse(is_new)
self.assertEqual(task_id, "task-1")
# 另一个评论 id 不受影响。
self.assertTrue(claim_fix_review(
self.conn, "octo/a", 3, 100)[0])
# 无评论 id 时无法去重,直接放行。
self.assertTrue(claim_fix_review(
self.conn, "octo/a", 3, None)[0])
class SignatureAnyTestCase(unittest.TestCase):
def _sig(self, secret, raw):
return "sha256=" + hmac.new(
secret.encode(), raw, hashlib.sha256).hexdigest()
def test_any_secret_matches(self):
raw = b'{"zen":"hi"}'
self.assertTrue(verify_github_signature_any(
["app-secret", "legacy-secret"], raw,
self._sig("legacy-secret", raw)))
self.assertFalse(verify_github_signature_any(
["app-secret", "legacy-secret"], raw,
self._sig("wrong-secret", raw)))
self.assertFalse(verify_github_signature_any([], raw, "sha256=x"))
class AppHttpTestCase(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.user_a, self.key_a = self.auth.create_user("user-a")
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()
self.server.github_app_exchange_client = _fake_exchange()
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()
os.environ.pop("GITHUB_APP_PUBLIC_BASE", None)
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)
def _req(self, method, path, body=None, key=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
hdrs = dict(headers or {})
payload = None
if body is not None:
payload = body if isinstance(body, str) else json.dumps(body)
hdrs.setdefault("Content-Type", "application/json")
if key is not None:
hdrs["X-API-Key"] = key
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
raw = resp.read().decode()
try:
parsed = json.loads(raw)
except ValueError:
parsed = raw
return resp.status, parsed
finally:
conn.close()
def _sig(self, secret, raw):
return "sha256=" + hmac.new(
secret.encode(), raw, hashlib.sha256).hexdigest()
def _webhook(self, event, payload, secret, delivery):
raw = json.dumps(payload)
return self._req(
"POST", "/webhooks/github", body=raw,
headers={"X-GitHub-Event": event,
"X-GitHub-Delivery": delivery,
"X-Hub-Signature-256": self._sig(secret, raw.encode())})
def _issue_setup_state(self):
os.environ["GITHUB_APP_PUBLIC_BASE"] = BASE
status, body = self._req(
"POST", "/github-app/setup/state", {}, key=self.key_a)
self.assertEqual(status, 200, body)
return body["state"], body["setup_url"]
def test_setup_state_requires_auth_and_base(self):
status, _ = self._req("POST", "/github-app/setup/state", {})
self.assertEqual(status, 401)
os.environ.pop("GITHUB_APP_PUBLIC_BASE", None)
status, body = self._req(
"POST", "/github-app/setup/state", {}, key=self.key_a)
self.assertEqual(status, 400, body)
def test_setup_page_and_callback_flow(self):
state, setup_url = self._issue_setup_state()
self.assertTrue(setup_url.startswith(BASE + "/github-app/setup"))
# 无 state 不渲染表单。
status, page = self._req("GET", "/github-app/setup")
self.assertEqual(status, 400, page)
# 有 state 渲染表单:manifest 与 GitHub 建 App 地址都在。
status, page = self._req(
"GET", "/github-app/setup?state=%s" % state)
self.assertEqual(status, 200, page)
self.assertIn("github.com/settings/apps/new", page)
self.assertIn("codex-app-server", page)
self.assertIn("/webhooks/github", page)
# 回调:坏 state 失败;好 state 换 code 存配置。
status, page = self._req(
"GET", "/github-app/callback?code=x&state=nope")
self.assertEqual(status, 400, page)
status, page = self._req(
"GET", "/github-app/callback?code=fake-code&state=%s" % state)
self.assertEqual(status, 200, page)
self.assertIn("fake-codex-app", page)
self.assertIn("/installations/new", page)
self.assertNotIn("fake-app-secret", page)
self.assertNotIn("fake-pem", page)
# 同一 state 第二次回调必然失败。
status, page = self._req(
"GET", "/github-app/callback?code=fake-code&state=%s" % state)
self.assertEqual(status, 400, page)
# 状态接口:已配置、无 secret。
status, body = self._req(
"GET", "/github-app/status", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertTrue(body["configured"])
self.assertEqual(body["slug"], "fake-codex-app")
self.assertNotIn("webhook_secret", body)
self.assertNotIn("pem", body)
def test_webhook_dual_secret_and_installation(self):
# 先存一个 App 配置(secret 为 fake-app-secret)。
conn = _db_connect(self.db_path)
try:
save_app_config(conn, {
"app_id": 424242, "slug": "fake-codex-app",
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
"pem": "fake-pem",
"html_url": "https://github.com/apps/fake-codex-app"})
finally:
conn.close()
os.environ["GITHUB_WEBHOOK_SECRET"] = "legacy-secret"
ping = {"zen": "hi"}
# App secret 与 legacy secret 都能验过;错 secret 401。
status, _ = self._webhook(
"ping", ping, "fake-app-secret", "d-app-1")
self.assertEqual(status, 200)
status, _ = self._webhook(
"ping", ping, "legacy-secret", "d-legacy-1")
self.assertEqual(status, 200)
status, _ = self._webhook("ping", ping, "wrong", "d-wrong-1")
self.assertEqual(status, 401)
# installation 事件记安装记录。
inst_payload = {
"action": "created",
"installation": {"id": 11, "account": {"login": "octo"}},
"repositories": [{"full_name": "octo/a"},
{"full_name": "octo/b"}]}
status, body = self._webhook(
"installation", inst_payload, "fake-app-secret", "d-inst-1")
self.assertEqual(status, 200, body)
self.assertEqual(body["result"], "installation_recorded")
status, body = self._req(
"GET", "/github-app/status", key=self.key_a)
self.assertEqual(status, 200, body)
self.assertEqual(len(body["installations"]), 2)
self.assertEqual(
body["installations"][0]["repo_full_name"], "octo/a")
def test_webhook_no_secret_is_503(self):
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)
status, _ = self._webhook(
"ping", {"zen": "hi"}, "anything", "d-none-1")
self.assertEqual(status, 503)
def test_fix_review_content_dedup_over_http(self):
# 项目名 == 仓库 full_name,/fix-review 才能建任务。
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at,"
" repo_url, auto_pr) VALUES(?, ?, ?, 0, NULL, 0)",
("proj-1", "octo/a", self.user_a))
conn.commit()
finally:
conn.close()
conn = _db_connect(self.db_path)
try:
save_app_config(conn, {
"app_id": 424242, "slug": "fake-codex-app",
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
"pem": "fake-pem",
"html_url": "https://github.com/apps/fake-codex-app"})
finally:
conn.close()
payload = {
"action": "created",
"comment": {"id": 777, "body": "please /fix-review this",
"user": {"login": "octo"}},
"issue": {"number": 5},
"repository": {"full_name": "octo/a"}}
# 同一评论送两遍,delivery_id 不同(模拟手动 + App 双通道)。
status, first = self._webhook(
"issue_comment", payload, "fake-app-secret", "d-fix-1")
self.assertEqual(status, 200, first)
self.assertIsNotNone(first.get("task_id"), first)
status, second = self._webhook(
"issue_comment", payload, "fake-app-secret", "d-fix-2")
self.assertEqual(status, 200, second)
self.assertTrue(second.get("duplicate"), second)
self.assertEqual(second.get("task_id"), first.get("task_id"))
if __name__ == "__main__":
unittest.main()