diff --git a/apps/api/app/services/provider_credentials.py b/apps/api/app/services/provider_credentials.py index 9c25fbe..7fecc19 100644 --- a/apps/api/app/services/provider_credentials.py +++ b/apps/api/app/services/provider_credentials.py @@ -340,7 +340,7 @@ async def save_credential( async with pool.acquire() as conn: row = await conn.fetchrow( """ - INSERT INTO app.admin_provider_credential ( + INSERT INTO app.admin_provider_credential AS existing ( provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at, refresh_token_encrypted, extra ) @@ -351,8 +351,14 @@ async def save_credential( auth_kind = EXCLUDED.auth_kind, updated_by = EXCLUDED.updated_by, updated_at = now(), - refresh_token_encrypted = COALESCE(EXCLUDED.refresh_token_encrypted, refresh_token_encrypted), - extra = CASE WHEN EXCLUDED.extra::text = '{}'::jsonb THEN extra ELSE EXCLUDED.extra END, + refresh_token_encrypted = COALESCE( + EXCLUDED.refresh_token_encrypted, + existing.refresh_token_encrypted + ), + extra = CASE + WHEN EXCLUDED.extra = '{}'::jsonb THEN existing.extra + ELSE EXCLUDED.extra + END, last_verified_at = NULL, last_verify_ok = NULL, last_verify_error = NULL diff --git a/apps/api/app/test_provider_credentials_service.py b/apps/api/app/test_provider_credentials_service.py index 559a762..6ece9a5 100644 --- a/apps/api/app/test_provider_credentials_service.py +++ b/apps/api/app/test_provider_credentials_service.py @@ -1,7 +1,7 @@ """provider 자격증명 서비스(암호화·카탈로그) 단위 테스트.""" import unittest -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pydantic import SecretStr @@ -73,5 +73,53 @@ class CatalogTest(unittest.TestCase): self.assertIn("codex", codes) +class SaveCredentialQueryTest(unittest.IsolatedAsyncioTestCase): + async def test_upsert_preserves_omitted_refresh_and_extra(self): + row = { + "provider": "openrouter", + "token_hint": "…next", + "auth_kind": "oauth_token", + "updated_by": "admin@example.com", + "updated_at": None, + "last_verified_at": None, + "last_verify_ok": None, + "last_verify_error": None, + "refresh_token_encrypted": None, + "extra": {}, + } + conn = AsyncMock() + conn.fetchrow.return_value = row + + class _Acquire: + async def __aenter__(self): + return conn + + async def __aexit__(self, exc_type, exc, traceback): + return False + + class _Pool: + def acquire(self): + return _Acquire() + + with ( + patch.object(svc, "ensure_table", AsyncMock()), + patch.object(svc, "get_pool", return_value=_Pool()), + patch.object(svc.settings, "provider_credential_secret", SecretStr("unit-test-master-secret-0123456789")), + ): + await svc.save_credential( + provider="openrouter", + token="new-access-token", + auth_kind="oauth_token", + updated_by="admin@example.com", + ) + + query = conn.fetchrow.await_args.args[0] + self.assertIn("INSERT INTO app.admin_provider_credential AS existing", query) + self.assertIn("EXCLUDED.refresh_token_encrypted,\n existing.refresh_token_encrypted", query) + self.assertIn("WHEN EXCLUDED.extra = '{}'::jsonb THEN existing.extra", query) + self.assertEqual(conn.fetchrow.await_args.args[6], None) + self.assertEqual(conn.fetchrow.await_args.args[7], "{}") + + if __name__ == "__main__": unittest.main()