"""auth 도메인 e2e — 로그인 / 내정보 / 인증거부 흐름. 유저 시드/로그인은 auth_headers 픽스처. """ async def test_login_and_me_flow(auth_headers, client): """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출. 기대결과: 200, 본인 id·name 이 그대로 반환.""" h = await auth_headers("user1", name="홍길동") r = await client.get("/v1/auth/me", headers=h) assert r.status_code == 200 me = r.json() assert me["id"] == "user1" assert me["name"] == "홍길동" # ★ 소속사 필드는 없다. 회사(테넌트)를 걷어냈다(2026-09-08) — 쓰는 사람은 사장님 혼자다. assert "company" not in me async def test_login_with_wrong_password(auth_headers, client): """검증: 존재하는 계정에 '틀린 비밀번호'로 로그인. 기대결과: 로그인 실패 — success=False, code=1100(ACCOUNT_INVALID_INFO), 토큰 빈 문자열.""" await auth_headers("user2") # pw1234 로 시드 r = await client.post("/v1/auth/login", json={"id": "user2", "password": "wrong"}) body = r.json() assert body["result"]["success"] is False assert body["result"]["code"] == 1100 assert body.get("access_token", "") == "" async def test_login_nonexistent_account(client): """검증: 존재하지 않는 계정으로 로그인. 기대결과: 실패 — success=False (계정 유무를 '틀린 비번'과 구분해 흘리지 않음).""" r = await client.post("/v1/auth/login", json={"id": "ghost", "password": "whatever"}) assert r.json()["result"]["success"] is False async def test_me_without_token_is_rejected(client): """검증: 토큰 없이 보호 엔드포인트 /me 호출. 기대결과: 인증 단계에서 거부 — HTTP 401 또는 403.""" r = await client.get("/v1/auth/me") assert r.status_code in (401, 403) # ── 가입(id/pw) ────────────────────────────────────────────────────────────── _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "email": "boss@example.com"} async def test_signup_creates_account_and_logs_in(client, db_engine): """검증: 가입 → 받은 토큰으로 곧바로 /me. 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다.""" r = await client.post("/v1/auth/signup", json=_SIGNUP) body = r.json() assert body["result"]["success"] is True assert body["access_token"] and body["refresh_token"] me = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"})).json() assert me["id"] == "sajang1" assert me["email"] == "boss@example.com" assert me["provider"] == 1 # AuthProvider.LOCAL async def test_signup_rejects_duplicate_id(client, db_engine): """검증: 같은 아이디로 두 번 가입. 기대결과: 두 번째는 1101(ACCOUNT_ALREADY_EXIST) — 이메일만 달라도 막힌다.""" await client.post("/v1/auth/signup", json=_SIGNUP) r = await client.post("/v1/auth/signup", json={**_SIGNUP, "email": "other@example.com"}) assert r.json()["result"]["code"] == 1101 async def test_signup_rejects_duplicate_email(client, db_engine): """검증: 아이디는 다른데 이메일이 같은 가입. 기대결과: 1101 — 한 사람에게 계정이 둘 생기는 걸 이메일에서 끊는다.""" await client.post("/v1/auth/signup", json=_SIGNUP) r = await client.post("/v1/auth/signup", json={**_SIGNUP, "id": "sajang2"}) assert r.json()["result"]["code"] == 1101 async def test_signup_rejects_weak_input(client, db_engine): """검증: 짧은 비밀번호 / 규칙에 안 맞는 아이디 / 형식이 아닌 이메일. 기대결과: 전부 101(INVALID_REQUEST_DATA) — 서버가 마지막 방어선이다(화면 검사만 믿지 않는다).""" for bad in ( {**_SIGNUP, "password": "short"}, {**_SIGNUP, "id": "1abc"}, # 영문으로 시작해야 한다 {**_SIGNUP, "id": "ab"}, # 4자 미만 {**_SIGNUP, "id": "google_12345"}, # 구글 계정 아이디 접두어는 선점 금지 {**_SIGNUP, "email": "not-an-email"}, ): r = await client.post("/v1/auth/signup", json=bad) assert r.json()["result"]["code"] == 101, bad # ── 구글 로그인 ────────────────────────────────────────────────────────────── def _stub_google(monkeypatch, *, sub="1234567890", email="g@example.com", name="구글유저"): """ID 토큰 검증을 대역으로 바꾼다. 서명 검증 자체는 test_google_identity.py 가 본다.""" from services.external.google_identity import GoogleAccount async def _verify(_credential): return GoogleAccount(sub=sub, email=email, name=name) monkeypatch.setattr("services.auth_service.verify_id_token", _verify) async def test_google_login_creates_then_reuses_account(client, db_engine, monkeypatch): """검증: 같은 구글 계정으로 두 번 로그인. 기대결과: 첫 번째에 계정이 생기고, 두 번째는 **같은 user_id** 로 붙는다(계정이 늘지 않는다).""" _stub_google(monkeypatch) first = (await client.post("/v1/auth/google", json={"credential": "x"})).json() assert first["result"]["success"] is True me1 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {first['access_token']}"})).json() assert me1["provider"] == 2 # AuthProvider.GOOGLE assert me1["id"] == "google_1234567890" second = (await client.post("/v1/auth/google", json={"credential": "x"})).json() me2 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})).json() assert me2["user_id"] == me1["user_id"] async def test_google_login_follows_sub_not_email(client, db_engine, monkeypatch): """검증: 같은 sub 인데 구글 쪽 이메일이 바뀐 경우. 기대결과: 같은 계정으로 들어온다 — 매칭 키가 이메일이 아니라 sub 라서.""" _stub_google(monkeypatch, email="before@example.com") first = (await client.post("/v1/auth/google", json={"credential": "x"})).json() me1 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {first['access_token']}"})).json() _stub_google(monkeypatch, email="after@example.com") second = (await client.post("/v1/auth/google", json={"credential": "x"})).json() me2 = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})).json() assert me2["user_id"] == me1["user_id"] async def test_google_login_refuses_to_link_existing_local_account(client, db_engine, monkeypatch): """검증: id/pw 로 이미 가입된 이메일로 구글 로그인. 기대결과: 1105(ACCOUNT_PROVIDER_CONFLICT) — 소유 증명 없이 잇지 않는다(계정 선점 방지).""" await client.post("/v1/auth/signup", json=_SIGNUP) _stub_google(monkeypatch, email=_SIGNUP["email"]) r = await client.post("/v1/auth/google", json={"credential": "x"}) assert r.json()["result"]["code"] == 1105 async def test_password_login_against_google_account_is_refused(client, db_engine, monkeypatch): """검증: 구글로 만들어진 계정에 id/pw 로그인 시도. 기대결과: 1105 — 500 이 아니다(대조할 비밀번호가 없는 계정이라 해시 검증에 들어가면 터진다).""" _stub_google(monkeypatch) await client.post("/v1/auth/google", json={"credential": "x"}) r = await client.post("/v1/auth/login", json={"id": "google_1234567890", "password": "whatever"}) assert r.json()["result"]["code"] == 1105 async def test_google_login_is_off_when_client_id_is_empty(client, db_engine): """검증: GOOGLE_CLIENT_ID 가 비어 있을 때(테스트 기본값) 구글 로그인 호출. 기대결과: 1106(OAUTH_NOT_CONFIGURED) — 네트워크를 타지 않고 즉시 끊긴다.""" r = await client.post("/v1/auth/google", json={"credential": "anything"}) assert r.json()["result"]["code"] == 1106 # ── refresh 토큰 무효화(token_version) ──────────────────────────────────────── async def test_refresh_token_reissues_access_token(auth_headers, client): """검증: 정상적인 refresh 토큰으로 재발급. 기대결과: 200, 새 access 토큰이 실려 온다.""" h = await auth_headers("refuser1") login = (await client.post("/v1/auth/login", json={"id": "refuser1", "password": "pw1234"})).json() refresh_token = login["refresh_token"] r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh_token}"}) body = r.json() assert body["result"]["success"] is True assert body["access_token"] # 새 access 토큰이 실제로 먹힌다. me = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"})).json() assert me["id"] == "refuser1" del h # auth_headers 는 시드 용도로만 쓴다 async def test_refresh_token_is_revoked_after_password_change(auth_headers, client): """검증: refresh 토큰을 받은 **뒤에** 비밀번호를 바꾼다. 기대결과: ★ ACCOUNT_SESSION_REVOKED — 예전 refresh 토큰으로는 더 이상 access 토큰을 못 찍는다. (비밀번호를 훔쳐 넣어 둔 refresh 토큰이 있어도 비번을 바꾸면 끊긴다는 것이 이 테스트의 요점이다.)""" h = await auth_headers("refuser2") login = (await client.post("/v1/auth/login", json={"id": "refuser2", "password": "pw1234"})).json() old_refresh_token = login["refresh_token"] upd = await client.patch("/v1/auth/me", headers=h, json={"password": "newpassword123"}) assert upd.json()["result"]["success"] is True r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {old_refresh_token}"}) body = r.json() assert body["result"]["success"] is False assert body["result"]["code"] == 1108, body # ACCOUNT_SESSION_REVOKED assert body.get("access_token", "") == "" # ★ 새로 로그인하면(새 비밀번호로) 새 refresh 토큰은 당연히 먹힌다. relogin = (await client.post("/v1/auth/login", json={"id": "refuser2", "password": "newpassword123"})).json() r2 = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {relogin['refresh_token']}"}) assert r2.json()["result"]["success"] is True async def test_refresh_token_is_revoked_when_account_blocked(auth_headers, client, db_engine): """검증: refresh 토큰을 받은 뒤 계정이 차단(UserStatus.INACTIVE)된다. 기대결과: ★ ACCOUNT_BLOCKED_USER — 로그인만 막는 게 아니라 이미 나간 refresh 토큰도 막는다.""" from sqlalchemy import text h = await auth_headers("refuser3") login = (await client.post("/v1/auth/login", json={"id": "refuser3", "password": "pw1234"})).json() del h async with db_engine.begin() as c: await c.execute(text("UPDATE users SET status = 2 WHERE id = 'refuser3'")) # UserStatus.INACTIVE r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {login['refresh_token']}"}) body = r.json() assert body["result"]["success"] is False assert body["result"]["code"] == 1102 # ACCOUNT_BLOCKED_USER