Initial commit: WealthWise financial analytics platform
This commit is contained in:
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package for WealthWise Backend
|
||||
246
backend/tests/test_auth.py
Normal file
246
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Tests for authentication layer.
|
||||
|
||||
This module tests JWT validation and authentication dependencies.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from jose import jwt
|
||||
|
||||
from app.api.deps import get_current_user, validate_supabase_token
|
||||
from app.core.config import get_settings
|
||||
from app.main import app
|
||||
from app.schemas.token import TokenPayload, UserContext
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TestJWTValidation:
|
||||
"""Test cases for JWT token validation."""
|
||||
|
||||
def create_test_token(
|
||||
self,
|
||||
secret: str = None,
|
||||
expired: bool = False,
|
||||
wrong_audience: bool = False,
|
||||
missing_claims: bool = False,
|
||||
algorithm: str = "HS256",
|
||||
) -> str:
|
||||
"""Create a test JWT token with specified properties.
|
||||
|
||||
Args:
|
||||
secret: JWT signing secret (defaults to settings.SUPABASE_JWT_SECRET)
|
||||
expired: Whether the token should be expired
|
||||
wrong_audience: Whether to use wrong audience
|
||||
missing_claims: Whether to omit required claims
|
||||
algorithm: Signing algorithm
|
||||
|
||||
Returns:
|
||||
Encoded JWT string
|
||||
"""
|
||||
secret = secret or settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY
|
||||
|
||||
# Base payload
|
||||
payload = {
|
||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "test@wealthwise.app",
|
||||
"aud": "wrong-audience" if wrong_audience else "authenticated",
|
||||
"role": "authenticated",
|
||||
"app_metadata": {},
|
||||
"user_metadata": {},
|
||||
}
|
||||
|
||||
if not missing_claims:
|
||||
# Set expiration
|
||||
if expired:
|
||||
payload["exp"] = int(time.time()) - 3600 # 1 hour ago
|
||||
else:
|
||||
payload["exp"] = int(time.time()) + 3600 # 1 hour from now
|
||||
|
||||
return jwt.encode(payload, secret, algorithm=algorithm)
|
||||
|
||||
def test_validate_valid_token(self):
|
||||
"""Test validation of a valid JWT token."""
|
||||
token = self.create_test_token()
|
||||
|
||||
payload = validate_supabase_token(token)
|
||||
|
||||
assert isinstance(payload, TokenPayload)
|
||||
assert payload.sub == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert payload.email == "test@wealthwise.app"
|
||||
assert payload.aud == "authenticated"
|
||||
|
||||
def test_validate_expired_token(self):
|
||||
"""Test that expired tokens are rejected."""
|
||||
token = self.create_test_token(expired=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Could not validate credentials" in exc_info.value.detail
|
||||
|
||||
def test_validate_wrong_audience(self):
|
||||
"""Test that tokens with wrong audience are rejected."""
|
||||
token = self.create_test_token(wrong_audience=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_validate_invalid_signature(self):
|
||||
"""Test that tokens with invalid signature are rejected."""
|
||||
token = self.create_test_token(secret="wrong-secret")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_validate_missing_exp(self):
|
||||
"""Test that tokens without expiration are rejected."""
|
||||
token = self.create_test_token(missing_claims=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestGetCurrentUser:
|
||||
"""Test cases for get_current_user dependency."""
|
||||
|
||||
def test_get_current_user_valid_token(self):
|
||||
"""Test that valid token returns UserContext."""
|
||||
# Create a valid token
|
||||
payload = {
|
||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "user@wealthwise.app",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
"role": "authenticated",
|
||||
"app_metadata": {},
|
||||
"user_metadata": {},
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
# Since get_current_user is async, we need to run it in an event loop
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
return await get_current_user(token)
|
||||
|
||||
user = asyncio.run(test())
|
||||
|
||||
assert isinstance(user, UserContext)
|
||||
assert user.id == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert user.email == "user@wealthwise.app"
|
||||
assert user.role == "authenticated"
|
||||
|
||||
def test_get_current_user_no_token(self):
|
||||
"""Test that missing token raises 401."""
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
return await get_current_user(None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(test())
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_get_current_user_invalid_token(self):
|
||||
"""Test that invalid token raises 401."""
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
return await get_current_user("invalid-token")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(test())
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestUserContext:
|
||||
"""Test cases for UserContext model."""
|
||||
|
||||
def test_from_token_payload(self):
|
||||
"""Test conversion from TokenPayload to UserContext."""
|
||||
payload = TokenPayload(
|
||||
sub="550e8400-e29b-41d4-a716-446655440000",
|
||||
email="test@wealthwise.app",
|
||||
aud="authenticated",
|
||||
role="authenticated",
|
||||
app_metadata={"role": "admin"},
|
||||
)
|
||||
|
||||
context = UserContext.from_token_payload(payload)
|
||||
|
||||
assert context.id == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert context.email == "test@wealthwise.app"
|
||||
assert context.role == "admin" # From app_metadata
|
||||
|
||||
def test_from_token_payload_no_app_metadata_role(self):
|
||||
"""Test fallback to role claim when app_metadata has no role."""
|
||||
payload = TokenPayload(
|
||||
sub="550e8400-e29b-41d4-a716-446655440000",
|
||||
email="test@wealthwise.app",
|
||||
aud="authenticated",
|
||||
role="authenticated",
|
||||
app_metadata={},
|
||||
)
|
||||
|
||||
context = UserContext.from_token_payload(payload)
|
||||
|
||||
assert context.role == "authenticated" # From role claim
|
||||
|
||||
|
||||
class TestProtectedEndpoints:
|
||||
"""Integration tests for protected endpoints."""
|
||||
|
||||
def test_me_endpoint_without_auth(self):
|
||||
"""Test that /me endpoint requires authentication."""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/users/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_me_endpoint_with_valid_auth(self):
|
||||
"""Test that /me endpoint works with valid token."""
|
||||
# Create valid token
|
||||
payload = {
|
||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "user@wealthwise.app",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
"role": "authenticated",
|
||||
"app_metadata": {},
|
||||
"user_metadata": {},
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert data["email"] == "user@wealthwise.app"
|
||||
193
backend/tests/test_auth_new.py
Normal file
193
backend/tests/test_auth_new.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Tests for self-hosted authentication system.
|
||||
|
||||
This module tests the JWT-based authentication with OAuth2 Password Flow.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.security import create_access_token, get_password_hash, verify_password
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from app.schemas.user import UserCreate
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
class TestSecurityUtilities:
|
||||
"""Test cases for security utilities."""
|
||||
|
||||
def test_password_hashing(self):
|
||||
"""Test password hashing and verification."""
|
||||
password = "testpassword123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
# Hash should be different from plain password
|
||||
assert hashed != password
|
||||
# Verification should succeed
|
||||
assert verify_password(password, hashed) is True
|
||||
# Wrong password should fail
|
||||
assert verify_password("wrongpassword", hashed) is False
|
||||
|
||||
def test_create_access_token(self):
|
||||
"""Test JWT token creation."""
|
||||
data = {"sub": "123e4567-e89b-12d3-a456-426614174000"}
|
||||
token = create_access_token(data)
|
||||
|
||||
assert token is not None
|
||||
assert isinstance(token, str)
|
||||
# JWT tokens have 3 parts separated by dots
|
||||
assert len(token.split(".")) == 3
|
||||
|
||||
|
||||
class TestAuthEndpoints:
|
||||
"""Test cases for authentication endpoints."""
|
||||
|
||||
def test_register_success(self):
|
||||
"""Test successful user registration."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "test@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "test@wealthwise.app"
|
||||
assert "id" in data
|
||||
assert "hashed_password" not in data
|
||||
assert data["is_active"] is True
|
||||
|
||||
def test_register_duplicate_email(self):
|
||||
"""Test registration with duplicate email."""
|
||||
# First registration
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "duplicate@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Second registration with same email
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "duplicate@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "email already registered" in response.json()["detail"].lower()
|
||||
|
||||
def test_login_success(self):
|
||||
"""Test successful login."""
|
||||
# Register first
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "login@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Login
|
||||
response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "login@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_login_invalid_credentials(self):
|
||||
"""Test login with invalid credentials."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "nonexistent@wealthwise.app",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_wrong_password(self):
|
||||
"""Test login with wrong password."""
|
||||
# Register first
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "wrongpass@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Login with wrong password
|
||||
response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "wrongpass@wealthwise.app",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestProtectedEndpoints:
|
||||
"""Test cases for protected endpoints."""
|
||||
|
||||
def test_me_without_auth(self):
|
||||
"""Test accessing /me without authentication."""
|
||||
response = client.get("/api/v1/users/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_me_with_valid_token(self):
|
||||
"""Test accessing /me with valid token."""
|
||||
# Register and login
|
||||
register_response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "protected@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "protected@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
# Access protected endpoint
|
||||
response = client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "protected@wealthwise.app"
|
||||
|
||||
def test_me_with_invalid_token(self):
|
||||
"""Test accessing /me with invalid token."""
|
||||
response = client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
55
backend/tests/test_config.py
Normal file
55
backend/tests/test_config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for core configuration module."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Test cases for Settings configuration."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test that default values are set correctly."""
|
||||
settings = Settings()
|
||||
|
||||
assert settings.PROJECT_NAME == "WealthWise"
|
||||
assert settings.API_V1_STR == "/api/v1"
|
||||
assert settings.VERSION == "1.0.0"
|
||||
assert settings.DB_POOL_SIZE == 20
|
||||
assert settings.DB_MAX_OVERFLOW == 10
|
||||
assert settings.DEBUG is False
|
||||
|
||||
def test_database_url_validation(self):
|
||||
"""Test that database URL is validated and converted to asyncpg."""
|
||||
# Test conversion from postgresql:// to postgresql+asyncpg://
|
||||
settings = Settings(DATABASE_URL="postgresql://user:pass@localhost:5432/db")
|
||||
assert settings.DATABASE_URL.startswith("postgresql+asyncpg://")
|
||||
|
||||
# Test that asyncpg URL is preserved
|
||||
asyncpg_url = "postgresql+asyncpg://user:pass@localhost:6543/postgres"
|
||||
settings2 = Settings(DATABASE_URL=asyncpg_url)
|
||||
assert settings2.DATABASE_URL == asyncpg_url
|
||||
|
||||
def test_cors_origins_parsing(self):
|
||||
"""Test CORS origins parsing from string."""
|
||||
settings = Settings(CORS_ORIGINS="http://localhost:5173,https://example.com")
|
||||
assert "http://localhost:5173" in settings.CORS_ORIGINS
|
||||
assert "https://example.com" in settings.CORS_ORIGINS
|
||||
|
||||
# Test list input
|
||||
settings2 = Settings(CORS_ORIGINS=["http://localhost:3000"])
|
||||
assert settings2.CORS_ORIGINS == ["http://localhost:3000"]
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""Test cases for get_settings function."""
|
||||
|
||||
def test_get_settings_returns_singleton(self):
|
||||
"""Test that get_settings returns a cached singleton."""
|
||||
settings1 = get_settings()
|
||||
settings2 = get_settings()
|
||||
|
||||
# Should be the same object (cached)
|
||||
assert settings1 is settings2
|
||||
40
backend/tests/test_health.py
Normal file
40
backend/tests/test_health.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Tests for health check endpoint."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
"""Test cases for health check endpoint."""
|
||||
|
||||
def test_health_endpoint_exists(self):
|
||||
"""Test that health endpoint is accessible."""
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code in [200, 503] # 200 if DB connected, 503 if not
|
||||
|
||||
def test_health_response_structure(self):
|
||||
"""Test that health response has expected structure."""
|
||||
response = client.get("/api/v1/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "database" in data
|
||||
assert "version" in data
|
||||
|
||||
def test_readiness_probe(self):
|
||||
"""Test readiness probe endpoint."""
|
||||
response = client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ready": True}
|
||||
|
||||
def test_liveness_probe(self):
|
||||
"""Test liveness probe endpoint."""
|
||||
response = client.get("/api/v1/health/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"alive": True}
|
||||
Reference in New Issue
Block a user