★★ 챗봇 웹훅의 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>
243 lines
10 KiB
Python
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
|