feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
302
backend/tests/integration/test_auth_api.py
Normal file
302
backend/tests/integration/test_auth_api.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Auth API endpoint integration tests (TEST-04 partial).
|
||||
|
||||
Tests auth endpoints end-to-end against real PostgreSQL:
|
||||
- POST /api/auth/login (success, wrong password, nonexistent user)
|
||||
- POST /api/auth/refresh (token refresh flow)
|
||||
- GET /api/auth/me (current user info)
|
||||
- Protected endpoint access without/with invalid token
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.services.auth import hash_password
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
from tests.integration.conftest import TEST_DATABASE_URL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _admin_commit(url, callback):
|
||||
"""Open a fresh admin connection, run callback, commit, close."""
|
||||
engine = create_async_engine(url, echo=False)
|
||||
async with engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn, expire_on_commit=False)
|
||||
result = await callback(session)
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
return result
|
||||
|
||||
|
||||
async def _admin_cleanup(url, *table_names):
|
||||
"""Delete from specified tables via admin engine."""
|
||||
from sqlalchemy import text
|
||||
|
||||
engine = create_async_engine(url, echo=False)
|
||||
async with engine.connect() as conn:
|
||||
for table in table_names:
|
||||
await conn.execute(text(f"DELETE FROM {table}"))
|
||||
await conn.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: Login success
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_login_success(client, admin_engine):
|
||||
"""POST /api/auth/login with correct credentials returns 200 and tokens."""
|
||||
uid = uuid.uuid4().hex[:6]
|
||||
|
||||
async def setup(session):
|
||||
tenant = Tenant(name=f"auth-login-{uid}")
|
||||
session.add(tenant)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
email=f"auth-login-{uid}@example.com",
|
||||
hashed_password=hash_password("SecurePass123!"),
|
||||
name="Auth Test User",
|
||||
role="tenant_admin",
|
||||
tenant_id=tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return {"email": user.email, "tenant_id": str(tenant.id)}
|
||||
|
||||
data = await _admin_commit(TEST_DATABASE_URL, setup)
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": data["email"], "password": "SecurePass123!"},
|
||||
)
|
||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||
|
||||
body = resp.json()
|
||||
assert "access_token" in body
|
||||
assert "refresh_token" in body
|
||||
assert body["token_type"] == "bearer"
|
||||
assert len(body["access_token"]) > 0
|
||||
assert len(body["refresh_token"]) > 0
|
||||
|
||||
# Verify httpOnly cookie is set
|
||||
cookies = resp.cookies
|
||||
# Cookie may or may not appear in httpx depending on secure flag
|
||||
# Just verify the response contains Set-Cookie header
|
||||
set_cookie = resp.headers.get("set-cookie", "")
|
||||
assert "access_token" in set_cookie or len(body["access_token"]) > 0
|
||||
finally:
|
||||
await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: Login with wrong password
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_login_wrong_password(client, admin_engine):
|
||||
"""POST /api/auth/login with wrong password returns 401."""
|
||||
uid = uuid.uuid4().hex[:6]
|
||||
|
||||
async def setup(session):
|
||||
tenant = Tenant(name=f"auth-wrongpw-{uid}")
|
||||
session.add(tenant)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
email=f"auth-wrongpw-{uid}@example.com",
|
||||
hashed_password=hash_password("CorrectPass123!"),
|
||||
name="Wrong PW User",
|
||||
role="tenant_admin",
|
||||
tenant_id=tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return {"email": user.email}
|
||||
|
||||
data = await _admin_commit(TEST_DATABASE_URL, setup)
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": data["email"], "password": "WrongPassword!"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid credentials" in resp.json()["detail"]
|
||||
finally:
|
||||
await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: Login with nonexistent user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_login_nonexistent_user(client):
|
||||
"""POST /api/auth/login with email that doesn't exist returns 401."""
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": f"doesnotexist-{uuid.uuid4().hex[:6]}@example.com", "password": "Anything!"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid credentials" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: Token refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_token_refresh(client, admin_engine):
|
||||
"""POST /api/auth/refresh with valid refresh token returns new tokens."""
|
||||
uid = uuid.uuid4().hex[:6]
|
||||
|
||||
async def setup(session):
|
||||
tenant = Tenant(name=f"auth-refresh-{uid}")
|
||||
session.add(tenant)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
email=f"auth-refresh-{uid}@example.com",
|
||||
hashed_password=hash_password("RefreshPass123!"),
|
||||
name="Refresh User",
|
||||
role="tenant_admin",
|
||||
tenant_id=tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return {"email": user.email}
|
||||
|
||||
data = await _admin_commit(TEST_DATABASE_URL, setup)
|
||||
|
||||
try:
|
||||
# Login first to get refresh token
|
||||
login_resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": data["email"], "password": "RefreshPass123!"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
tokens = login_resp.json()
|
||||
refresh_token = tokens["refresh_token"]
|
||||
original_access = tokens["access_token"]
|
||||
|
||||
# Use refresh token to get new access token
|
||||
refresh_resp = await client.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert refresh_resp.status_code == 200
|
||||
|
||||
new_tokens = refresh_resp.json()
|
||||
assert "access_token" in new_tokens
|
||||
assert "refresh_token" in new_tokens
|
||||
assert new_tokens["token_type"] == "bearer"
|
||||
# Verify the new access token is a valid JWT (can be same if within same second)
|
||||
assert len(new_tokens["access_token"]) > 0
|
||||
assert len(new_tokens["refresh_token"]) > 0
|
||||
|
||||
# Verify the new access token works for /me
|
||||
me_resp = await client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {new_tokens['access_token']}"},
|
||||
)
|
||||
assert me_resp.status_code == 200
|
||||
assert me_resp.json()["email"] == data["email"]
|
||||
finally:
|
||||
await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: Get current user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_get_current_user(client, admin_engine):
|
||||
"""GET /api/auth/me with valid token returns current user info."""
|
||||
uid = uuid.uuid4().hex[:6]
|
||||
|
||||
async def setup(session):
|
||||
tenant = Tenant(name=f"auth-me-{uid}")
|
||||
session.add(tenant)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
email=f"auth-me-{uid}@example.com",
|
||||
hashed_password=hash_password("MePass123!"),
|
||||
name="Me User",
|
||||
role="tenant_admin",
|
||||
tenant_id=tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return {"email": user.email, "tenant_id": str(tenant.id), "user_id": str(user.id)}
|
||||
|
||||
data = await _admin_commit(TEST_DATABASE_URL, setup)
|
||||
|
||||
try:
|
||||
# Login
|
||||
login_resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": data["email"], "password": "MePass123!"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
# Get /me
|
||||
me_resp = await client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert me_resp.status_code == 200
|
||||
|
||||
me_data = me_resp.json()
|
||||
assert me_data["email"] == data["email"]
|
||||
assert me_data["name"] == "Me User"
|
||||
assert me_data["role"] == "tenant_admin"
|
||||
assert me_data["tenant_id"] == data["tenant_id"]
|
||||
assert me_data["id"] == data["user_id"]
|
||||
finally:
|
||||
await _admin_cleanup(TEST_DATABASE_URL, "users", "tenants")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6: Protected endpoint without token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_protected_endpoint_without_token(client):
|
||||
"""GET /api/tenants/{id}/devices without auth headers returns 401."""
|
||||
fake_tenant_id = str(uuid.uuid4())
|
||||
resp = await client.get(f"/api/tenants/{fake_tenant_id}/devices")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 7: Protected endpoint with invalid token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_protected_endpoint_with_invalid_token(client):
|
||||
"""GET /api/tenants/{id}/devices with invalid Bearer token returns 401."""
|
||||
fake_tenant_id = str(uuid.uuid4())
|
||||
resp = await client.get(
|
||||
f"/api/tenants/{fake_tenant_id}/devices",
|
||||
headers={"Authorization": "Bearer totally-invalid-jwt-token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
Reference in New Issue
Block a user