o2o-site-AEO/solution/backend/tests/test_kakao_link.py
hbyang ca0bea77a7 [feat] solution/backend: 카카오 로그인 — 회원번호로 카톡 채널을 코드 없이 잇는다
★★ 챗봇 웹훅의 user.properties.appUserId 는 카카오 로그인의 회원번호와 **같은 값**이다
(카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 카카오로 로그인만 해 두면 채널에
말을 거는 순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다.

- external/kakao_identity: 액세스 토큰을 카카오에 되물어 확인한다. ★ 응답의 app_id 를
  우리 앱과 대조하는 것이 구글의 aud 검사에 해당한다 — 이게 없으면 남의 앱 토큰으로
  우리 계정이 된다. 이름·이메일은 동의 항목이라 못 받아도 로그인은 되게 했다
- auth_service.kakao_login: google_login 과 같은 세 갈래. 이메일이 겹쳐도 자동으로
  잇지 않는다(DECISIONS 1 — 계정 선점)
- kakao_link_service.link_by_app_user_id: 자동 매칭. ★ 이미 다른 사장님에게 묶인
  카톡은 빼앗지 않는다 — 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다
- ★ 코드 경로는 그대로 둔다: id/pw·구글 가입자에겐 appUserId 가 없고, 봇에 앱키가
  안 물린 환경에서는 값 자체가 안 온다

★ 함께 고친 것 — services/agent/tools.py 가 사라진 site_payload._DEFAULT_THEME 를
보고 있었다(c690862 템플릿 정의 통합에서 이름이 없어졌는데 이 한 줄만 남았다).
**대화의 섹션 기능이 통째로 죽어 있었고** 웹훅이 AttributeError 를 삼켜 "지금은
처리할 수 없어요" 로만 보였다 — common/template_catalog.industry_of 로 바꿨다.

test_kakao_link·test_kakao_webhook·test_agent_runtime·test_auth 194 passed
(신규 4: 자동 매칭·미가입자·빼앗지 않음·재진입). 남은 1건은 컨테이너에 실제
GOOGLE_CLIENT_ID 가 있어 나는 기존 실패다.

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

243 lines
10 KiB
Python

"""카카오톡 채널 신원 연결."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import text
from services import kakao_link_service as service
from services.kakao_link_service import KakaoLinkError
@pytest.fixture(autouse=True)
def channel(monkeypatch):
"""KAKAO_CHANNEL_PUBLIC_ID 가 있어야 기능이 열린다."""
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh")
monkeypatch.setenv("KAKAO_LINK_CODE_TTL_MIN", "10")
monkeypatch.setenv("KAKAO_LINK_MAX_ATTEMPTS", "3")
async def test_채널_설정이_없으면_기능_자체가_꺼진다(db_engine, monkeypatch):
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "")
assert service.enabled() is False
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_DISABLED"):
await service.issue_code(uuid.uuid4())
# 화면은 자리를 그리되 버튼을 죽인다 — 상태 조회 자체는 살아 있어야 한다.
assert (await service.state(uuid.uuid4()))["connection_enabled"] is False
async def test_코드는_한_번만_먹는다(db_engine):
user_id, key = uuid.uuid4(), "kakao-key-1"
code = (await service.issue_code(user_id))["code"]
assert await service.redeem(code, key) == user_id
# 두 번째는 실패해야 한다.
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(code, "kakao-key-2")
async def test_연결된_발화자만_사장님이_된다(db_engine):
user_id, key = uuid.uuid4(), "kakao-key-3"
# 이게 이 기능의 전부다 — 연결 전에는 어떤 값도 돌려주지 않는다.
assert await service.resolve(key) is None
await service.redeem((await service.issue_code(user_id))["code"], key)
assert await service.resolve(key) == user_id
assert await service.resolve("모르는-키") is None
async def test_만료된_코드는_안_먹는다(db_engine):
user_id = uuid.uuid4()
code = (await service.issue_code(user_id))["code"]
async with db_engine.begin() as c:
await c.execute(
text("UPDATE owner_kakao_links SET code_expires_at = now() - interval '1 minute' WHERE user_id=:u"),
{"u": user_id},
)
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(code, "kakao-key-4")
async def test_오입력_시도는_상한에서_끊긴다(db_engine):
"""짧은 코드(6자리)라 무차별 대입이 가능하다."""
user_id = uuid.uuid4()
code = (await service.issue_code(user_id))["code"]
async with db_engine.begin() as c:
await c.execute(
text("UPDATE owner_kakao_links SET code_attempts = 3 WHERE user_id=:u"), {"u": user_id}
)
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(code, "kakao-key-5")
async def test_재발급은_행을_늘리지_않고_옛_코드를_죽인다(db_engine):
user_id = uuid.uuid4()
first = (await service.issue_code(user_id))["code"]
second = (await service.issue_code(user_id))["code"]
assert first != second
async with db_engine.begin() as c:
rows = (
await c.execute(
text("SELECT count(*) FROM owner_kakao_links WHERE user_id=:u AND deleted=false"),
{"u": user_id},
)
).scalar_one()
assert rows == 1
# 옛 코드가 살아 있으면 둘 중 어느 것이 먹을지 화면이 말해 줄 수 없다.
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_CODE_INVALID"):
await service.redeem(first, "kakao-key-6")
assert await service.redeem(second, "kakao-key-6") == user_id
async def test_이미_연결된_사장님은_코드를_다시_받지_않는다(db_engine):
user_id = uuid.uuid4()
await service.redeem((await service.issue_code(user_id))["code"], "kakao-key-7")
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_ALREADY"):
await service.issue_code(user_id)
async def test_한_카카오_계정은_한_사장님에만_묶인다(db_engine):
"""없으면 같은 카톡 계정이 여러 사장님에 걸려 '어느 가게 이야기냐' 가 DB 에서 갈라진다."""
first, second, key = uuid.uuid4(), uuid.uuid4(), "kakao-key-8"
await service.redeem((await service.issue_code(first))["code"], key)
code = (await service.issue_code(second))["code"]
with pytest.raises(Exception): # 부분 유니크 위반 — 연결 자체가 성립하지 않는다
await service.redeem(code, key)
assert await service.resolve(key) == first
async def test_해제하면_그_발화자는_다시_아무도_아니다(db_engine):
user_id, key = uuid.uuid4(), "kakao-key-9"
await service.redeem((await service.issue_code(user_id))["code"], key)
await service.disconnect(user_id)
assert await service.resolve(key) is None
# 행은 남는다 — 지우면 누가 언제 연결했는지가 사라진다.
async with db_engine.begin() as c:
status = (
await c.execute(
text("SELECT status FROM owner_kakao_links WHERE user_id=:u"), {"u": user_id}
)
).scalar_one()
assert status == "REVOKED"
# 해제한 뒤에는 다시 연결할 수 있어야 한다.
await service.redeem((await service.issue_code(user_id))["code"], key)
assert await service.resolve(key) == user_id
async def test_해제할_연결이_없으면_거절한다(db_engine):
with pytest.raises(KakaoLinkError, match="KAKAO_LINK_NOT_FOUND"):
await service.disconnect(uuid.uuid4())
async def test_상태는_코드_평문을_돌려주지_않는다(db_engine):
user_id = uuid.uuid4()
await service.issue_code(user_id)
snapshot = await service.state(user_id)
assert snapshot["status"] == "PENDING"
assert snapshot["code_expires_at"]
assert "code" not in snapshot
async def test_저장되는_것은_해시뿐이다(db_engine):
user_id = uuid.uuid4()
code = (await service.issue_code(user_id))["code"]
async with db_engine.begin() as c:
stored = (
await c.execute(
text("SELECT code_sha FROM owner_kakao_links WHERE user_id=:u"), {"u": user_id}
)
).scalar_one()
assert stored != code
assert len(stored) == 64
async def test_라우터는_로그인_없이_열리지_않는다(client):
for method, path in [
("get", "/v1/agent/kakao/link"),
("post", "/v1/agent/kakao/link/code"),
("post", "/v1/agent/kakao/link/disconnect"),
]:
res = await getattr(client, method)(path)
assert res.status_code in (401, 403), path
async def test_코드는_응답에서_한_번만_나가고_캐시되지_않는다(client, auth_headers):
h = await auth_headers("kakao-owner")
res = await client.post("/v1/agent/kakao/link/code", headers=h)
assert res.status_code == 200
assert res.json()["code"]
assert res.headers["Cache-Control"] == "no-store"
assert res.headers["Referrer-Policy"] == "no-referrer"
state = await client.get("/v1/agent/kakao/link", headers=h)
assert "code" not in state.json()
def test_코드에는_헷갈리는_글자가_없다():
"""잘못 읽어 실패하면 원인이 화면에 안 보이고 '연결이 안 된다' 로만 보인다."""
assert not set("01OILl") & set(service._CODE_ALPHABET)
assert len(service._new_code()) == service._CODE_LENGTH
# ── 카카오 로그인 자동 매칭 (2026-09-30) ────────────────────────────────
async def _kakao_user(db_engine, uid: str):
"""카카오로 가입한 사장님 하나. provider_uid 가 곧 챗봇의 appUserId 다."""
from common.enums import AuthProvider
user_id = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("""INSERT INTO users(user_id, id, name, provider, provider_uid, status, role)
VALUES (:u, :id, '테스트', :p, :uid, 1, 1)"""),
{"u": user_id, "id": f"kakao_{uid}", "p": AuthProvider.KAKAO.value, "uid": uid},
)
return user_id
async def test_카카오로_가입했으면_코드_없이_이어진다(db_engine):
"""★ appUserId 가 카카오 로그인 회원번호와 같은 값이라, 말을 거는 순간 알아볼 수 있다."""
user_id = await _kakao_user(db_engine, "900001")
assert await service.resolve("kakao-speaker-a") is None
linked = await service.link_by_app_user_id("900001", "kakao-speaker-a")
assert linked == user_id
assert await service.resolve("kakao-speaker-a") == user_id
async def test_카카오로_가입하지_않았으면_자동으로_잇지_않는다(db_engine):
"""id/pw·구글 가입자에게는 appUserId 가 없다 — 코드 경로가 유일한 길이다."""
assert await service.link_by_app_user_id("없는번호", "kakao-speaker-b") is None
assert await service.resolve("kakao-speaker-b") is None
# 값이 비어 있어도 조용히 지나간다.
assert await service.link_by_app_user_id("", "kakao-speaker-b") is None
assert await service.link_by_app_user_id("900001", "") is None
async def test_남의_카톡을_빼앗지_않는다(db_engine):
"""★ 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다."""
first = await _kakao_user(db_engine, "900002")
second = await _kakao_user(db_engine, "900003")
await service.link_by_app_user_id("900002", "kakao-speaker-c")
# 다른 사장님이 같은 카톡으로 오면 잇지 않는다.
assert await service.link_by_app_user_id("900003", "kakao-speaker-c") is None
assert await service.resolve("kakao-speaker-c") == first
assert second is not None
async def test_같은_사람이_다시_말을_걸어도_그대로다(db_engine):
"""이미 이어져 있으면 성공으로 답하고 아무것도 바꾸지 않는다."""
user_id = await _kakao_user(db_engine, "900004")
assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id
assert await service.link_by_app_user_id("900004", "kakao-speaker-d") == user_id
async with db_engine.begin() as c:
n = (await c.execute(
text("SELECT count(*) FROM owner_kakao_links WHERE user_id=:u AND deleted=false"),
{"u": user_id},
)).scalar_one()
assert n == 1