카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 user_id 와 관계가 없다. 다른 엔드포인트는 전부 place_crud.get_place(s, owner_user_id, place_id) 로 소유자 범위를 지키는데 채널 발화에는 그 owner_user_id 를 줄 근거가 없다 — 매핑이 없으면 채널 진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다. - postgres-init: owner_kakao_links(0021 + init.sql). 부분 유니크 셋 중 uq_kakao_link_channel_key(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다 - services/kakao_link_service: 일회성은 코드 값이 아니라 WHERE status='PENDING' CAS 한 문장이 보장한다. 실패는 전부 같은 에러 — 없는 코드·만료·시도초과를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다 - 코드는 sha256 만 저장. 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧 연결 권한이다. 글자에서 0·O·1·I·L 제외 — 잘못 읽으면 원인이 화면에 안 보인다 - router/v1/agent/kakao: 셋 다 no-store·no-referrer·noindex. ★ 소비(redeem) 엔드포인트는 일부러 없다 — 웹훅 서명 검증 전에 공개 소비 경로를 열면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 - config/agent_config: social_config 와 일부러 가름. SNS 게재는 되돌릴 수 없는 대외 발화, 에이전트는 자기 사이트를 고치는 창구 — 승인 강도가 다르다 - frontend/features/agent: /sites 의 Threads 카드 옆. 연결은 사람 단위라 같은 자리다 - docs/AGENT.md 신설, CLAUDE.md 색인·함정, DEVLOG test_kakao_link.py 15 passed. 전체 780 passed / 50 failed — 그 50건은 HEAD 에서도 동일(워크트리 대조), 기존 이슈로 이번 변경과 무관. npm run lint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
186 lines
7.7 KiB
Python
186 lines
7.7 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
|