Phase 4: Authentication System (T039-T048)
Implemented complete JWT-based authentication system with RBAC: **Tests (TDD Approach):** - Created contract tests for /api/v1/auth/login endpoint - Created contract tests for /api/v1/auth/logout endpoint - Created unit tests for AuthService (login, logout, validate_token, password hashing) - Created pytest configuration and fixtures (test DB, test users, tokens) **Schemas:** - LoginRequest: username/password validation - TokenResponse: access_token, refresh_token, user info - LogoutResponse: logout confirmation - RefreshTokenRequest: token refresh payload - UserInfo: user data (excludes password_hash) **Services:** - AuthService: login(), logout(), validate_token(), hash_password(), verify_password() - Integrated bcrypt password hashing - JWT token generation (access + refresh tokens) - Token blacklisting in Redis - Audit logging for all auth operations **Middleware:** - Authentication middleware with JWT validation - Role-based access control (RBAC) helpers - require_role() dependency factory - Convenience dependencies: require_viewer(), require_operator(), require_administrator() - Client IP and User-Agent extraction **Router:** - POST /api/v1/auth/login - Authenticate and get tokens - POST /api/v1/auth/logout - Blacklist token - POST /api/v1/auth/refresh - Refresh access token - GET /api/v1/auth/me - Get current user info **Integration:** - Registered auth router in main.py - Updated startup event to initialize Redis and SDK Bridge clients - Updated shutdown event to cleanup connections properly - Fixed error translation utilities - Added asyncpg dependency for PostgreSQL async driver 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
172
src/api/tests/test_auth_api.py
Normal file
172
src/api/tests/test_auth_api.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Contract tests for authentication API endpoints
|
||||
These tests define the expected behavior - they will FAIL until implementation is complete
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from fastapi import status
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAuthLogin:
|
||||
"""Contract tests for POST /api/v1/auth/login"""
|
||||
|
||||
async def test_login_success(self, async_client: AsyncClient):
|
||||
"""Test successful login with valid credentials"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert "token_type" in data
|
||||
assert "expires_in" in data
|
||||
assert "user" in data
|
||||
|
||||
# Verify token type
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
# Verify user info
|
||||
assert data["user"]["username"] == "admin"
|
||||
assert data["user"]["role"] == "administrator"
|
||||
assert "password_hash" not in data["user"] # Never expose password hash
|
||||
|
||||
async def test_login_invalid_username(self, async_client: AsyncClient):
|
||||
"""Test login with non-existent username"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"username": "nonexistent",
|
||||
"password": "somepassword"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert data["error"] == "Unauthorized"
|
||||
|
||||
async def test_login_invalid_password(self, async_client: AsyncClient):
|
||||
"""Test login with incorrect password"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"username": "admin",
|
||||
"password": "wrongpassword"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
|
||||
async def test_login_missing_username(self, async_client: AsyncClient):
|
||||
"""Test login with missing username field"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"password": "admin123"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_login_missing_password(self, async_client: AsyncClient):
|
||||
"""Test login with missing password field"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"username": "admin"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_login_empty_username(self, async_client: AsyncClient):
|
||||
"""Test login with empty username"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"username": "",
|
||||
"password": "admin123"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_login_empty_password(self, async_client: AsyncClient):
|
||||
"""Test login with empty password"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"username": "admin",
|
||||
"password": ""
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAuthLogout:
|
||||
"""Contract tests for POST /api/v1/auth/logout"""
|
||||
|
||||
async def test_logout_success(self, async_client: AsyncClient, auth_token: str):
|
||||
"""Test successful logout with valid token"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["message"] == "Successfully logged out"
|
||||
|
||||
async def test_logout_no_token(self, async_client: AsyncClient):
|
||||
"""Test logout without authentication token"""
|
||||
response = await async_client.post("/api/v1/auth/logout")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
async def test_logout_invalid_token(self, async_client: AsyncClient):
|
||||
"""Test logout with invalid token"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Authorization": "Bearer invalid_token_here"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
async def test_logout_expired_token(self, async_client: AsyncClient, expired_token: str):
|
||||
"""Test logout with expired token"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Authorization": f"Bearer {expired_token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAuthProtectedEndpoint:
|
||||
"""Test authentication middleware on protected endpoints"""
|
||||
|
||||
async def test_protected_endpoint_with_valid_token(self, async_client: AsyncClient, auth_token: str):
|
||||
"""Test accessing protected endpoint with valid token"""
|
||||
# This will be used to test any protected endpoint once we have them
|
||||
# For now, we'll test with a mock protected endpoint
|
||||
pass
|
||||
|
||||
async def test_protected_endpoint_without_token(self, async_client: AsyncClient):
|
||||
"""Test accessing protected endpoint without token"""
|
||||
# Will be implemented when we have actual protected endpoints
|
||||
pass
|
||||
Reference in New Issue
Block a user