vignette/apps/api/app/test_provider_oauth.py
Yun Chan cb4d45579d
Some checks failed
API contract / OpenAPI type drift (push) Failing after 59s
OAuth 로그인 버튼으로 provider 연결 + 제공자 연결 패널 그리드 이동
claude(setup-token과 동일한 PKCE, code#state 수동 흐름)와
openrouter(헤드리스 코드 표시 모드)는 OAuth 로그인 버튼으로 연결한다.
관리자가 제공자 로그인 뒤 표시되는 인증 코드를 붙여넣으면 서버가
토큰/키로 교환해 저장·게이트웨이 push한다. state는 서버 발급·15분
단일 사용으로 CSRF를 막는다. codex·agy는 붙여넣기 방식을 유지한다.

제공자 연결 패널은 우측 사이드에서 메인 컬럼의 Provider·모델별 사용
원장 아래로 옮기고 반응형 그리드(너비에 따라 1~n열)로 배치한다.
2026-09-11 17:33:51 +09:00

136 lines
5.3 KiB
Python

"""provider OAuth(PKCE) 서비스 단위 테스트 — 실제 네트워크 호출 없음."""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from app.services import provider_oauth as svc
class _FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
def raise_for_status(self):
if self.status_code >= 400:
import httpx
raise httpx.HTTPStatusError("err", request=None, response=None)
def json(self):
return self._payload
class _FakeAsyncClient:
def __init__(self, payload):
self._payload = payload
self.captured = {}
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return None
async def post(self, url, **kwargs):
self.captured = {"url": url, **kwargs}
return _FakeResponse(self._payload)
class StartOAuthTest(unittest.TestCase):
def setUp(self):
svc._PENDING.clear()
def tearDown(self):
svc._PENDING.clear()
def test_unsupported_provider_rejected(self):
with self.assertRaises(svc.ProviderCredentialError):
svc.start_oauth("codex", "admin@example.com")
def test_claude_authorize_url_contains_pkce_and_state(self):
result = svc.start_oauth("claude", "admin@example.com")
self.assertIn("https://claude.ai/oauth/authorize", result["authorize_url"])
self.assertIn("code_challenge_method=S256", result["authorize_url"])
self.assertIn(f"&state={result['state']}", result["authorize_url"])
self.assertIn("user%3Ainference", result["authorize_url"])
self.assertIn(result["state"], svc._PENDING)
def test_openrouter_authorize_url_is_headless(self):
result = svc.start_oauth("openrouter", "admin@example.com")
self.assertIn("https://openrouter.ai/auth?", result["authorize_url"])
self.assertIn("code_challenge_method=S256", result["authorize_url"])
self.assertNotIn("callback_url", result["authorize_url"])
def test_pending_expires(self):
import time
result = svc.start_oauth("claude", "admin@example.com")
attempt = svc._PENDING[result["state"]]
stale = svc._PendingOAuth(
provider=attempt.provider,
code_verifier=attempt.code_verifier,
created_by=attempt.created_by,
created_at=time.time() - svc.OAUTH_PENDING_TTL_SECONDS - 1,
)
svc._PENDING[result["state"]] = stale
with self.assertRaises(svc.ProviderCredentialError):
svc._pop_pending("claude", result["state"])
class FinishOAuthTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
svc._PENDING.clear()
def tearDown(self):
svc._PENDING.clear()
async def test_claude_code_state_exchange_and_save(self):
start = svc.start_oauth("claude", "admin@example.com")
fake = _FakeAsyncClient({"access_token": "sk-ant-oat-token-abc", "expires_in": 31536000})
with patch.object(svc.httpx, "AsyncClient", return_value=fake), patch.object(
svc, "save_credential"
) as save, patch.object(svc, "push_credentials_to_gateway") as push:
save.return_value = SimpleNamespace(
provider="claude", token_hint="…oat", auth_kind="oauth_token",
updated_by="admin@example.com", updated_at=1.0,
)
push.return_value = {"synced": True}
result = await svc.finish_oauth(
"claude", f"auth-code-123#{start['state']}", start["state"], "admin@example.com"
)
self.assertEqual(result["stored"].auth_kind, "oauth_token")
sent = fake.captured["json"]
self.assertEqual(sent["grant_type"], "authorization_code")
self.assertEqual(sent["code"], "auth-code-123")
self.assertIn("code_verifier", sent)
self.assertEqual(fake.captured["url"], svc._CLAUDE_OAUTH["token_url"])
async def test_state_mismatch_rejected(self):
start = svc.start_oauth("claude", "admin@example.com")
with self.assertRaises(svc.ProviderCredentialError):
await svc.finish_oauth("claude", "code#other-state", start["state"], "a@b.com")
async def test_openrouter_code_exchange_returns_api_key(self):
start = svc.start_oauth("openrouter", "admin@example.com")
fake = _FakeAsyncClient({"key": "sk-or-v1-oauth-key"})
with patch.object(svc.httpx, "AsyncClient", return_value=fake), patch.object(
svc, "save_credential"
) as save, patch.object(svc, "push_credentials_to_gateway") as push:
save.return_value = SimpleNamespace(
provider="openrouter", token_hint="…key", auth_kind="api_key",
updated_by="admin@example.com", updated_at=1.0,
)
push.return_value = {"synced": True}
result = await svc.finish_oauth(
"openrouter", "or-code-xyz", start["state"], "admin@example.com"
)
self.assertEqual(result["stored"].auth_kind, "api_key")
self.assertEqual(fake.captured["url"], svc._OPENROUTER_OAUTH["exchange_url"])
self.assertEqual(fake.captured["json"]["code"], "or-code-xyz")
self.assertEqual(fake.captured["json"]["code_challenge_method"], "S256")
if __name__ == "__main__":
unittest.main()