7bd8124199
POST /api/auth/register now requires a real date_of_birth (ISO YYYY-MM-DD). The server computes age (auth.calculate_age) and rejects under-18s with 403; a future/nonsensical date is 400. The DOB is stored and surfaced only to that user (/api/auth/me). Still self-attested (a user can enter a false DOB) -- but a concrete, stored, legally-meaningful data point and the seam for a future ID-check pass. Encryption-at-rest for the column and the Tier-2 hook are not done; the limitation is stated plainly in docs/architecture/content_moderation.md 5.2. - auth.py: RegisterRequest.date_of_birth (date); MIN_AGE=18; register() computes + enforces; UserResponse.date_of_birth typed `date | None` (the old `str | None` would have 500'd /me once a DOB was actually stored). - api_client.gd / main_menu.gd: the Over18 checkbox is now a "Date of birth (YYYY-MM-DD)" field with a client-side sanity check. - tests: test_auth_age_gate.py rewritten around the endpoint + age math (adult/minor/exactly-18/future-dob/stored); ~25 over_18 references across 6 backend test files + 6 game smoke tests updated. Backend 74 passed; game smokes green (auth 6/6, menu_full_flow 12/12, e2e 4/4, etc.); fresh-clone boot PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
252 lines
9.0 KiB
Python
252 lines
9.0 KiB
Python
"""Integration tests for the auth endpoints (register + login).
|
|
|
|
These tests hit the real FastAPI app against the real configured database
|
|
(DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated
|
|
in-memory DB), so they exercise the full stack: Pydantic validation → DB →
|
|
JWT → response. Requires migrations already applied (alembic upgrade head)
|
|
before running.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from app.config import settings
|
|
from app.main import app
|
|
from app.database import get_db
|
|
from app.routers.auth import pwd_context
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _override_db():
|
|
"""Override get_db so the app uses a fresh session per test.
|
|
|
|
Deliberately does NOT reuse app.database's shared engine: FastAPI's
|
|
(sync) TestClient runs each request through anyio's blocking-portal
|
|
machinery, which can hand different requests different event loops.
|
|
asyncpg connections are bound to the loop they were created on, so
|
|
reusing one from the shared engine's connection pool across requests
|
|
raises "cannot perform operation: another operation is in progress" the
|
|
moment a test makes more than one request (register-then-login,
|
|
register-twice, etc.) -- confirmed this is the actual cause, not a bug
|
|
in the endpoints themselves. NullPool sidesteps it: every checkout is a
|
|
brand-new connection, never handed to a different loop than the one
|
|
that opened it.
|
|
"""
|
|
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
|
|
|
async def _get_db():
|
|
async_session = test_session_factory()
|
|
try:
|
|
yield async_session
|
|
await async_session.commit()
|
|
except Exception:
|
|
await async_session.rollback()
|
|
raise
|
|
finally:
|
|
await async_session.close()
|
|
|
|
app.dependency_overrides[get_db] = _get_db
|
|
yield
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Register endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRegister:
|
|
"""Tests for POST /api/auth/register."""
|
|
|
|
def test_register_success(self):
|
|
"""A valid registration returns 201 with a token."""
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "newuser",
|
|
"email": "new@example.com",
|
|
"password": "secret123",
|
|
"date_of_birth": "1990-01-01",
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert "access_token" in data
|
|
assert data["token_type"] == "bearer"
|
|
|
|
def test_register_rejects_under_18(self):
|
|
"""A DOB that computes to under 18 gets a 403 (see test_auth_age_gate
|
|
for the age-math coverage)."""
|
|
from datetime import date
|
|
client = TestClient(app)
|
|
minor_dob = date.today().replace(year=date.today().year - 14).isoformat()
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "kid",
|
|
"email": "kid@example.com",
|
|
"password": "secret123",
|
|
"date_of_birth": minor_dob,
|
|
},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert "at least 18" in resp.json()["detail"]
|
|
|
|
def test_register_rejects_duplicate_username(self):
|
|
"""Registering the same username twice returns 409."""
|
|
client = TestClient(app)
|
|
# First registration
|
|
client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "duplicate",
|
|
"email": "dup1@example.com",
|
|
"password": "secret123",
|
|
"date_of_birth": "1990-01-01",
|
|
},
|
|
)
|
|
# Second registration with same username, different email
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "duplicate",
|
|
"email": "dup2@example.com",
|
|
"password": "secret123",
|
|
"date_of_birth": "1990-01-01",
|
|
},
|
|
)
|
|
assert resp.status_code == 409
|
|
assert "Username already registered" in resp.json()["detail"]
|
|
|
|
def test_register_rejects_duplicate_email(self):
|
|
"""Registering the same email twice returns 409."""
|
|
client = TestClient(app)
|
|
client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "user1",
|
|
"email": "same@example.com",
|
|
"password": "secret123",
|
|
"date_of_birth": "1990-01-01",
|
|
},
|
|
)
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "user2",
|
|
"email": "same@example.com",
|
|
"password": "secret123",
|
|
"date_of_birth": "1990-01-01",
|
|
},
|
|
)
|
|
assert resp.status_code == 409
|
|
assert "Email already registered" in resp.json()["detail"]
|
|
|
|
def test_register_missing_dob(self):
|
|
"""Missing the required date_of_birth field returns 422."""
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "user",
|
|
"email": "user@example.com",
|
|
"password": "secret123",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Login endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLogin:
|
|
"""Tests for POST /api/auth/login."""
|
|
|
|
def _create_user(self, username: str, password: str, email: str = None):
|
|
"""Helper to create a user via register."""
|
|
client = TestClient(app)
|
|
if email is None:
|
|
email = f"{username}@example.com"
|
|
resp = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": username,
|
|
"email": email,
|
|
"password": password,
|
|
"date_of_birth": "1990-01-01",
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
return resp.json()["access_token"]
|
|
|
|
def test_login_success(self):
|
|
"""Correct credentials return a token."""
|
|
self._create_user("loginuser", "correctpass")
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/api/auth/login",
|
|
data={"username": "loginuser", "password": "correctpass"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "access_token" in data
|
|
assert data["token_type"] == "bearer"
|
|
|
|
def test_login_wrong_password(self):
|
|
"""Wrong password returns 401."""
|
|
# Distinct username from test_login_success -- there's no per-test
|
|
# DB rollback/isolation here (real Postgres via the app's own
|
|
# dependency-overridden session), so reusing "loginuser" collided
|
|
# with the user that test already created (409 on register instead
|
|
# of the intended 401-on-login assertion this test is actually for).
|
|
self._create_user("loginuser_wrongpass", "correctpass")
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/api/auth/login",
|
|
data={"username": "loginuser_wrongpass", "password": "wrongpass"},
|
|
)
|
|
assert resp.status_code == 401
|
|
assert "Incorrect username or password" in resp.json()["detail"]
|
|
|
|
def test_login_nonexistent_user(self):
|
|
"""Non-existent user returns 401 (not 404)."""
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/api/auth/login",
|
|
data={"username": "nobody", "password": "any"},
|
|
)
|
|
assert resp.status_code == 401
|
|
assert "Incorrect username or password" in resp.json()["detail"]
|
|
|
|
def test_login_accepts_form_encoded(self):
|
|
"""Login accepts application/x-www-form-urlencoded (OAuth2)."""
|
|
self._create_user("formuser", "formpass")
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/api/auth/login",
|
|
data={"username": "formuser", "password": "formpass"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "access_token" in resp.json()
|
|
|
|
def test_login_token_is_valid_jwt(self):
|
|
"""The returned token is a valid JWT that can be decoded."""
|
|
self._create_user("jwttoken", "jwt123")
|
|
client = TestClient(app)
|
|
resp = client.post(
|
|
"/api/auth/login",
|
|
data={"username": "jwttoken", "password": "jwt123"},
|
|
)
|
|
token = resp.json()["access_token"]
|
|
# Verify it's a 3-part JWT
|
|
parts = token.split(".")
|
|
assert len(parts) == 3
|