o2o-site-AEO/solution/backend/tests/test_auth.py
Mina Choi 94551afdaf [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프
가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.

- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
  body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
  실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
  잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
  순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
  `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
  회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
  스코프가 없으면 아무에게도 안 보이는 유령이다.
  실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
  격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다

남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.

검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:14 +09:00

160 lines
8.1 KiB
Python

"""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