chrysopedia/backend/tests/test_auth.py
jlightner 77f44b0b48 test: Implemented auth API router with register/login/me/update-profile…
- "backend/routers/auth.py"
- "backend/main.py"
- "backend/auth.py"
- "backend/requirements.txt"
- "backend/tests/conftest.py"
- "backend/tests/test_auth.py"

GSD-Task: S02/T02
2026-04-03 21:54:11 +00:00

314 lines
11 KiB
Python

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