- "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
314 lines
11 KiB
Python
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
|