런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 — 새로 생긴 것은 형식 변환(kakao_bot)과 대화 상태(channel)뿐이다. ★★ 오픈빌더는 서명을 주지 않는다. URL 만 알면 누구나 때릴 수 있고 userRequest.user.id 를 위조하면 그 사장님 행세를 한다 — 1단계의 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 X-Agent-Secret, compare_digest) + 선택적 KAKAO_BOT_ID 대조로 막고, 시크릿이 없으면 엔드포인트가 404 다 (401 은 "여기 뭔가 있다" 를 알려 준다). - router/v1/agent/kakao_bot: 카카오 형식을 아는 유일한 파일. 헤더·경로 두 경로 - services/agent/channel: 신원(★ 토큰을 발급하지 않는다) · 가게 고르기 · 확인 - 0022: owner_kakao_links 에 current_place_id · pending_* 빌더 화면과 다른 것 셋: - 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다 - place_id 가 URL 에 없다 → 여럿이면 추측하지 않고 되묻는다. 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다 - 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 든다. ★ 3분 만료가 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 돈다 5초 벽은 DEADLINE_SEC=4.0 으로 끊고, 어떤 실패도 200+안내다 — 메신저에서는 500 도 침묵으로 보인다. 밟은 것: execute_lambda 는 람다 반환값을 그대로 준다(CRUD 관례가 (ErrorType,값)). 우리 람다가 객체만 돌려주자 언패킹 TypeError 가 났고, 라우터가 예외를 삼켜 화면에는 안내 한 줄만 보였다 — 원인이 안 보이는 종류다. test_kakao_webhook.py 17 passed. 전체 841 passed / 53 failed(이전과 동일). npm run lint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
265 lines
12 KiB
Python
265 lines
12 KiB
Python
"""카카오톡 채널 웹훅.
|
|
|
|
여기서 지키는 것 셋:
|
|
1. 시크릿 없는 요청은 아무것도 하지 못한다 — 오픈빌더가 서명을 주지 않으므로 이게 유일한 문이다
|
|
2. 연결되지 않은 발화자는 어떤 사장님도 되지 못한다
|
|
3. 확인(SEMI)은 **만료되면 안 먹는다** — 한참 뒤의 "네" 한 마디에 묵은 발행이 돌면 안 된다
|
|
"""
|
|
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
|
|
from services import kakao_link_service as link_service
|
|
from services.agent import channel, runtime
|
|
|
|
SECRET = "test-webhook-secret-0123456789"
|
|
PATH = "/v1/agent/kakao/webhook"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def secret(monkeypatch):
|
|
monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", SECRET)
|
|
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh")
|
|
monkeypatch.setenv("AGENT_CHAT_ENABLED", "1")
|
|
monkeypatch.delenv("KAKAO_BOT_ID", raising=False)
|
|
|
|
|
|
def body(utterance, speaker="kakao-speaker-1", bot_id="bot-1"):
|
|
return {
|
|
"userRequest": {"utterance": utterance, "user": {"id": speaker, "type": "botUserKey"}},
|
|
"bot": {"id": bot_id, "name": "ADO2"},
|
|
}
|
|
|
|
|
|
def said(res) -> str:
|
|
return res.json()["template"]["outputs"][0]["simpleText"]["text"]
|
|
|
|
|
|
def quick(res) -> list:
|
|
return [q["label"] for q in res.json()["template"].get("quickReplies", [])]
|
|
|
|
|
|
async def owner_with_place(client, auth_headers, name="대화숙소"):
|
|
h = await auth_headers(f"kakao-{uuid.uuid4().hex[:8]}")
|
|
res = await client.post("/v1/place", headers=h, json={"name": name, "category": 1})
|
|
return h, res.json()["place"]["place_id"]
|
|
|
|
|
|
async def user_id_of(db_engine, place_id):
|
|
async with db_engine.begin() as c:
|
|
return (
|
|
await c.execute(text("SELECT owner_user_id FROM places WHERE place_id=:p"), {"p": uuid.UUID(place_id)})
|
|
).scalar_one()
|
|
|
|
|
|
async def link(db_engine, client, auth_headers, speaker, name="대화숙소"):
|
|
"""사장님 하나 + 가게 하나 + 그 사장님에 묶인 카톡 발화자."""
|
|
h, pid = await owner_with_place(client, auth_headers, name)
|
|
uid = await user_id_of(db_engine, pid)
|
|
await link_service.redeem((await link_service.issue_code(uid))["code"], speaker)
|
|
return h, pid, uid
|
|
|
|
|
|
# ── 1. 시크릿이 유일한 문이다 ────────────────────────────────────────────
|
|
|
|
async def test_시크릿이_없으면_존재를_알리지_않는다(client, monkeypatch):
|
|
"""★ 401 이 아니라 404 다. 401 은 '여기 뭔가 있다' 를 알려 준다."""
|
|
monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", "")
|
|
assert (await client.post(PATH, json=body("안녕"))).status_code == 404
|
|
|
|
|
|
async def test_틀린_시크릿도_404(client):
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": "wrong-secret"}, json=body("안녕"))
|
|
assert res.status_code == 404
|
|
res = await client.post(PATH, json=body("안녕")) # 헤더 없음
|
|
assert res.status_code == 404
|
|
|
|
|
|
async def test_경로_시크릿도_받는다(client, db_engine):
|
|
res = await client.post(f"{PATH}/{SECRET}", json=body("안녕"))
|
|
assert res.status_code == 200
|
|
res = await client.post(f"{PATH}/wrong-secret", json=body("안녕"))
|
|
assert res.status_code == 404
|
|
|
|
|
|
async def test_다른_봇의_요청은_거절한다(client, monkeypatch, db_engine):
|
|
monkeypatch.setenv("KAKAO_BOT_ID", "bot-1")
|
|
ok = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="bot-1"))
|
|
assert ok.status_code == 200
|
|
bad = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="남의봇"))
|
|
assert bad.status_code == 404
|
|
|
|
|
|
# ── 2. 연결되지 않은 발화자 ──────────────────────────────────────────────
|
|
|
|
async def test_연결_전에는_어떤_도구도_돌지_않는다(client, monkeypatch, db_engine):
|
|
called = AsyncMock()
|
|
monkeypatch.setattr(runtime, "chat", called)
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("체크인 3시로 바꿔줘", "모르는-키"))
|
|
assert res.status_code == 200
|
|
assert "연결" in said(res)
|
|
called.assert_not_awaited()
|
|
|
|
|
|
async def test_발화자_id_를_위조해도_남의_가게에_닿지_않는다(client, auth_headers, db_engine, monkeypatch):
|
|
"""★ 신원 연결이 없으면 카톡 진입점만 소유자 범위 밖에 놓인다 — 그걸 막는 자리다."""
|
|
await link(db_engine, client, auth_headers, "진짜-사장님-키")
|
|
called = AsyncMock()
|
|
monkeypatch.setattr(runtime, "chat", called)
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", "위조한-키"))
|
|
assert "연결" in said(res)
|
|
called.assert_not_awaited()
|
|
|
|
|
|
async def test_코드를_보내면_연결된다(client, auth_headers, db_engine):
|
|
h, pid = await owner_with_place(client, auth_headers)
|
|
uid = await user_id_of(db_engine, pid)
|
|
code = (await link_service.issue_code(uid))["code"]
|
|
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body(code, "새-발화자"))
|
|
assert "연결됐습니다" in said(res)
|
|
assert await link_service.resolve("새-발화자") == uid
|
|
|
|
|
|
async def test_틀린_코드는_이유를_구분해_말하지_않는다(client, db_engine):
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("ZZZZZZ", "새-발화자2"))
|
|
assert "코드가 맞지 않거나" in said(res)
|
|
|
|
|
|
# ── 3. 확인은 만료되면 안 먹는다 ─────────────────────────────────────────
|
|
|
|
async def test_발행은_묻고_바로가기를_준다(client, auth_headers, db_engine, monkeypatch):
|
|
speaker = "확인-테스트-키"
|
|
await link(db_engine, client, auth_headers, speaker)
|
|
# 테스트는 실제 모델을 부르지 않는다 — 런타임만 열고 선택 결과를 대신 준다.
|
|
monkeypatch.setattr(runtime, "is_configured", lambda: True)
|
|
monkeypatch.setattr(runtime, "_choose", AsyncMock(return_value={"tool": "publish", "args": {}, "message": ""}))
|
|
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("발행해줘", speaker))
|
|
assert channel.CONFIRM_LABEL in quick(res)
|
|
async with db_engine.begin() as c:
|
|
pending = (
|
|
await c.execute(text("SELECT pending_tool FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker})
|
|
).scalar_one()
|
|
assert pending == "publish"
|
|
|
|
|
|
async def test_만료된_확인에_네_라고_해도_실행되지_않는다(client, auth_headers, db_engine, monkeypatch):
|
|
"""★ 이게 없으면 한참 뒤의 '네' 한 마디에 **묵은 발행**이 돈다."""
|
|
speaker = "만료-테스트-키"
|
|
await link(db_engine, client, auth_headers, speaker)
|
|
async with db_engine.begin() as c:
|
|
await c.execute(
|
|
text("""UPDATE owner_kakao_links
|
|
SET pending_tool='publish', pending_args='{}'::jsonb,
|
|
pending_expires_at = now() - interval '1 minute'
|
|
WHERE channel_user_key=:k"""),
|
|
{"k": speaker},
|
|
)
|
|
ran = AsyncMock()
|
|
monkeypatch.setattr(runtime, "chat", ran)
|
|
await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("네", speaker))
|
|
# 확인으로 실행된 적이 없다(다른 말로 취급돼 일반 경로로 갔을 수는 있다).
|
|
for call in ran.await_args_list:
|
|
assert call.kwargs.get("confirm") is None
|
|
|
|
|
|
async def test_바로가기_라벨은_예로_읽히는_말에_들어_있다():
|
|
"""★ 라벨과 _YES 가 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다."""
|
|
assert channel.CONFIRM_LABEL in channel._YES
|
|
assert channel.PUBLISH_LABEL in channel._YES
|
|
assert channel.DECLINE_LABEL in channel._NO
|
|
|
|
|
|
# ── 가게 고르기 ──────────────────────────────────────────────────────────
|
|
|
|
async def test_가게가_여럿이면_추측하지_않고_되묻는다(client, auth_headers, db_engine, monkeypatch):
|
|
"""★ 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다."""
|
|
speaker = "다가게-키"
|
|
h, _pid, _uid = await link(db_engine, client, auth_headers, speaker, "첫째가게")
|
|
await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1})
|
|
|
|
called = AsyncMock()
|
|
monkeypatch.setattr(runtime, "chat", called)
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", speaker))
|
|
assert "어느 가게" in said(res)
|
|
assert set(quick(res)) == {"첫째가게", "둘째가게"}
|
|
called.assert_not_awaited()
|
|
|
|
# 고르면 기억한다.
|
|
picked = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("둘째가게", speaker))
|
|
assert "둘째가게" in said(picked)
|
|
async with db_engine.begin() as c:
|
|
current = (
|
|
await c.execute(
|
|
text("SELECT current_place_id FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker}
|
|
)
|
|
).scalar_one()
|
|
assert current is not None
|
|
|
|
|
|
# ── 5초 벽 · 실패 ────────────────────────────────────────────────────────
|
|
|
|
async def test_느리면_침묵_대신_안내로_끊는다(client, monkeypatch, db_engine):
|
|
"""넘기면 카카오가 연결을 끊는다 — 사장님에게는 말없이 실패하는 봇이 된다."""
|
|
import router.v1.agent.kakao_bot as bot
|
|
|
|
async def slow(*_a, **_kw):
|
|
import asyncio
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
monkeypatch.setattr(bot, "DEADLINE_SEC", 0.01)
|
|
monkeypatch.setattr(bot.channel, "handle", slow)
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕"))
|
|
assert res.status_code == 200
|
|
assert "잠시 뒤" in said(res)
|
|
|
|
|
|
async def test_내부_오류도_200_으로_답한다(client, monkeypatch, db_engine):
|
|
"""메신저에서는 500 도 침묵으로 보인다 — 무슨 일이 있었는지 한 줄은 말해야 한다."""
|
|
import router.v1.agent.kakao_bot as bot
|
|
|
|
monkeypatch.setattr(bot.channel, "handle", AsyncMock(side_effect=RuntimeError("어딘가 터짐")))
|
|
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕"))
|
|
assert res.status_code == 200
|
|
assert "어딘가 터짐" not in res.text
|
|
|
|
|
|
async def test_발화자가_없으면_거기서_끝낸다(client, db_engine):
|
|
res = await client.post(
|
|
PATH, headers={"X-Agent-Secret": SECRET}, json={"userRequest": {"utterance": "안녕", "user": {}}}
|
|
)
|
|
assert res.status_code == 200
|
|
assert "확인하지 못했" in said(res)
|
|
|
|
|
|
# ── 응답 형식 ────────────────────────────────────────────────────────────
|
|
|
|
def test_카카오_형식은_이_파일_밖으로_나가지_않는다():
|
|
"""★ 서비스 계층에 새면 다른 채널을 붙일 때 전부 걷어내야 한다."""
|
|
import ast
|
|
import inspect
|
|
|
|
# ★ 주석·docstring 에 이름이 나오는 것은 '샌' 것이 아니다 — 실제 코드만 본다.
|
|
tree = ast.parse(inspect.getsource(channel))
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
|
node.value.value = "" # docstring 비우기
|
|
dumped = ast.dump(tree)
|
|
for token in ("simpleText", "quickReplies", "userRequest", "2.0"):
|
|
assert token not in dumped, token
|
|
|
|
|
|
def test_바로가기는_열_개를_넘기지_않는다():
|
|
import router.v1.agent.kakao_bot as bot
|
|
|
|
out = bot._reply("안녕", [f"라벨{i}" for i in range(20)])
|
|
assert len(out["template"]["quickReplies"]) == 10
|
|
assert out["version"] == "2.0"
|
|
assert out["template"]["outputs"][0]["simpleText"]["text"] == "안녕"
|