From d58eb67764b8e5d6c17472a845bfa4d7cc260973 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 22 Sep 2026 22:53:18 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=9C=EA=B3=B5=EC=9E=90=20=ED=82=A4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=BF=BC=EB=A6=AC=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/services/provider_credentials.py | 12 +++-- .../app/test_provider_credentials_service.py | 50 ++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) 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()