o2o-site-AEO/solution/backend/tests/test_kakao_identity.py
hbyang 3253166622 [feat] solution,deploy: 카카오 로그인을 인가 코드 교환으로 — 시크릿을 서버에 가둔다
콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 Redirect URI 가
프론트 경로(/auth/kakao/callback)인데, 코드는 "프론트가 액세스 토큰을 들고 온다" 는
전제였다 — 시크릿을 번들에 구울 수 없으므로 그 흐름으로는 토큰을 받을 수 없다.

- services/external/kakao_identity: access_token 검증 → **인가 코드 교환**으로 교체.
  ★ 우리 REST 키·시크릿으로 교환한 토큰이라 정의상 우리 앱 것이다 — 구글의 aud 대조에
    해당하는 검사(KAKAO_LOGIN_APP_ID)가 구조적으로 필요 없어져 설정 칸째로 지웠다.
  ★ 로그인 앱과 챗봇 앱이 다르면 로그인은 되는데 채널 매칭만 조용히 안 된다 —
    REST 키가 어긋나면 경고 로그를 남긴다.
  ★ 미인증 이메일은 신원으로 쓰지 않는다(is_email_valid·is_email_verified 둘 다). 이 값으로
    auth_service 가 다른 수단 가입과의 충돌을 판정한다.
- config/config_models: KAKAO_LOGIN_APP_ID → REST_API_KEY · CLIENT_SECRET · REDIRECT_URI
- router/v1/auth: Req_KakaoLogin {access_token} → {code, redirect_uri}
- frontend: lib/kakaoIdentity(인가 URL · sessionStorage state) · KakaoSignInButton ·
  pages/KakaoCallbackPage(코드는 1회용이라 ref 로 한 번만 보낸다) · routes
  ★ 로그인 폼에서 카카오를 구글 위에 둔다 — 이 수단으로 들어와야 6자리 코드 없이 채널이 이어진다
- compose · nginx/Dockerfile: REST 키·Redirect URI 만 프론트로 흘려보낸다. 시크릿은 안 나간다
- 어드민 키는 저장하지 않는다 — 탈퇴까지 되는 키인데 로그인에는 쓰이지 않는다
- api/generated: orval 재생성. 카카오 외에도 오래 밀려 있던 문구·agent 경로가 함께 따라왔다

테스트 8건 추가(tests/test_kakao_identity.py), 관련 85 passed
(test_auth::test_google_login_is_off_when_client_id_is_empty 는 기존 실패)
lint(react-router typegen + tsc + eslint) 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 16:51:05 +09:00

140 lines
5.8 KiB
Python

"""카카오 로그인 — 인가 코드를 서버가 교환하고 회원번호를 꺼낸다.
★ 회원번호(`id`)가 챗봇 웹훅의 `appUserId` 와 같은 값이라는 것이 이 모듈의 존재 이유다.
그 값을 문자열로 유지하는지(정수로 흘리면 매핑 조회가 조용히 빗나간다)를 여기서 못 박는다.
"""
import httpx
import pytest
from config import config_models
from config.server_configs import kakao_login_config
from services.external import kakao_identity
from services.external.kakao_identity import KakaoNotConfigured, KakaoTokenInvalid
pytestmark = pytest.mark.asyncio
REST_KEY = "test-rest-key"
SECRET = "test-secret"
REDIRECT = "https://example.test/auth/kakao/callback"
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", REST_KEY)
monkeypatch.setenv("KAKAO_LOGIN_CLIENT_SECRET", SECRET)
monkeypatch.setenv("KAKAO_LOGIN_REDIRECT_URI", REDIRECT)
config_models.get_kakao_login_config.cache_clear()
fresh = config_models.get_kakao_login_config()
# 모듈이 import 시점에 잡아 둔 싱글턴을 본다 — 그 객체를 갈아 끼운다.
monkeypatch.setattr(kakao_identity, "kakao_login_config", fresh)
yield
config_models.get_kakao_login_config.cache_clear()
def _client(handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
async def test_인가코드를_시크릿과_함께_교환한다():
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["form"] = dict(httpx.QueryParams(request.content.decode()))
return httpx.Response(200, json={"access_token": "at-1"})
async with _client(handler) as client:
token = await kakao_identity.exchange_code("code-1", client=client)
assert token == "at-1"
assert seen["url"] == "https://kauth.kakao.com/oauth/token"
assert seen["form"] == {
"grant_type": "authorization_code",
"client_id": REST_KEY,
"client_secret": SECRET,
"redirect_uri": REDIRECT,
"code": "code-1",
}
async def test_호출부가_준_리다이렉트가_설정값을_이긴다():
"""로컬처럼 주소가 다른 환경 때문에 열어 둔 구멍이다 — 실제로 그 값이 나가는지 본다."""
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["form"] = dict(httpx.QueryParams(request.content.decode()))
return httpx.Response(200, json={"access_token": "at-2"})
async with _client(handler) as client:
await kakao_identity.exchange_code("code-2", "http://localhost/cb", client=client)
assert seen["form"]["redirect_uri"] == "http://localhost/cb"
async def test_교환_거절은_KakaoTokenInvalid():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "invalid_grant", "error_code": "KOE320"})
async with _client(handler) as client:
with pytest.raises(KakaoTokenInvalid):
await kakao_identity.exchange_code("used-code", client=client)
async def test_설정이_비면_호출하지_않고_KakaoNotConfigured(monkeypatch):
"""키가 없으면 카카오 로그인만 꺼진다 — 다른 외부 어댑터와 같은 규칙이다."""
monkeypatch.setenv("KAKAO_LOGIN_REST_API_KEY", "")
config_models.get_kakao_login_config.cache_clear()
monkeypatch.setattr(kakao_identity, "kakao_login_config", config_models.get_kakao_login_config())
def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover - 불리면 실패다
raise AssertionError("설정이 없는데 카카오를 호출했다")
async with _client(handler) as client:
with pytest.raises(KakaoNotConfigured):
await kakao_identity.exchange_code("code", client=client)
async def test_회원번호는_문자열이다():
"""★ 카카오는 `id` 를 **정수**로 준다. 챗봇의 appUserId 는 문자열이라, 여기서 문자열로
맞춰 두지 않으면 `link_by_app_user_id` 조회가 조용히 빗나간다."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"id": 1234567890,
"kakao_account": {"email": "owner@example.test", "profile": {"nickname": "사장님"}},
})
async with _client(handler) as client:
account = await kakao_identity.fetch_account("at-1", client=client)
assert account.uid == "1234567890"
assert account.email == "owner@example.test"
assert account.name == "사장님"
async def test_이메일_이름이_없어도_신원은_성립한다():
"""동의 항목이라 비어 올 수 있다. 판정 키는 회원번호뿐이다."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"id": 7, "kakao_account": {}})
async with _client(handler) as client:
account = await kakao_identity.fetch_account("at-1", client=client)
assert (account.uid, account.email, account.name) == ("7", "", "")
@pytest.mark.parametrize("flag", ["is_email_valid", "is_email_verified"])
async def test_미인증_이메일은_신원으로_쓰지_않는다(flag):
"""★ auth_service 가 이 값으로 다른 수단 가입과의 충돌을 판정한다 — 확인 안 된 주소가
들어가면 남의 계정을 막거나 가로채는 데 쓰일 수 있다."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"id": 8,
"kakao_account": {"email": "someone@example.test", flag: False},
})
async with _client(handler) as client:
account = await kakao_identity.fetch_account("at-1", client=client)
assert account.email == ""
assert account.uid == "8"