"""Integration tests for the auth router — registration, login, profile.""" import uuid from datetime import datetime, timedelta, timezone import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from models import InviteCode, User # ── Registration ───────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_register_valid(client, invite_code): """Register with a valid invite code → 201 + user created.""" resp = await client.post("/api/v1/auth/register", json={ "email": "newuser@example.com", "password": "strongpass1", "display_name": "New User", "invite_code": invite_code, }) assert resp.status_code == 201 data = resp.json() assert data["email"] == "newuser@example.com" assert data["display_name"] == "New User" assert data["role"] == "creator" assert "id" in data # Password not leaked assert "hashed_password" not in data @pytest.mark.asyncio async def test_register_invalid_invite_code(client, invite_code): """Register with a wrong invite code → 403.""" resp = await client.post("/api/v1/auth/register", json={ "email": "bad@example.com", "password": "strongpass1", "display_name": "Bad", "invite_code": "WRONG-CODE", }) assert resp.status_code == 403 assert "Invalid invite code" in resp.json()["detail"] @pytest.mark.asyncio async def test_register_expired_invite_code(client, db_engine): """Register with an expired invite code → 403.""" factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: past = (datetime.now(timezone.utc) - timedelta(days=1)).replace(tzinfo=None) session.add(InviteCode(code="EXPIRED-CODE", uses_remaining=10, expires_at=past)) await session.commit() resp = await client.post("/api/v1/auth/register", json={ "email": "exp@example.com", "password": "strongpass1", "display_name": "Expired", "invite_code": "EXPIRED-CODE", }) assert resp.status_code == 403 assert "expired" in resp.json()["detail"].lower() @pytest.mark.asyncio async def test_register_exhausted_invite_code(client, db_engine): """Register with an invite code that has uses_remaining=0 → 403.""" factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: session.add(InviteCode(code="EXHAUSTED", uses_remaining=0)) await session.commit() resp = await client.post("/api/v1/auth/register", json={ "email": "nope@example.com", "password": "strongpass1", "display_name": "Nope", "invite_code": "EXHAUSTED", }) assert resp.status_code == 403 assert "exhausted" in resp.json()["detail"].lower() @pytest.mark.asyncio async def test_register_invite_code_decrements(client, db_engine): """Invite code uses_remaining decrements after registration.""" factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: session.add(InviteCode(code="SINGLE-USE", uses_remaining=1)) await session.commit() # First registration succeeds resp = await client.post("/api/v1/auth/register", json={ "email": "first@example.com", "password": "strongpass1", "display_name": "First", "invite_code": "SINGLE-USE", }) assert resp.status_code == 201 # Second registration with same code fails (exhausted) resp = await client.post("/api/v1/auth/register", json={ "email": "second@example.com", "password": "strongpass1", "display_name": "Second", "invite_code": "SINGLE-USE", }) assert resp.status_code == 403 @pytest.mark.asyncio async def test_register_duplicate_email(client, invite_code, registered_user): """Register with an already-used email → 409.""" resp = await client.post("/api/v1/auth/register", json={ "email": "testuser@chrysopedia.com", "password": "anotherpass1", "display_name": "Dup", "invite_code": invite_code, }) assert resp.status_code == 409 assert "already registered" in resp.json()["detail"].lower() # ── Login ──────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_login_success(client, registered_user): """Login with correct credentials → 200 + JWT.""" resp = await client.post("/api/v1/auth/login", json={ "email": "testuser@chrysopedia.com", "password": "securepass123", }) assert resp.status_code == 200 data = resp.json() assert "access_token" in data assert data["token_type"] == "bearer" @pytest.mark.asyncio async def test_login_wrong_password(client, registered_user): """Login with wrong password → 401.""" resp = await client.post("/api/v1/auth/login", json={ "email": "testuser@chrysopedia.com", "password": "wrongpassword", }) assert resp.status_code == 401 @pytest.mark.asyncio async def test_login_nonexistent_email(client): """Login with an email that doesn't exist → 401.""" resp = await client.post("/api/v1/auth/login", json={ "email": "nobody@example.com", "password": "somepass123", }) assert resp.status_code == 401 # ── Profile (GET /me) ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_get_me_authenticated(client, auth_headers): """GET /me with valid token → 200 + profile.""" resp = await client.get("/api/v1/auth/me", headers=auth_headers) assert resp.status_code == 200 data = resp.json() assert data["email"] == "testuser@chrysopedia.com" assert data["display_name"] == "Test User" @pytest.mark.asyncio async def test_get_me_no_token(client, db_engine): """GET /me without token → 401.""" resp = await client.get("/api/v1/auth/me") assert resp.status_code == 401 @pytest.mark.asyncio async def test_get_me_invalid_token(client, db_engine): """GET /me with garbage token → 401.""" resp = await client.get("/api/v1/auth/me", headers={ "Authorization": "Bearer invalid.garbage.token", }) assert resp.status_code == 401 @pytest.mark.asyncio async def test_get_me_expired_token(client, db_engine, invite_code): """GET /me with an expired JWT → 401.""" from auth import create_access_token # Register a user first resp = await client.post("/api/v1/auth/register", json={ "email": "expired@example.com", "password": "strongpass1", "display_name": "Expired Token User", "invite_code": invite_code, }) assert resp.status_code == 201 user_id = resp.json()["id"] # Create a token that expires immediately token = create_access_token(user_id, "creator", expires_minutes=-1) resp = await client.get("/api/v1/auth/me", headers={ "Authorization": f"Bearer {token}", }) assert resp.status_code == 401 # ── Profile (PUT /me) ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_update_display_name(client, auth_headers): """PUT /me updates display_name → 200 + new name.""" resp = await client.put("/api/v1/auth/me", json={ "display_name": "Updated Name", }, headers=auth_headers) assert resp.status_code == 200 assert resp.json()["display_name"] == "Updated Name" # Verify persistence resp2 = await client.get("/api/v1/auth/me", headers=auth_headers) assert resp2.json()["display_name"] == "Updated Name" @pytest.mark.asyncio async def test_update_password(client, invite_code): """PUT /me changes password → can login with new password.""" # Register await client.post("/api/v1/auth/register", json={ "email": "pwchange@example.com", "password": "oldpassword1", "display_name": "PW User", "invite_code": invite_code, }) # Login login_resp = await client.post("/api/v1/auth/login", json={ "email": "pwchange@example.com", "password": "oldpassword1", }) token = login_resp.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} # Change password resp = await client.put("/api/v1/auth/me", json={ "current_password": "oldpassword1", "new_password": "newpassword1", }, headers=headers) assert resp.status_code == 200 # Old password fails resp_old = await client.post("/api/v1/auth/login", json={ "email": "pwchange@example.com", "password": "oldpassword1", }) assert resp_old.status_code == 401 # New password works resp_new = await client.post("/api/v1/auth/login", json={ "email": "pwchange@example.com", "password": "newpassword1", }) assert resp_new.status_code == 200 # ── Malformed inputs (422 validation) ─────────────────────────────────────── @pytest.mark.asyncio async def test_register_missing_fields(client): """Register with missing fields → 422.""" resp = await client.post("/api/v1/auth/register", json={}) assert resp.status_code == 422 @pytest.mark.asyncio async def test_register_empty_password(client): """Register with empty password → 422 (min_length=8).""" resp = await client.post("/api/v1/auth/register", json={ "email": "a@b.com", "password": "", "display_name": "X", "invite_code": "CODE", }) assert resp.status_code == 422 @pytest.mark.asyncio async def test_login_empty_body(client): """Login with empty body → 422.""" resp = await client.post("/api/v1/auth/login", json={}) assert resp.status_code == 422 # ── Public endpoints unaffected ────────────────────────────────────────────── @pytest.mark.asyncio async def test_public_techniques_no_auth(client, db_engine): """GET /api/v1/techniques works without auth.""" resp = await client.get("/api/v1/techniques") # 200 even if empty — no 401/403 assert resp.status_code == 200 @pytest.mark.asyncio async def test_public_creators_no_auth(client, db_engine): """GET /api/v1/creators works without auth.""" resp = await client.get("/api/v1/creators") assert resp.status_code == 200