App-token:推分支建 PR 改走 GitHub App installation token
- github_app:纯标准库 RS256 签 App JWT(PKCS#1/PKCS#8), 换 installation token(现换现用、不缓存),按仓库名查安装 记录,任一步失败回落 None - pr:app_token 路径 push 走 credential helper(token 只进 子进程环境),建 PR 走 REST API(读 default_branch 当 base); 不传 token 时 gh 路径不变 - server:/pr 端点按项目 repo_url 解析仓库名、现换 token, 响应加 auth_via=app|pat,换失败回落 PAT 不报错 - 测试:JWT/换 token/解析 9 个、pr App 路径 6 个、端点级 App 与 PAT 回落 2 个
This commit is contained in:
1 parent
ee4b01c8f6
commit
25d9b3d938
7 files changed
+1060
-20
No files matched your search
@@ -0,0 +1,248 @@
|
||||
"""POST /tasks/{id}/pr 的 App-token 接线单测(端点级):
|
||||
|
||||
App 路径:项目 repo_url 解析出仓库名、库里有该仓库的 App
|
||||
安装记录 -> 现换 installation token(fake 换 token 客户端 +
|
||||
测试专用 pem,JWT 纯标准库签发、不联网)-> push 走
|
||||
credential helper、建 PR 走 GitHub API(fake),响应里
|
||||
auth_via=app。
|
||||
PAT 回落:没有安装记录 -> 走原来的 gh 路径,auth_via=pat。
|
||||
|
||||
git/GitHub API 一律 fake,不跑真实命令;token 值绝不出现
|
||||
在 git 命令记录里。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.github_app import (
|
||||
record_installation_repos,
|
||||
save_app_config,
|
||||
)
|
||||
from cloud.server import create_server
|
||||
from cloud.tests.test_app_token import TEST_KEY_PKCS1
|
||||
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 (
|
||||
record_installation_repos,
|
||||
save_app_config,
|
||||
)
|
||||
from cloud.server import create_server
|
||||
from cloud.tests.test_app_token import TEST_KEY_PKCS1
|
||||
|
||||
|
||||
def _result(returncode=0, stdout="", stderr=""):
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout,
|
||||
stderr=stderr)
|
||||
|
||||
|
||||
class _Git:
|
||||
"""假 git:remote/branch/add/commit/push,记录命令。"""
|
||||
|
||||
def __init__(self, remote, branch):
|
||||
self.calls = []
|
||||
self.remote = remote
|
||||
self.branch = branch
|
||||
|
||||
def __call__(self, cmd, cwd):
|
||||
self.calls.append(list(cmd))
|
||||
if "remote" in cmd:
|
||||
return _result(0, self.remote + "\n")
|
||||
if "branch" in cmd:
|
||||
return _result(0, self.branch + "\n")
|
||||
if "add" in cmd:
|
||||
return _result(0, "")
|
||||
if "commit" in cmd:
|
||||
return _result(1, "",
|
||||
"nothing to commit, working tree clean")
|
||||
if "push" in cmd:
|
||||
return _result(0, "")
|
||||
return _result(0, "")
|
||||
|
||||
|
||||
class _Gh:
|
||||
"""假 gh:auth 通过、pr create 回链接。"""
|
||||
|
||||
def __init__(self, pr_url):
|
||||
self.calls = []
|
||||
self.pr_url = pr_url
|
||||
|
||||
def __call__(self, cmd, cwd):
|
||||
self.calls.append(list(cmd))
|
||||
if cmd[:2] == ["gh", "auth"]:
|
||||
return _result(0, "", "")
|
||||
if cmd[:2] == ["gh", "pr"]:
|
||||
return _result(0, self.pr_url + "\n")
|
||||
return _result(0, "")
|
||||
|
||||
|
||||
class _Api:
|
||||
"""假 GitHub API:GET 仓库回 default_branch,POST pulls 回 PR。"""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, method, url, token, payload):
|
||||
self.calls.append((method, url, token, payload))
|
||||
if method == "GET":
|
||||
return 200, {"default_branch": "main"}
|
||||
if method == "POST":
|
||||
return 201, {"html_url": "https://github.com/octo/a/pull/77",
|
||||
"number": 77}
|
||||
raise AssertionError("unexpected API call: %s %s" % (method, url))
|
||||
|
||||
|
||||
def _token_client(method, url, headers, body):
|
||||
"""假换 token:JWT 已在前面签好(只验格式),直接回 token。"""
|
||||
assert method == "POST", method
|
||||
assert "/access_tokens" in url, url
|
||||
assert headers["Authorization"].startswith("Bearer "), headers
|
||||
return 201, {"token": "srv-app-token",
|
||||
"expires_at": "2030-01-01T00:00:00Z",
|
||||
"permissions": {"contents": "write"},
|
||||
"repository_selection": "selected"}
|
||||
|
||||
|
||||
class _Case(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.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.worktrees = os.path.join(self.tmp.name, "worktrees")
|
||||
os.makedirs(self.worktrees, exist_ok=True)
|
||||
self.server.host_worktrees_dir = self.worktrees
|
||||
self.git = None
|
||||
self.api = _Api()
|
||||
self.server.pr_github_api = self.api
|
||||
self.server.pr_app_token_client = _token_client
|
||||
|
||||
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):
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
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 _project(self, name, repo_url):
|
||||
status, project = self._req(
|
||||
"POST", "/projects",
|
||||
{"name": name, "repo_url": repo_url, "auto_pr": 1},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, project)
|
||||
return project["id"]
|
||||
|
||||
def _task(self, task_id, project_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" depends_on, priority, created_by, created_at,"
|
||||
" updated_at, last_heartbeat) VALUES(?, ?, ?, 'merged',"
|
||||
" '[]', 0, 'system', 0, 0, NULL)",
|
||||
(task_id, project_id, "do it"))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
os.makedirs(os.path.join(self.worktrees, task_id), exist_ok=True)
|
||||
|
||||
def test_app_path_via_endpoint(self):
|
||||
project_id = self._project(
|
||||
"octo/a", "https://github.com/octo/a")
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
save_app_config(conn, {
|
||||
"app_id": 5476837, "slug": "fake-app",
|
||||
"client_id": "Iv1.x", "webhook_secret": "fake-secret",
|
||||
"pem": TEST_KEY_PKCS1,
|
||||
"html_url": "https://github.com/apps/fake-app"})
|
||||
record_installation_repos(conn, 175380082, "octo",
|
||||
["octo/a"])
|
||||
finally:
|
||||
conn.close()
|
||||
self.git = _Git("https://github.com/octo/a.git", "host/t-app-1")
|
||||
self.server.pr_git_runner = self.git
|
||||
self._task("t-app-1", project_id)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/t-app-1/pr", key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["pr_url"],
|
||||
"https://github.com/octo/a/pull/77")
|
||||
self.assertEqual(body["pr_number"], 77)
|
||||
self.assertEqual(body["auth_via"], "app")
|
||||
pushes = [c for c in self.git.calls if "push" in c]
|
||||
self.assertEqual(len(pushes), 1)
|
||||
self.assertTrue(any(a.startswith("credential.helper=")
|
||||
for a in pushes[0]))
|
||||
self.assertNotIn("srv-app-token",
|
||||
" ".join(" ".join(c) for c in self.git.calls))
|
||||
posts = [c for c in self.api.calls if c[0] == "POST"]
|
||||
self.assertEqual(len(posts), 1)
|
||||
self.assertEqual(posts[0][2], "srv-app-token")
|
||||
|
||||
def test_pat_fallback_via_endpoint(self):
|
||||
project_id = self._project(
|
||||
"octo/b", "https://github.com/octo/b")
|
||||
self.git = _Git("https://github.com/octo/b.git", "host/t-pat-1")
|
||||
self.server.pr_git_runner = self.git
|
||||
gh = _Gh("https://github.com/octo/b/pull/88")
|
||||
self.server.pr_gh_runner = gh
|
||||
self.server.pr_gh_which = "/usr/bin/gh"
|
||||
self._task("t-pat-1", project_id)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/t-pat-1/pr", key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["pr_url"],
|
||||
"https://github.com/octo/b/pull/88")
|
||||
self.assertEqual(body["auth_via"], "pat")
|
||||
pushes = [c for c in self.git.calls if "push" in c]
|
||||
self.assertEqual(len(pushes), 1)
|
||||
self.assertFalse(any(a.startswith("credential.helper=")
|
||||
for a in pushes[0]))
|
||||
self.assertEqual(self.api.calls, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user