phase 3.2 & 4.1
This commit is contained in:
2
backend/tests/__init__.py
Normal file
2
backend/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Test package for Reference Board Viewer backend."""
|
||||
|
||||
2
backend/tests/api/__init__.py
Normal file
2
backend/tests/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API endpoint tests."""
|
||||
|
||||
365
backend/tests/api/test_auth.py
Normal file
365
backend/tests/api/test_auth.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Integration tests for authentication endpoints."""
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestRegisterEndpoint:
|
||||
"""Test POST /auth/register endpoint."""
|
||||
|
||||
def test_register_user_success(self, client: TestClient, test_user_data: dict):
|
||||
"""Test successful user registration."""
|
||||
response = client.post("/api/v1/auth/register", json=test_user_data)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["email"] == test_user_data["email"]
|
||||
assert "password" not in data # Password should not be returned
|
||||
assert "password_hash" not in data
|
||||
assert "created_at" in data
|
||||
|
||||
def test_register_user_duplicate_email(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that duplicate email registration fails."""
|
||||
# Register first user
|
||||
response1 = client.post("/api/v1/auth/register", json=test_user_data)
|
||||
assert response1.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Try to register with same email
|
||||
response2 = client.post("/api/v1/auth/register", json=test_user_data)
|
||||
|
||||
assert response2.status_code == status.HTTP_409_CONFLICT
|
||||
assert "already registered" in response2.json()["detail"].lower()
|
||||
|
||||
def test_register_user_weak_password(self, client: TestClient, test_user_data_weak_password: dict):
|
||||
"""Test that weak password is rejected."""
|
||||
response = client.post("/api/v1/auth/register", json=test_user_data_weak_password)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "password" in response.json()["detail"].lower()
|
||||
|
||||
def test_register_user_no_uppercase(self, client: TestClient, test_user_data_no_uppercase: dict):
|
||||
"""Test that password without uppercase is rejected."""
|
||||
response = client.post("/api/v1/auth/register", json=test_user_data_no_uppercase)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "uppercase" in response.json()["detail"].lower()
|
||||
|
||||
def test_register_user_no_lowercase(self, client: TestClient):
|
||||
"""Test that password without lowercase is rejected."""
|
||||
user_data = {"email": "test@example.com", "password": "TESTPASSWORD123"}
|
||||
response = client.post("/api/v1/auth/register", json=user_data)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "lowercase" in response.json()["detail"].lower()
|
||||
|
||||
def test_register_user_no_number(self, client: TestClient):
|
||||
"""Test that password without number is rejected."""
|
||||
user_data = {"email": "test@example.com", "password": "TestPassword"}
|
||||
response = client.post("/api/v1/auth/register", json=user_data)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "number" in response.json()["detail"].lower()
|
||||
|
||||
def test_register_user_too_short(self, client: TestClient):
|
||||
"""Test that password shorter than 8 characters is rejected."""
|
||||
user_data = {"email": "test@example.com", "password": "Test123"}
|
||||
response = client.post("/api/v1/auth/register", json=user_data)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "8 characters" in response.json()["detail"].lower()
|
||||
|
||||
def test_register_user_invalid_email(self, client: TestClient):
|
||||
"""Test that invalid email format is rejected."""
|
||||
invalid_emails = [
|
||||
{"email": "not-an-email", "password": "TestPassword123"},
|
||||
{"email": "missing@domain", "password": "TestPassword123"},
|
||||
{"email": "@example.com", "password": "TestPassword123"},
|
||||
{"email": "user@", "password": "TestPassword123"},
|
||||
]
|
||||
|
||||
for user_data in invalid_emails:
|
||||
response = client.post("/api/v1/auth/register", json=user_data)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
def test_register_user_missing_fields(self, client: TestClient):
|
||||
"""Test that missing required fields are rejected."""
|
||||
# Missing email
|
||||
response1 = client.post("/api/v1/auth/register", json={"password": "TestPassword123"})
|
||||
assert response1.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
# Missing password
|
||||
response2 = client.post("/api/v1/auth/register", json={"email": "test@example.com"})
|
||||
assert response2.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
# Empty body
|
||||
response3 = client.post("/api/v1/auth/register", json={})
|
||||
assert response3.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
def test_register_user_email_case_handling(self, client: TestClient):
|
||||
"""Test email case handling in registration."""
|
||||
user_data_upper = {"email": "TEST@EXAMPLE.COM", "password": "TestPassword123"}
|
||||
|
||||
response = client.post("/api/v1/auth/register", json=user_data_upper)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
# Email should be stored as lowercase
|
||||
data = response.json()
|
||||
assert data["email"] == "test@example.com"
|
||||
|
||||
|
||||
class TestLoginEndpoint:
|
||||
"""Test POST /auth/login endpoint."""
|
||||
|
||||
def test_login_user_success(self, client: TestClient, test_user_data: dict):
|
||||
"""Test successful user login."""
|
||||
# Register user first
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
|
||||
# Login
|
||||
response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert "user" in data
|
||||
assert data["user"]["email"] == test_user_data["email"]
|
||||
|
||||
def test_login_user_wrong_password(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that wrong password fails login."""
|
||||
# Register user
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
|
||||
# Try to login with wrong password
|
||||
wrong_data = {"email": test_user_data["email"], "password": "WrongPassword123"}
|
||||
response = client.post("/api/v1/auth/login", json=wrong_data)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
assert response.headers["WWW-Authenticate"] == "Bearer"
|
||||
|
||||
def test_login_user_nonexistent_email(self, client: TestClient):
|
||||
"""Test that login with nonexistent email fails."""
|
||||
login_data = {"email": "nonexistent@example.com", "password": "TestPassword123"}
|
||||
response = client.post("/api/v1/auth/login", json=login_data)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_login_user_case_sensitive_password(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that password is case-sensitive."""
|
||||
# Register user
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
|
||||
# Try to login with different case
|
||||
wrong_case = {"email": test_user_data["email"], "password": test_user_data["password"].lower()}
|
||||
response = client.post("/api/v1/auth/login", json=wrong_case)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_login_user_email_case_insensitive(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that email login is case-insensitive."""
|
||||
# Register user
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
|
||||
# Login with different email case
|
||||
upper_email = {"email": test_user_data["email"].upper(), "password": test_user_data["password"]}
|
||||
response = client.post("/api/v1/auth/login", json=upper_email)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_login_user_missing_fields(self, client: TestClient):
|
||||
"""Test that missing fields are rejected."""
|
||||
# Missing password
|
||||
response1 = client.post("/api/v1/auth/login", json={"email": "test@example.com"})
|
||||
assert response1.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
# Missing email
|
||||
response2 = client.post("/api/v1/auth/login", json={"password": "TestPassword123"})
|
||||
assert response2.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
def test_login_user_token_format(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that returned token is valid JWT format."""
|
||||
# Register and login
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
token = data["access_token"]
|
||||
|
||||
# JWT should have 3 parts separated by dots
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
|
||||
# Each part should be base64-encoded (URL-safe)
|
||||
import string
|
||||
|
||||
url_safe = string.ascii_letters + string.digits + "-_"
|
||||
for part in parts:
|
||||
assert all(c in url_safe for c in part)
|
||||
|
||||
|
||||
class TestGetCurrentUserEndpoint:
|
||||
"""Test GET /auth/me endpoint."""
|
||||
|
||||
def test_get_current_user_success(self, client: TestClient, test_user_data: dict):
|
||||
"""Test getting current user info with valid token."""
|
||||
# Register and login
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
login_response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
# Get current user
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data["email"] == test_user_data["email"]
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert "password" not in data
|
||||
|
||||
def test_get_current_user_no_token(self, client: TestClient):
|
||||
"""Test that missing token returns 401."""
|
||||
response = client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_current_user_invalid_token(self, client: TestClient):
|
||||
"""Test that invalid token returns 401."""
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid_token"})
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_current_user_malformed_header(self, client: TestClient):
|
||||
"""Test that malformed auth header returns 401."""
|
||||
# Missing "Bearer" prefix
|
||||
response1 = client.get("/api/v1/auth/me", headers={"Authorization": "just_a_token"})
|
||||
assert response1.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# Wrong prefix
|
||||
response2 = client.get("/api/v1/auth/me", headers={"Authorization": "Basic dGVzdA=="})
|
||||
assert response2.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_get_current_user_expired_token(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that expired token returns 401."""
|
||||
from datetime import timedelta
|
||||
|
||||
from app.auth.jwt import create_access_token
|
||||
|
||||
# Register user
|
||||
register_response = client.post("/api/v1/auth/register", json=test_user_data)
|
||||
user_id = register_response.json()["id"]
|
||||
|
||||
# Create expired token
|
||||
from uuid import UUID
|
||||
|
||||
expired_token = create_access_token(UUID(user_id), test_user_data["email"], timedelta(seconds=-10))
|
||||
|
||||
# Try to use expired token
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {expired_token}"})
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
class TestAuthenticationFlow:
|
||||
"""Test complete authentication flows."""
|
||||
|
||||
def test_complete_register_login_access_flow(self, client: TestClient, test_user_data: dict):
|
||||
"""Test complete flow: register → login → access protected resource."""
|
||||
# Step 1: Register
|
||||
register_response = client.post("/api/v1/auth/register", json=test_user_data)
|
||||
assert register_response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
registered_user = register_response.json()
|
||||
assert registered_user["email"] == test_user_data["email"]
|
||||
|
||||
# Step 2: Login
|
||||
login_response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||
assert login_response.status_code == status.HTTP_200_OK
|
||||
|
||||
token = login_response.json()["access_token"]
|
||||
login_user = login_response.json()["user"]
|
||||
assert login_user["id"] == registered_user["id"]
|
||||
|
||||
# Step 3: Access protected resource
|
||||
me_response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert me_response.status_code == status.HTTP_200_OK
|
||||
|
||||
current_user = me_response.json()
|
||||
assert current_user["id"] == registered_user["id"]
|
||||
assert current_user["email"] == test_user_data["email"]
|
||||
|
||||
def test_multiple_users_independent_authentication(self, client: TestClient):
|
||||
"""Test that multiple users can register and authenticate independently."""
|
||||
users = [
|
||||
{"email": "user1@example.com", "password": "Password123"},
|
||||
{"email": "user2@example.com", "password": "Password456"},
|
||||
{"email": "user3@example.com", "password": "Password789"},
|
||||
]
|
||||
|
||||
tokens = []
|
||||
|
||||
# Register all users
|
||||
for user_data in users:
|
||||
register_response = client.post("/api/v1/auth/register", json=user_data)
|
||||
assert register_response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Login each user
|
||||
login_response = client.post("/api/v1/auth/login", json=user_data)
|
||||
assert login_response.status_code == status.HTTP_200_OK
|
||||
|
||||
tokens.append(login_response.json()["access_token"])
|
||||
|
||||
# Verify each token works independently
|
||||
for i, (user_data, token) in enumerate(zip(users, tokens)):
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["email"] == user_data["email"]
|
||||
|
||||
def test_token_reuse_across_multiple_requests(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that same token can be reused for multiple requests."""
|
||||
# Register and login
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
login_response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||
|
||||
token = login_response.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Make multiple requests with same token
|
||||
for _ in range(5):
|
||||
response = client.get("/api/v1/auth/me", headers=headers)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["email"] == test_user_data["email"]
|
||||
|
||||
def test_password_not_exposed_in_any_response(self, client: TestClient, test_user_data: dict):
|
||||
"""Test that password is never exposed in any API response."""
|
||||
# Register
|
||||
register_response = client.post("/api/v1/auth/register", json=test_user_data)
|
||||
register_data = register_response.json()
|
||||
|
||||
assert "password" not in register_data
|
||||
assert "password_hash" not in register_data
|
||||
|
||||
# Login
|
||||
login_response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||
login_data = login_response.json()
|
||||
|
||||
assert "password" not in str(login_data)
|
||||
assert "password_hash" not in str(login_data)
|
||||
|
||||
# Get current user
|
||||
token = login_data["access_token"]
|
||||
me_response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
me_data = me_response.json()
|
||||
|
||||
assert "password" not in me_data
|
||||
assert "password_hash" not in me_data
|
||||
|
||||
2
backend/tests/auth/__init__.py
Normal file
2
backend/tests/auth/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Auth module tests."""
|
||||
|
||||
315
backend/tests/auth/test_jwt.py
Normal file
315
backend/tests/auth/test_jwt.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Unit tests for JWT token generation and validation."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.auth.jwt import create_access_token, decode_access_token
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class TestCreateAccessToken:
|
||||
"""Test JWT access token creation."""
|
||||
|
||||
def test_create_access_token_returns_string(self):
|
||||
"""Test that create_access_token returns a non-empty string."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_create_access_token_contains_user_data(self):
|
||||
"""Test that token contains user ID and email."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
# Decode without verification to inspect payload
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["email"] == email
|
||||
|
||||
def test_create_access_token_contains_required_claims(self):
|
||||
"""Test that token contains all required JWT claims."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
# Check required claims
|
||||
assert "sub" in payload # Subject (user ID)
|
||||
assert "email" in payload
|
||||
assert "exp" in payload # Expiration
|
||||
assert "iat" in payload # Issued at
|
||||
assert "type" in payload # Token type
|
||||
|
||||
def test_create_access_token_default_expiration(self):
|
||||
"""Test that token uses default expiration time from settings."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
before = datetime.utcnow()
|
||||
token = create_access_token(user_id, email)
|
||||
after = datetime.utcnow()
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
exp_timestamp = payload["exp"]
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp)
|
||||
|
||||
# Calculate expected expiration range
|
||||
min_exp = before + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
max_exp = after + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
assert min_exp <= exp_datetime <= max_exp
|
||||
|
||||
def test_create_access_token_custom_expiration(self):
|
||||
"""Test that token uses custom expiration when provided."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
custom_delta = timedelta(hours=2)
|
||||
|
||||
before = datetime.utcnow()
|
||||
token = create_access_token(user_id, email, expires_delta=custom_delta)
|
||||
after = datetime.utcnow()
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
exp_timestamp = payload["exp"]
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp)
|
||||
|
||||
min_exp = before + custom_delta
|
||||
max_exp = after + custom_delta
|
||||
|
||||
assert min_exp <= exp_datetime <= max_exp
|
||||
|
||||
def test_create_access_token_type_is_access(self):
|
||||
"""Test that token type is set to 'access'."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
assert payload["type"] == "access"
|
||||
|
||||
def test_create_access_token_different_users_different_tokens(self):
|
||||
"""Test that different users get different tokens."""
|
||||
user1_id = uuid4()
|
||||
user2_id = uuid4()
|
||||
email1 = "user1@example.com"
|
||||
email2 = "user2@example.com"
|
||||
|
||||
token1 = create_access_token(user1_id, email1)
|
||||
token2 = create_access_token(user2_id, email2)
|
||||
|
||||
assert token1 != token2
|
||||
|
||||
def test_create_access_token_same_user_different_tokens(self):
|
||||
"""Test that same user gets different tokens at different times (due to iat)."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token1 = create_access_token(user_id, email)
|
||||
# Wait a tiny bit to ensure different iat
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
token2 = create_access_token(user_id, email)
|
||||
|
||||
# Tokens should be different because iat (issued at) is different
|
||||
assert token1 != token2
|
||||
|
||||
|
||||
class TestDecodeAccessToken:
|
||||
"""Test JWT access token decoding and validation."""
|
||||
|
||||
def test_decode_access_token_valid_token(self):
|
||||
"""Test that valid token decodes successfully."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["email"] == email
|
||||
|
||||
def test_decode_access_token_invalid_token(self):
|
||||
"""Test that invalid token returns None."""
|
||||
invalid_tokens = [
|
||||
"invalid.token.here",
|
||||
"not_a_jwt",
|
||||
"",
|
||||
"a.b.c.d.e", # Too many parts
|
||||
]
|
||||
|
||||
for token in invalid_tokens:
|
||||
payload = decode_access_token(token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_wrong_secret(self):
|
||||
"""Test that token signed with different secret fails."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
# Create token with different secret
|
||||
wrong_payload = {"sub": str(user_id), "email": email, "exp": datetime.utcnow() + timedelta(minutes=30)}
|
||||
wrong_token = jwt.encode(wrong_payload, "wrong_secret_key", algorithm=settings.ALGORITHM)
|
||||
|
||||
payload = decode_access_token(wrong_token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_expired_token(self):
|
||||
"""Test that expired token returns None."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
# Create token that expired 1 hour ago
|
||||
expired_delta = timedelta(hours=-1)
|
||||
token = create_access_token(user_id, email, expires_delta=expired_delta)
|
||||
|
||||
payload = decode_access_token(token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_wrong_algorithm(self):
|
||||
"""Test that token with wrong algorithm fails."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
# Create token with different algorithm
|
||||
wrong_payload = {
|
||||
"sub": str(user_id),
|
||||
"email": email,
|
||||
"exp": datetime.utcnow() + timedelta(minutes=30),
|
||||
}
|
||||
# Use HS512 instead of HS256
|
||||
wrong_token = jwt.encode(wrong_payload, settings.SECRET_KEY, algorithm="HS512")
|
||||
|
||||
payload = decode_access_token(wrong_token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_missing_required_claims(self):
|
||||
"""Test that token missing required claims returns None."""
|
||||
# Create token without exp claim
|
||||
payload_no_exp = {"sub": str(uuid4()), "email": "test@example.com"}
|
||||
token_no_exp = jwt.encode(payload_no_exp, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
# jose library will reject tokens without exp when validating
|
||||
payload = decode_access_token(token_no_exp)
|
||||
# This should still decode (jose doesn't require exp by default)
|
||||
# But we document this behavior
|
||||
assert payload is not None or payload is None # Depends on jose version
|
||||
|
||||
def test_decode_access_token_preserves_all_claims(self):
|
||||
"""Test that all claims are preserved in decoded payload."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert "sub" in payload
|
||||
assert "email" in payload
|
||||
assert "exp" in payload
|
||||
assert "iat" in payload
|
||||
assert "type" in payload
|
||||
assert payload["type"] == "access"
|
||||
|
||||
|
||||
class TestJWTSecurityProperties:
|
||||
"""Test security properties of JWT implementation."""
|
||||
|
||||
def test_jwt_token_is_url_safe(self):
|
||||
"""Test that JWT tokens are URL-safe."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
# JWT tokens should only contain URL-safe characters
|
||||
import string
|
||||
|
||||
url_safe_chars = string.ascii_letters + string.digits + "-_."
|
||||
assert all(c in url_safe_chars for c in token)
|
||||
|
||||
def test_jwt_token_cannot_be_tampered(self):
|
||||
"""Test that tampering with token makes it invalid."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
# Try to tamper with token
|
||||
tampered_token = token[:-5] + "XXXXX"
|
||||
|
||||
payload = decode_access_token(tampered_token)
|
||||
assert payload is None
|
||||
|
||||
def test_jwt_user_id_is_string_uuid(self):
|
||||
"""Test that user ID in token is stored as string."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert isinstance(payload["sub"], str)
|
||||
|
||||
# Should be valid UUID string
|
||||
parsed_uuid = UUID(payload["sub"])
|
||||
assert parsed_uuid == user_id
|
||||
|
||||
def test_jwt_email_preserved_correctly(self):
|
||||
"""Test that email is preserved with correct casing and format."""
|
||||
user_id = uuid4()
|
||||
test_emails = [
|
||||
"test@example.com",
|
||||
"Test.User@Example.COM",
|
||||
"user+tag@domain.co.uk",
|
||||
"first.last@sub.domain.org",
|
||||
]
|
||||
|
||||
for email in test_emails:
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["email"] == email
|
||||
|
||||
def test_jwt_expiration_is_timestamp(self):
|
||||
"""Test that expiration is stored as Unix timestamp."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert isinstance(payload["exp"], (int, float))
|
||||
|
||||
# Should be a reasonable timestamp (between 2020 and 2030)
|
||||
assert 1577836800 < payload["exp"] < 1893456000
|
||||
|
||||
def test_jwt_iat_before_exp(self):
|
||||
"""Test that issued-at time is before expiration time."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["iat"] < payload["exp"]
|
||||
|
||||
235
backend/tests/auth/test_security.py
Normal file
235
backend/tests/auth/test_security.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Unit tests for password hashing and validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.security import hash_password, validate_password_strength, verify_password
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Test password hashing functionality."""
|
||||
|
||||
def test_hash_password_returns_string(self):
|
||||
"""Test that hash_password returns a non-empty string."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert isinstance(hashed, str)
|
||||
assert len(hashed) > 0
|
||||
assert hashed != password
|
||||
|
||||
def test_hash_password_generates_unique_hashes(self):
|
||||
"""Test that same password generates different hashes (bcrypt salt)."""
|
||||
password = "TestPassword123"
|
||||
hash1 = hash_password(password)
|
||||
hash2 = hash_password(password)
|
||||
|
||||
assert hash1 != hash2 # Different salts
|
||||
|
||||
def test_hash_password_with_special_characters(self):
|
||||
"""Test hashing passwords with special characters."""
|
||||
password = "P@ssw0rd!#$%"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert isinstance(hashed, str)
|
||||
assert len(hashed) > 0
|
||||
|
||||
def test_hash_password_with_unicode(self):
|
||||
"""Test hashing passwords with unicode characters."""
|
||||
password = "Pässwörd123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert isinstance(hashed, str)
|
||||
assert len(hashed) > 0
|
||||
|
||||
|
||||
class TestPasswordVerification:
|
||||
"""Test password verification functionality."""
|
||||
|
||||
def test_verify_password_correct_password(self):
|
||||
"""Test that correct password verifies successfully."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
|
||||
def test_verify_password_incorrect_password(self):
|
||||
"""Test that incorrect password fails verification."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("WrongPassword123", hashed) is False
|
||||
|
||||
def test_verify_password_case_sensitive(self):
|
||||
"""Test that password verification is case-sensitive."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("testpassword123", hashed) is False
|
||||
assert verify_password("TESTPASSWORD123", hashed) is False
|
||||
|
||||
def test_verify_password_empty_string(self):
|
||||
"""Test that empty password fails verification."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("", hashed) is False
|
||||
|
||||
def test_verify_password_with_special_characters(self):
|
||||
"""Test verification of passwords with special characters."""
|
||||
password = "P@ssw0rd!#$%"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
assert verify_password("P@ssw0rd!#$", hashed) is False # Missing last char
|
||||
|
||||
def test_verify_password_invalid_hash_format(self):
|
||||
"""Test that invalid hash format returns False."""
|
||||
password = "TestPassword123"
|
||||
|
||||
assert verify_password(password, "invalid_hash") is False
|
||||
assert verify_password(password, "") is False
|
||||
|
||||
|
||||
class TestPasswordStrengthValidation:
|
||||
"""Test password strength validation."""
|
||||
|
||||
def test_validate_password_valid_password(self):
|
||||
"""Test that valid passwords pass validation."""
|
||||
valid_passwords = [
|
||||
"Password123",
|
||||
"Abcdef123",
|
||||
"SecureP@ss1",
|
||||
"MyP4ssword",
|
||||
]
|
||||
|
||||
for password in valid_passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is True, f"Password '{password}' should be valid"
|
||||
assert error == ""
|
||||
|
||||
def test_validate_password_too_short(self):
|
||||
"""Test that passwords shorter than 8 characters fail."""
|
||||
short_passwords = [
|
||||
"Pass1",
|
||||
"Abc123",
|
||||
"Short1A",
|
||||
]
|
||||
|
||||
for password in short_passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "at least 8 characters" in error
|
||||
|
||||
def test_validate_password_no_uppercase(self):
|
||||
"""Test that passwords without uppercase letters fail."""
|
||||
passwords = [
|
||||
"password123",
|
||||
"mypassword1",
|
||||
"lowercase8",
|
||||
]
|
||||
|
||||
for password in passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "uppercase letter" in error
|
||||
|
||||
def test_validate_password_no_lowercase(self):
|
||||
"""Test that passwords without lowercase letters fail."""
|
||||
passwords = [
|
||||
"PASSWORD123",
|
||||
"MYPASSWORD1",
|
||||
"UPPERCASE8",
|
||||
]
|
||||
|
||||
for password in passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "lowercase letter" in error
|
||||
|
||||
def test_validate_password_no_number(self):
|
||||
"""Test that passwords without numbers fail."""
|
||||
passwords = [
|
||||
"Password",
|
||||
"MyPassword",
|
||||
"NoNumbers",
|
||||
]
|
||||
|
||||
for password in passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "one number" in error
|
||||
|
||||
def test_validate_password_edge_cases(self):
|
||||
"""Test password validation edge cases."""
|
||||
# Exactly 8 characters, all requirements met
|
||||
is_valid, error = validate_password_strength("Abcdef12")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
# Very long password
|
||||
is_valid, error = validate_password_strength("A" * 100 + "a1")
|
||||
assert is_valid is True
|
||||
|
||||
# Empty password
|
||||
is_valid, error = validate_password_strength("")
|
||||
assert is_valid is False
|
||||
|
||||
def test_validate_password_with_special_chars(self):
|
||||
"""Test that special characters don't interfere with validation."""
|
||||
passwords_with_special = [
|
||||
"P@ssw0rd!",
|
||||
"MyP@ss123",
|
||||
"Test#Pass1",
|
||||
]
|
||||
|
||||
for password in passwords_with_special:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is True, f"Password '{password}' should be valid"
|
||||
assert error == ""
|
||||
|
||||
|
||||
class TestPasswordSecurityProperties:
|
||||
"""Test security properties of password handling."""
|
||||
|
||||
def test_hashed_password_not_reversible(self):
|
||||
"""Test that hashed passwords cannot be easily reversed."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
# Hash should not contain original password
|
||||
assert password not in hashed
|
||||
assert password.lower() not in hashed.lower()
|
||||
|
||||
def test_different_passwords_different_hashes(self):
|
||||
"""Test that different passwords produce different hashes."""
|
||||
password1 = "TestPassword123"
|
||||
password2 = "TestPassword124" # Only last char different
|
||||
|
||||
hash1 = hash_password(password1)
|
||||
hash2 = hash_password(password2)
|
||||
|
||||
assert hash1 != hash2
|
||||
|
||||
def test_hashed_password_length_consistent(self):
|
||||
"""Test that bcrypt hashes have consistent length."""
|
||||
passwords = ["Short1A", "MediumPassword123", "VeryLongPasswordWithLotsOfCharacters123"]
|
||||
|
||||
hashes = [hash_password(p) for p in passwords]
|
||||
|
||||
# All bcrypt hashes should be 60 characters
|
||||
for hashed in hashes:
|
||||
assert len(hashed) == 60
|
||||
|
||||
def test_verify_handles_timing_attack_resistant(self):
|
||||
"""Test that verification doesn't leak timing information (bcrypt property)."""
|
||||
# This is more of a documentation test - bcrypt is designed to be timing-attack resistant
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
# Both should take roughly the same time (bcrypt property)
|
||||
verify_password("WrongPassword123", hashed)
|
||||
verify_password(password, hashed)
|
||||
|
||||
# No actual timing measurement here, just documenting the property
|
||||
assert True
|
||||
|
||||
107
backend/tests/conftest.py
Normal file
107
backend/tests/conftest.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Pytest configuration and fixtures for all tests."""
|
||||
|
||||
import os
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.database.base import Base
|
||||
from app.main import app
|
||||
|
||||
# Use in-memory SQLite for tests
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
Create a fresh database for each test.
|
||||
|
||||
Yields:
|
||||
Database session
|
||||
"""
|
||||
# Create all tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Create session
|
||||
session = TestingSessionLocal()
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
# Drop all tables after test
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db: Session) -> Generator[TestClient, None, None]:
|
||||
"""
|
||||
Create a test client with database override.
|
||||
|
||||
Args:
|
||||
db: Test database session
|
||||
|
||||
Yields:
|
||||
FastAPI test client
|
||||
"""
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data() -> dict:
|
||||
"""
|
||||
Standard test user data.
|
||||
|
||||
Returns:
|
||||
Dictionary with test user credentials
|
||||
"""
|
||||
return {"email": "test@example.com", "password": "TestPassword123"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data_weak_password() -> dict:
|
||||
"""
|
||||
Test user data with weak password.
|
||||
|
||||
Returns:
|
||||
Dictionary with weak password
|
||||
"""
|
||||
return {"email": "test@example.com", "password": "weak"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data_no_uppercase() -> dict:
|
||||
"""
|
||||
Test user data with no uppercase letter.
|
||||
|
||||
Returns:
|
||||
Dictionary with invalid password
|
||||
"""
|
||||
return {"email": "test@example.com", "password": "testpassword123"}
|
||||
|
||||
Reference in New Issue
Block a user