관리자 AI 제공자 연결 기능 추가 (claude·codex·agy·openrouter 토큰 붙여넣기 연결)
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s
관리자 /admin/ai 화면에서 provider 토큰(OAuth 액세스 토큰 또는 API 키)을 발급받아 붙여넣으면 DB에 암호화 저장되고, shared-secret으로 보호되는 게이트웨이 내부 엔드포인트로 push되어 실행 중인 엔진 컨테이너에 즉시 적용된다. NAS처럼 게이트웨이가 컨테이너로 도는 환경에서 CLI·로컬 PC 의존 없이 claude_api·openai·openrouter를 연결할 수 있다. - 게이트웨이: openrouter 어댑터 신설(chat/completions), OAuth 토큰이면 Anthropic Bearer 헤더, /internal/provider-credentials GET/POST와 boot_id 기반 재동기화 - API: app.admin_provider_credential 테이블(idempotent DDL), stdlib HMAC-CTR+MAC 암호화 서비스(PROVIDER_CREDENTIAL_SECRET, 폴백 SESSION_SECRET), /admin/providers CRUD·검증 라우트 - 웹: 제공자 연결 패널(저장·검증·해제, 토큰 힌트만 표시), 엔진 선택에 OpenRouter 추가, api.gen.ts 재생성 - compose: api 서비스에 PROVIDER_CREDENTIAL_SECRET 전달(로컬·NAS)
This commit is contained in:
parent
6831a79ffb
commit
4b45d33142
22 changed files with 2088 additions and 13 deletions
77
apps/api/app/test_provider_credentials_service.py
Normal file
77
apps/api/app/test_provider_credentials_service.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""provider 자격증명 서비스(암호화·카탈로그) 단위 테스트."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.services import provider_credentials as svc
|
||||
|
||||
|
||||
class TokenEncryptionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patcher = patch.object(
|
||||
svc.settings,
|
||||
"provider_credential_secret",
|
||||
SecretStr("unit-test-master-secret-0123456789"),
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_roundtrip(self):
|
||||
plain = "sk-or-v1-아주-긴-토큰-abc123!@#"
|
||||
envelope = svc.encrypt_token(plain)
|
||||
self.assertTrue(envelope.startswith("v1."))
|
||||
self.assertNotIn(plain, envelope)
|
||||
self.assertEqual(svc.decrypt_token(envelope), plain)
|
||||
|
||||
def test_unique_nonce_produces_unique_ciphertext(self):
|
||||
first = svc.encrypt_token("same-token")
|
||||
second = svc.encrypt_token("same-token")
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
def test_tampered_envelope_rejected(self):
|
||||
envelope = svc.encrypt_token("secret-token")
|
||||
raw = bytearray(envelope.encode("ascii"))
|
||||
raw[-1] = raw[-1] # 정상
|
||||
corrupted = (envelope[:-1] + ("A" if envelope[-1] != "A" else "B"))
|
||||
with self.assertRaises(svc.ProviderCredentialError):
|
||||
svc.decrypt_token(corrupted)
|
||||
|
||||
def test_master_secret_falls_back_to_session_secret(self):
|
||||
with patch.object(svc.settings, "provider_credential_secret", SecretStr("")), patch.object(
|
||||
svc.settings, "session_secret", "session-secret-long-enough-for-prod-1234"
|
||||
):
|
||||
self.assertEqual(svc._master_secret(), b"session-secret-long-enough-for-prod-1234")
|
||||
|
||||
def test_short_master_secret_rejected(self):
|
||||
with patch.object(svc.settings, "provider_credential_secret", SecretStr("short")):
|
||||
with self.assertRaises(svc.ProviderCredentialError):
|
||||
svc._master_secret()
|
||||
|
||||
|
||||
class TokenHintTest(unittest.TestCase):
|
||||
def test_long_token_tail_hint(self):
|
||||
self.assertEqual(svc.token_hint("sk-1234567890abcdef"), "…cdef")
|
||||
|
||||
def test_short_token_generic_hint(self):
|
||||
self.assertEqual(svc.token_hint("short"), "저장됨")
|
||||
|
||||
|
||||
class CatalogTest(unittest.TestCase):
|
||||
def test_catalog_covers_provider_codes(self):
|
||||
self.assertEqual(
|
||||
tuple(meta.code for meta in svc.PROVIDER_CATALOG),
|
||||
svc.PROVIDER_CODES,
|
||||
)
|
||||
|
||||
def test_openrouter_and_agy_present(self):
|
||||
codes = set(svc.PROVIDER_CODES)
|
||||
self.assertIn("openrouter", codes)
|
||||
self.assertIn("agy", codes)
|
||||
self.assertIn("claude", codes)
|
||||
self.assertIn("codex", codes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue