계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데 그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. - auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가 NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다 - services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와 email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에 들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다 - users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다). provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서 NOT NULL 컬럼이면 그 경로가 통째로 깨진다 - attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다 - 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 — 남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5 - LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다 (admin 라우터에 /signup 이 없어 404 가 난다) - GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 — 두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다 ★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션). pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조 거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관). tsc·eslint·vite build 통과.
160 lines
8.2 KiB
Python
160 lines
8.2 KiB
Python
"""auth 도메인 e2e — 로그인 / 내정보 / 인증거부 흐름.
|
|
|
|
유저 시드/로그인은 auth_headers 픽스처.
|
|
"""
|
|
|
|
|
|
async def test_login_and_me_flow(auth_headers, client, company_id):
|
|
"""검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출.
|
|
기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환."""
|
|
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"] == "홍길동"
|
|
assert me["company"]["company_id"] == company_id
|
|
|
|
|
|
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
|
|
assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다
|
|
|
|
|
|
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
|