Files
codex-app-server/cloud/tests/test_github_app.py
T
host 7091a2d0d2 P0 security batch: true global admin, quota gates on all spending paths, default quota for quota-less users, fix-review author trust + docker/owner attribution, GitHub App setup issuer binding, real cancel kill, wider shell env exclusion
- CODEX_GLOBAL_ADMINS replaces maintainer-derived global admin;
  admin endpoints (quotas, sweep, backup, push dispatch) fail-closed
  403 when unset, with audit on allow and deny.
- continue/followups/messages/retry check both caller and original
  creator quotas (USD + unpriced token cap), 402 on either overrun.
- Quota-less users now get the #86 default quota instead of unlimited;
  init_db backfills and OAuth create/bind/existing-login branches
  INSERT OR IGNORE a default row; explicit NULL row stays unlimited.
  user_quotas added to migration tables.
- /fix-review: untrusted authors rejected before dedup claim, no task;
  trusted tasks force executor=docker, created_by=project owner, owner
  quota gate, prompt is a fixed template plus a 2000-char excerpt.
- GitHub App setup state only issued by global admins, state rows bind
  issued_by (ALTER-compatible for old DBs), callback rejects issuers
  no longer in the admin list without consuming the state.
- Cancel: popen poll checks DB status every second, Docker path runs a
  cancel watchdog that kills by container name, write-back is CAS-guarded
  so late results never overwrite a new status.
- SHELL_ENV_EXCLUDE adds *_TOKEN, *_AUTH*, *_CREDENTIAL*, *API_KEY*;
  comment states the residual risk: same-UID /proc environ remains
  readable, true isolation needs UID separation or hidepid.
- Tests: new cloud/tests/test_p0_security.py (17 cases); old tests
  updated to the new semantics (admin env, default quota, trusted
  fix-review author) without weakening any gate.
2026-10-27 15:35:23 +00:00

517 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 DialectTranslationTestCase(unittest.TestCase):
"""Postgres 适配层翻译:2026-10-26 实弹回归——安装事件在生产
Postgres 上因 INSERT OR IGNORE 未翻译直接报语法错。
直接测 _translate_sql(纯函数):函数发出的 SQL 在这里翻
译成合法 Postgres,SQLite 路径不受影响(翻译只走 Pg 游标)。
"""
def test_or_ignore_translates(self):
from cloud.db import _translate_sql
sql, kind = _translate_sql(
"INSERT OR IGNORE INTO github_app_installations("
"installation_id, account_login, repo_full_name, created_at)"
" VALUES(?, ?, ?, ?)")
self.assertEqual(kind, "normal")
self.assertNotIn("OR IGNORE", sql)
self.assertIn("ON CONFLICT DO NOTHING", sql)
self.assertIn("%s", sql)
self.assertNotIn("?", sql)
def test_or_ignore_with_null_value_translates(self):
from cloud.db import _translate_sql
sql, _ = _translate_sql(
"INSERT OR IGNORE INTO github_fix_review_dedup("
"repo, pr_number, comment_id, task_id, created_at)"
" VALUES(?, ?, ?, NULL, ?)")
self.assertNotIn("OR IGNORE", sql)
self.assertIn("ON CONFLICT DO NOTHING", sql)
self.assertIn("NULL", sql)
def test_or_replace_app_config_updates_on_conflict(self):
from cloud.db import _translate_sql
sql, _ = _translate_sql(
"INSERT OR REPLACE INTO github_app_config(id, app_id, slug,"
" client_id, webhook_secret, pem, html_url, created_at)"
" VALUES(1, ?, ?, ?, ?, ?, ?, ?)")
self.assertIn("ON CONFLICT (id) DO UPDATE SET", sql)
self.assertIn("slug = EXCLUDED.slug", sql)
self.assertNotIn("OR REPLACE", sql)
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")
# P0 安全批后:setup state 只限全局管理员,本用例里
# user-a 即管理员。
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
os.environ["CODEX_GLOBAL_ADMINS"] = self.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)
if self._old_admins is None:
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
else:
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
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_installation_repositories_sync(self):
# 安装后增减仓库的同步事件:added 增行。
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": "added",
"installation": {"id": 11, "account": {"login": "octo"}},
"repositories_added": [{"full_name": "octo/c"}]}
status, body = self._webhook(
"installation_repositories", payload, "fake-app-secret",
"d-inst-repos-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(
[r["repo_full_name"] for r in body["installations"]],
["octo/c"])
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",
"author_association": "COLLABORATOR"}},
"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()