여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
182 lines
7.3 KiB
Python
182 lines
7.3 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
|