deer-flow/backend/tests/test_github_app_auth.py
Hyeonsang Cho 161c3503d0
fix(github): build the App JWT off the loop and reuse the parsed key (#8203)
* fix(github): build the App JWT off the loop and reuse the parsed key

mint_installation_token() runs on the channel manager's event loop. On
every cold or expiring installation token it built the App JWT inline:
it read GITHUB_APP_PRIVATE_KEY_PATH and passed the PEM to jwt.encode,
which parses and validates the RSA key each time. For RSA-2048 the parse
is ~45 ms of the ~50 ms loop stall, and it holds the GIL, so a worker
thread alone does not help.

Reuse the parsed key while the PEM text is unchanged (still read on
every mint, so rotation needs no restart; parsed through PyJWT's own
RS256 prepare_key, so formats and errors are unchanged; failures are not
cached), and build the JWT in asyncio.to_thread so the file read and the
signature leave the loop. Later mints drop from ~48 ms to ~1.4 ms of
loop stall.

* test(github): pin the single parse under concurrent mints

Async mints call mint_app_jwt in worker threads, and the lock around the
parsed-key cache is what keeps a cold race from parsing the key twice.
Two threads now start mint_app_jwt behind a barrier while the first
parse is held open; the key must be parsed once.

The test-only reset also takes the lock, so a mint still parsing in a
worker thread finishes before the reset instead of storing its key after
it.
2026-11-13 14:31:29 +08:00

411 lines
15 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.

"""Tests for the GitHub App auth module.
Uses an in-test RSA keypair for JWT signing, plus ``httpx.MockTransport``
to simulate the GitHub installation-token endpoint.
"""
from __future__ import annotations
import httpx
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from app.gateway.github.app_auth import (
_clear_token_cache_for_tests,
load_app_private_key,
mint_app_jwt,
mint_installation_token,
)
@pytest.fixture(autouse=True)
def _clear_cache() -> None:
_clear_token_cache_for_tests()
@pytest.fixture()
def private_key_pem() -> str:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
@pytest.fixture()
def set_github_env(monkeypatch: pytest.MonkeyPatch, private_key_pem: str) -> None:
monkeypatch.setenv("GITHUB_APP_ID", "123456")
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", private_key_pem)
# ---------------------------------------------------------------------------
# JWT tests
# ---------------------------------------------------------------------------
def test_mint_app_jwt_is_rs256(set_github_env: None) -> None:
import jwt
token = mint_app_jwt()
header = jwt.get_unverified_header(token)
assert header["alg"] == "RS256"
def test_mint_app_jwt_iss_is_app_id(set_github_env: None) -> None:
import jwt
token = mint_app_jwt()
payload = jwt.decode(token, options={"verify_signature": False})
assert payload["iss"] == "123456"
def test_mint_app_jwt_exp_is_within_10_min(set_github_env: None) -> None:
import jwt
now = 1700000000.0
token = mint_app_jwt(now=now)
payload = jwt.decode(token, options={"verify_signature": False})
# iat should be ~60s before now, exp ~9 min after now
assert payload["iat"] == int(now) - 60
assert payload["exp"] == int(now) + 9 * 60
def test_mint_app_jwt_verifies_with_its_own_key(set_github_env: None) -> None:
import jwt
token = mint_app_jwt()
# Extract the public key from the PEM so we can verify RS256.
from cryptography.hazmat.primitives import serialization
priv = serialization.load_pem_private_key(load_app_private_key().encode(), password=None)
pub_pem = (
priv.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)
jwt.decode(token, pub_pem, algorithms=["RS256"])
def _public_pem(private_key_pem: str) -> str:
priv = serialization.load_pem_private_key(private_key_pem.encode(), password=None)
return (
priv.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)
def test_mint_app_jwt_parses_the_private_key_once(set_github_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""Parsing the PEM holds the GIL for tens of ms; repeat mints reuse the parsed key."""
import jwt.algorithms
parses = 0
load_pem_private_key = jwt.algorithms.load_pem_private_key
def counting_load(*args, **kwargs):
nonlocal parses
parses += 1
return load_pem_private_key(*args, **kwargs)
monkeypatch.setattr(jwt.algorithms, "load_pem_private_key", counting_load)
public_pem = _public_pem(load_app_private_key())
tokens = [mint_app_jwt(now=1700000000.0 + i) for i in range(3)]
assert parses == 1
for token in tokens:
jwt.decode(token, public_pem, algorithms=["RS256"], options={"verify_exp": False, "verify_iat": False})
def test_concurrent_mint_app_jwt_calls_parse_the_key_once(set_github_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""Async mints run ``mint_app_jwt`` in worker threads; a cold race still parses once."""
import threading
import jwt.algorithms
parses = 0
second_parse = threading.Event()
load_pem_private_key = jwt.algorithms.load_pem_private_key
def slow_counting_load(*args, **kwargs):
nonlocal parses
parses += 1
if parses == 1:
# Hold the first parse open long enough for the other thread to
# start its own parse, which only the lock prevents.
second_parse.wait(timeout=0.5)
else:
second_parse.set()
return load_pem_private_key(*args, **kwargs)
monkeypatch.setattr(jwt.algorithms, "load_pem_private_key", slow_counting_load)
barrier = threading.Barrier(2)
tokens: list[str] = []
def mint() -> None:
barrier.wait()
tokens.append(mint_app_jwt())
threads = [threading.Thread(target=mint) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert len(tokens) == 2
assert parses == 1
def test_mint_app_jwt_uses_a_rotated_inline_key(set_github_env: None, monkeypatch: pytest.MonkeyPatch, private_key_pem: str) -> None:
import jwt
first = mint_app_jwt()
rotated_pem = (
rsa.generate_private_key(public_exponent=65537, key_size=2048)
.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
.decode()
)
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", rotated_pem)
second = mint_app_jwt()
jwt.decode(first, _public_pem(private_key_pem), algorithms=["RS256"])
jwt.decode(second, _public_pem(rotated_pem), algorithms=["RS256"])
with pytest.raises(jwt.InvalidSignatureError):
jwt.decode(second, _public_pem(private_key_pem), algorithms=["RS256"])
def test_mint_app_jwt_rejects_an_invalid_key_every_time(monkeypatch: pytest.MonkeyPatch) -> None:
"""A bad key is not cached: each mint raises, as before."""
import jwt
monkeypatch.setenv("GITHUB_APP_ID", "123456")
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "not a pem")
for _ in range(2):
with pytest.raises(jwt.InvalidKeyError):
mint_app_jwt()
# ---------------------------------------------------------------------------
# Installation token tests
# ---------------------------------------------------------------------------
def _make_token_transport(
installation_id: int,
token: str = "ghs_test-token",
status: int = 201,
) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response:
expected_url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
if request.url.path == expected_url or request.url == expected_url:
return httpx.Response(status, json={"token": token, "expires_at": "2099-01-01T00:00:00Z"})
return httpx.Response(404, json={"error": "not found"})
return httpx.MockTransport(handler)
@pytest.mark.asyncio
async def test_mint_installation_token_returns_token(set_github_env: None) -> None:
transport = _make_token_transport(42)
async with httpx.AsyncClient(transport=transport) as client:
token = await mint_installation_token(42, client=client)
assert token == "ghs_test-token"
@pytest.mark.asyncio
async def test_mint_installation_token_caches_second_call(set_github_env: None) -> None:
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
t1 = await mint_installation_token(42, client=client)
t2 = await mint_installation_token(42, client=client)
assert t1 == "tok-1"
assert t2 == "tok-1" # from cache, not re-minted
assert call_count == 1
@pytest.mark.asyncio
async def test_mint_installation_token_force_refresh_bypasses_cache(set_github_env: None) -> None:
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
t1 = await mint_installation_token(42, client=client)
t2 = await mint_installation_token(42, client=client, force_refresh=True)
assert t1 == "tok-1"
assert t2 == "tok-2"
assert call_count == 2
@pytest.mark.asyncio
async def test_mint_installation_token_refreshes_expired_token(set_github_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""Simulate expiry by making the cached token have a very short leeway."""
monkeypatch.setattr("app.gateway.github.app_auth._INSTALLATION_TOKEN_LEEWAY_SECONDS", 999999)
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
t1 = await mint_installation_token(42, client=client)
# The leeway is huge so the next call should re-mint.
t2 = await mint_installation_token(42, client=client)
assert t1 == "tok-1"
assert t2 == "tok-2"
assert call_count == 2
@pytest.mark.asyncio
async def test_mint_installation_token_raises_on_non_201(set_github_env: None) -> None:
transport = _make_token_transport(55, status=500)
async with httpx.AsyncClient(transport=transport) as client:
with pytest.raises(Exception): # noqa: PT011
await mint_installation_token(55, client=client)
@pytest.mark.asyncio
async def test_mint_installation_token_raises_on_bad_id(set_github_env: None) -> None:
with pytest.raises(Exception): # noqa: PT011
await mint_installation_token(-1)
@pytest.mark.asyncio
async def test_mint_installation_token_without_client(set_github_env: None) -> None:
"""Uses an internal httpx client when none is passed."""
transport = _make_token_transport(99)
async with httpx.AsyncClient(transport=transport) as _: # dummy, not actually used
pass
# The mint_installation_token call will create its own client, but
# we can't intercept it. Instead, test that it works with the
# default transport by passing None — this hits the real network.
# We skip this edge in unit tests; the test with explicit client
# covers the logic. Let's just verify the function signature is
# correct.
pass
# ---------------------------------------------------------------------------
# Per-installation lock concurrency
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cold_mints_for_different_installations_run_concurrently(set_github_env: None) -> None:
"""A slow mint for installation A must not block installation B.
The old single global lock serialized every mint behind whatever
HTTPS call was in flight — bursty multi-installation traffic right
after a process restart suffered worst-case `N × roundtrip` latency
where it should have been just one roundtrip. Per-installation lock
fixes this.
We model the GitHub side as a slow handler that takes a per-request
asyncio.Event to release. If installation A's mint is sleeping with
its lock held, installation B's mint MUST still be able to proceed.
"""
import asyncio
a_release = asyncio.Event()
b_release = asyncio.Event()
a_in_flight = asyncio.Event()
b_in_flight = asyncio.Event()
async def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/installations/1/access_tokens"):
a_in_flight.set()
await a_release.wait()
return httpx.Response(201, json={"token": "tok-A", "expires_at": "2099-01-01T00:00:00Z"})
if path.endswith("/installations/2/access_tokens"):
b_in_flight.set()
await b_release.wait()
return httpx.Response(201, json={"token": "tok-B", "expires_at": "2099-01-01T00:00:00Z"})
return httpx.Response(404, json={"error": "not found"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
task_a = asyncio.create_task(mint_installation_token(1, client=client))
task_b = asyncio.create_task(mint_installation_token(2, client=client))
# Both mints must reach their handler before either gets to
# return — proves they're NOT serialized behind one global lock.
await asyncio.wait_for(a_in_flight.wait(), timeout=2.0)
await asyncio.wait_for(b_in_flight.wait(), timeout=2.0)
# Release in reverse order to also prove there's no global
# FIFO ordering — installation B can complete before A.
b_release.set()
b_token = await asyncio.wait_for(task_b, timeout=2.0)
assert b_token == "tok-B"
assert not task_a.done() # A still parked, holding its own lock
a_release.set()
a_token = await asyncio.wait_for(task_a, timeout=2.0)
assert a_token == "tok-A"
@pytest.mark.asyncio
async def test_concurrent_mints_for_same_installation_dedupe(set_github_env: None) -> None:
"""Two coroutines racing for the same installation must mint once.
Double-checked locking is the whole point of holding the per-
installation lock: the loser of the race sees the winner's freshly
cached token instead of triggering a redundant HTTPS call.
"""
import asyncio
call_count = 0
release = asyncio.Event()
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
await release.wait()
return httpx.Response(201, json={"token": f"tok-{call_count}", "expires_at": "2099-01-01T00:00:00Z"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
# Kick off two coroutines before the handler can finish. The
# first one enters the lock and starts the HTTPS call; the
# second waits for the lock.
task_1 = asyncio.create_task(mint_installation_token(42, client=client))
task_2 = asyncio.create_task(mint_installation_token(42, client=client))
# Wait until the first mint is parked in the handler so both
# tasks are guaranteed to have reached the lock check.
await asyncio.sleep(0.05)
release.set()
results = await asyncio.gather(task_1, task_2)
# Both got the same token, minted exactly once.
assert results[0] == results[1] == "tok-1"
assert call_count == 1