"""카카오톡 채널 신원 연결. 여기서 지키는 것은 하나다 — **연결되지 않은 발화자는 어떤 사장님도 되지 못한다.** 나머지 검사(코드 일회성·만료·시도 제한·재발급)는 전부 그 한 줄을 지탱한다. """ 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