Updated README.md

This commit is contained in:
2025-10-18 22:42:22 +02:00
parent 79a31ecf40
commit ece8af0678
24 changed files with 2782 additions and 755 deletions

0
tests/api/__init__.py Normal file
View File

View File

@@ -0,0 +1,515 @@
"""
Unit tests for FastAPI authentication routes.
This module tests all authentication API endpoints using FastAPI's TestClient
and mocked dependencies to ensure proper behavior and error handling.
"""
from datetime import datetime
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from my_auth.api.routes import create_auth_router
from my_auth.core.auth import AuthService
from my_auth.exceptions import (
UserAlreadyExistsError,
InvalidCredentialsError,
UserNotFoundError,
InvalidTokenError,
ExpiredTokenError,
RevokedTokenError,
AccountDisabledError
)
from my_auth.models.token import AccessTokenResponse
from my_auth.models.user import UserInDB
@pytest.fixture
def mock_auth_service():
"""
Create a mock AuthService for testing.
Returns:
Mock AuthService instance with all methods mocked.
"""
service = Mock(spec=AuthService)
return service
@pytest.fixture
def test_app(mock_auth_service):
"""
Create a FastAPI test application with auth router.
Args:
mock_auth_service: Mocked authentication service.
Returns:
FastAPI application configured for testing.
"""
app = FastAPI()
auth_router = create_auth_router(mock_auth_service)
app.include_router(auth_router)
return app
@pytest.fixture
def client(test_app):
"""
Create a test client for the FastAPI application.
Args:
test_app: FastAPI test application.
Returns:
TestClient instance.
"""
return TestClient(test_app)
@pytest.fixture
def sample_user():
"""
Create a sample user for testing.
Returns:
UserInDB instance with sample data.
"""
return UserInDB(
id="user123",
email="test@example.com",
username="testuser",
hashed_password="hashed_password_here",
roles=["user"],
user_settings={},
is_verified=False,
is_active=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
@pytest.fixture
def sample_tokens():
"""
Create sample access and refresh tokens.
Returns:
AccessTokenResponse with sample tokens.
"""
return AccessTokenResponse(
access_token="sample.access.token",
refresh_token="sample_refresh_token",
token_type="bearer"
)
def test_i_can_register_user(client, mock_auth_service, sample_user):
"""
Test successful user registration.
Verifies that a POST request to /auth/register with valid data
returns 201 status and the created user information.
"""
mock_auth_service.register.return_value = sample_user
response = client.post("/auth/register", json={
"email": "test@example.com",
"username": "testuser",
"password": "SecurePass123!",
"roles": ["user"],
"user_settings": {}
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
assert data["username"] == "testuser"
assert data["id"] == "user123"
assert "hashed_password" not in data
mock_auth_service.register.assert_called_once()
def test_i_cannot_register_with_existing_email(client, mock_auth_service):
"""
Test registration fails with existing email.
Verifies that attempting to register with an already registered
email returns 409 Conflict status.
"""
mock_auth_service.register.side_effect = UserAlreadyExistsError(
"User with this email already exists"
)
response = client.post("/auth/register", json={
"email": "existing@example.com",
"username": "testuser",
"password": "SecurePass123!",
"roles": [],
"user_settings": {}
})
assert response.status_code == 409
assert "already exists" in response.json()["detail"].lower()
def test_i_can_login(client, mock_auth_service, sample_user, sample_tokens):
"""
Test successful login.
Verifies that a POST request to /auth/login with valid credentials
returns access and refresh tokens.
"""
mock_auth_service.login.return_value = (sample_user, sample_tokens)
response = client.post("/auth/login", data={
"username": "test@example.com", # OAuth2 uses 'username' field
"password": "SecurePass123!"
})
assert response.status_code == 200
data = response.json()
assert data["access_token"] == "sample.access.token"
assert data["refresh_token"] == "sample_refresh_token"
assert data["token_type"] == "bearer"
mock_auth_service.login.assert_called_once_with("test@example.com", "SecurePass123!")
def test_i_cannot_login_with_invalid_credentials(client, mock_auth_service):
"""
Test login fails with invalid credentials.
Verifies that attempting to login with incorrect email or password
returns 401 Unauthorized status.
"""
mock_auth_service.login.side_effect = InvalidCredentialsError()
response = client.post("/auth/login", data={
"username": "test@example.com",
"password": "WrongPassword"
})
assert response.status_code == 401
assert "invalid" in response.json()["detail"].lower()
def test_i_cannot_login_with_disabled_account(client, mock_auth_service):
"""
Test login fails with disabled account.
Verifies that attempting to login to a disabled account
returns 403 Forbidden status.
"""
mock_auth_service.login.side_effect = AccountDisabledError()
response = client.post("/auth/login", data={
"username": "test@example.com",
"password": "SecurePass123!"
})
assert response.status_code == 403
assert "disabled" in response.json()["detail"].lower()
def test_i_can_refresh_token(client, mock_auth_service, sample_tokens):
"""
Test successful token refresh.
Verifies that a POST request to /auth/refresh with a valid refresh token
returns new access and refresh tokens.
"""
new_tokens = AccessTokenResponse(
access_token="new.access.token",
refresh_token="new_refresh_token",
token_type="bearer"
)
mock_auth_service.refresh_access_token.return_value = new_tokens
response = client.post("/auth/refresh", json={
"refresh_token": "sample_refresh_token"
})
assert response.status_code == 200
data = response.json()
assert data["access_token"] == "new.access.token"
assert data["refresh_token"] == "new_refresh_token"
mock_auth_service.refresh_access_token.assert_called_once_with("sample_refresh_token")
def test_i_cannot_refresh_with_invalid_token(client, mock_auth_service):
"""
Test token refresh fails with invalid token.
Verifies that attempting to refresh with an invalid token
returns 401 Unauthorized status.
"""
mock_auth_service.refresh_access_token.side_effect = InvalidTokenError(
"Invalid refresh token"
)
response = client.post("/auth/refresh", json={
"refresh_token": "invalid_token"
})
assert response.status_code == 401
assert "invalid" in response.json()["detail"].lower()
def test_i_cannot_refresh_with_expired_token(client, mock_auth_service):
"""
Test token refresh fails with expired token.
Verifies that attempting to refresh with an expired token
returns 401 Unauthorized status.
"""
mock_auth_service.refresh_access_token.side_effect = ExpiredTokenError()
response = client.post("/auth/refresh", json={
"refresh_token": "expired_token"
})
assert response.status_code == 401
assert "expired" in response.json()["detail"].lower()
def test_i_cannot_refresh_with_revoked_token(client, mock_auth_service):
"""
Test token refresh fails with revoked token.
Verifies that attempting to refresh with a revoked token
returns 401 Unauthorized status.
"""
mock_auth_service.refresh_access_token.side_effect = RevokedTokenError()
response = client.post("/auth/refresh", json={
"refresh_token": "revoked_token"
})
assert response.status_code == 401
assert "revoked" in response.json()["detail"].lower()
def test_i_can_logout(client, mock_auth_service):
"""
Test successful logout.
Verifies that a POST request to /auth/logout successfully
revokes the refresh token and returns 204 status.
"""
mock_auth_service.logout.return_value = True
response = client.post("/auth/logout", json={
"refresh_token": "sample_refresh_token"
})
assert response.status_code == 204
mock_auth_service.logout.assert_called_once_with("sample_refresh_token")
def test_i_can_request_password_reset(client, mock_auth_service):
"""
Test password reset request.
Verifies that a POST request to /auth/password-reset-request
generates a reset token for the given email.
"""
mock_auth_service.request_password_reset.return_value = "reset_token_123"
response = client.post("/auth/password-reset-request", json={
"email": "test@example.com"
})
assert response.status_code == 200
data = response.json()
assert "token" in data
assert data["token"] == "reset_token_123"
mock_auth_service.request_password_reset.assert_called_once_with("test@example.com")
def test_i_cannot_request_password_reset_for_unknown_email(client, mock_auth_service):
"""
Test password reset request fails for unknown email.
Verifies that requesting a password reset for a non-existent email
returns 404 Not Found status.
"""
mock_auth_service.request_password_reset.side_effect = UserNotFoundError(
"No user found with email"
)
response = client.post("/auth/password-reset-request", json={
"email": "unknown@example.com"
})
assert response.status_code == 404
def test_i_can_reset_password(client, mock_auth_service):
"""
Test successful password reset.
Verifies that a POST request to /auth/password-reset with valid
token and new password successfully resets the password.
"""
mock_auth_service.reset_password.return_value = True
response = client.post("/auth/password-reset", json={
"token": "reset_token_123",
"new_password": "NewSecurePass123!"
})
assert response.status_code == 200
data = response.json()
assert "success" in data["message"].lower()
mock_auth_service.reset_password.assert_called_once_with(
"reset_token_123",
"NewSecurePass123!"
)
def test_i_cannot_reset_password_with_invalid_token(client, mock_auth_service):
"""
Test password reset fails with invalid token.
Verifies that attempting to reset password with an invalid token
returns 401 Unauthorized status.
"""
mock_auth_service.reset_password.side_effect = InvalidTokenError(
"Invalid password reset token"
)
response = client.post("/auth/password-reset", json={
"token": "invalid_token",
"new_password": "NewSecurePass123!"
})
assert response.status_code == 401
def test_i_cannot_reset_password_with_expired_token(client, mock_auth_service):
"""
Test password reset fails with expired token.
Verifies that attempting to reset password with an expired token
returns 401 Unauthorized status.
"""
mock_auth_service.reset_password.side_effect = ExpiredTokenError(
"Password reset token has expired"
)
response = client.post("/auth/password-reset", json={
"token": "expired_token",
"new_password": "NewSecurePass123!"
})
assert response.status_code == 401
def test_i_can_request_email_verification(client, mock_auth_service):
"""
Test email verification request.
Verifies that a POST request to /auth/verify-email-request
generates a verification token for the given email.
"""
mock_auth_service.request_email_verification.return_value = "verify_token_jwt"
response = client.post("/auth/verify-email-request", json={
"email": "test@example.com"
})
assert response.status_code == 200
data = response.json()
assert "token" in data
assert data["token"] == "verify_token_jwt"
mock_auth_service.request_email_verification.assert_called_once_with("test@example.com")
def test_i_can_verify_email(client, mock_auth_service):
"""
Test successful email verification.
Verifies that a GET request to /auth/verify-email with a valid
token successfully verifies the email address.
"""
mock_auth_service.verify_email.return_value = True
response = client.get("/auth/verify-email?token=verify_token_jwt")
assert response.status_code == 200
data = response.json()
assert "success" in data["message"].lower()
mock_auth_service.verify_email.assert_called_once_with("verify_token_jwt")
def test_i_cannot_verify_email_with_invalid_token(client, mock_auth_service):
"""
Test email verification fails with invalid token.
Verifies that attempting to verify email with an invalid token
returns 401 Unauthorized status.
"""
mock_auth_service.verify_email.side_effect = InvalidTokenError(
"Invalid email verification token"
)
response = client.get("/auth/verify-email?token=invalid_token")
assert response.status_code == 401
def test_i_can_get_current_user(client, mock_auth_service, sample_user):
"""
Test retrieving current authenticated user.
Verifies that a GET request to /auth/me with a valid Bearer token
returns the current user's information.
"""
mock_auth_service.get_current_user.return_value = sample_user
response = client.get(
"/auth/me",
headers={"Authorization": "Bearer sample.access.token"}
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "test@example.com"
assert data["username"] == "testuser"
assert data["id"] == "user123"
assert "hashed_password" not in data
mock_auth_service.get_current_user.assert_called_once_with("sample.access.token")
def test_i_cannot_access_protected_route_without_token(client):
"""
Test protected route fails without authentication token.
Verifies that attempting to access /auth/me without a Bearer token
returns 401 Unauthorized status.
"""
response = client.get("/auth/me")
assert response.status_code == 401
def test_i_cannot_access_protected_route_with_invalid_token(client, mock_auth_service):
"""
Test protected route fails with invalid token.
Verifies that attempting to access /auth/me with an invalid token
returns 401 Unauthorized status.
"""
mock_auth_service.get_current_user.side_effect = InvalidTokenError(
"Invalid access token"
)
response = client.get(
"/auth/me",
headers={"Authorization": "Bearer invalid.token"}
)
assert response.status_code == 401

View File

@@ -1,6 +1,7 @@
# tests/core/conftest.py
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock
@@ -9,7 +10,7 @@ import pytest
from my_auth.core.password import PasswordManager
from my_auth.core.token import TokenManager
from src.my_auth.core.auth import AuthService
from src.my_auth.models.user import UserCreate
from src.my_auth.models.user import UserCreate, UserInDB
from src.my_auth.persistence.sqlite import SQLiteUserRepository, SQLiteTokenRepository
@@ -33,6 +34,23 @@ def test_user_hashed_password():
return "$2b$12$R.S/XfI2tQYt3Kk.iF1XwOQz0Qe.L0T0mD/O1H8E2V5D4Q6F7G8H9I0"
@pytest.fixture
def test_user_in_db() -> UserInDB:
"""Provides a basic UserInDB instance for testing."""
return UserInDB(
id="1",
email="test@example.com",
username="testuser",
hashed_password="some_hash",
is_active=True,
is_verified=True,
roles=['member'],
user_settings={},
created_at=datetime.now(),
updated_at=datetime.now()
)
@pytest.fixture()
def sqlite_db_path(tmp_path_factory):
"""
@@ -79,6 +97,7 @@ def mock_token_manager() -> TokenManager:
mock = MagicMock(spec=TokenManager)
mock.create_access_token.return_value = "MOCKED_ACCESS_TOKEN"
mock.create_refresh_token.return_value = "MOCKED_REFRESH_TOKEN"
mock.get_refresh_token_expiration.return_value = datetime.now() + timedelta(days=1)
return mock

View File

@@ -6,8 +6,9 @@ from unittest.mock import MagicMock, patch
import pytest
from src.my_auth.core.auth import AuthService
from src.my_auth.exceptions import UserAlreadyExistsError, InvalidCredentialsError, InvalidTokenError, ExpiredTokenError
from src.my_auth.models.token import TokenData
from src.my_auth.exceptions import UserAlreadyExistsError, InvalidCredentialsError, InvalidTokenError, \
ExpiredTokenError, RevokedTokenError
from src.my_auth.models.token import TokenData, TokenPayload
from src.my_auth.models.user import UserCreate, UserUpdate
@@ -140,15 +141,18 @@ class TestAuthServiceTokenManagement(object):
result = auth_service.logout(self.refresh_token)
assert result is True
with pytest.raises(InvalidTokenError):
with pytest.raises(RevokedTokenError):
auth_service.refresh_access_token(self.refresh_token)
def test_get_current_user_success(self, auth_service: AuthService):
"""Success: Getting the current user works by successfully decoding the JWT."""
# Mock the decoder to simulate a decoded payload
token_payload = TokenPayload(sub=self.user.id,
email=str(self.user.email),
exp=int(datetime.now().timestamp() * 1000))
with patch.object(auth_service.token_manager, 'decode_access_token',
return_value={"sub": self.user.id}) as mock_decode:
return_value=token_payload) as mock_decode:
user = auth_service.get_current_user("dummy_jwt")
assert user.id == self.user.id
@@ -168,103 +172,103 @@ class TestAuthServiceTokenManagement(object):
auth_service.get_current_user("expired_access_jwt")
class TestAuthServiceResetVerification(object):
"""Tests for password reset and email verification flows."""
@pytest.fixture(autouse=True)
def setup_user(self, auth_service: AuthService, test_user_data_create: UserCreate):
"""Sets up a registered user using a mock hash for speed."""
pm = auth_service.password_manager
original_hash = pm.hash_password.return_value
# Temporarily set hash for setup
pm.hash_password.return_value = "HASHED_PASS"
user = auth_service.register(test_user_data_create)
self.user = user
# Restore hash mock
pm.hash_password.return_value = original_hash
@patch('src.my_auth.core.email.send_email')
def test_request_password_reset_success(self, mock_send_email: MagicMock, auth_service: AuthService):
"""Success: Requesting a password reset generates a token and sends an email."""
tm = auth_service.token_manager
with patch.object(tm, 'create_password_reset_token',
return_value="MOCKED_RESET_TOKEN") as mock_create_token:
token_string = auth_service.request_password_reset(self.user.email)
assert token_string == "MOCKED_RESET_TOKEN"
mock_create_token.assert_called_once()
mock_send_email.assert_called_once()
def test_reset_password_success(self, auth_service: AuthService):
"""Success: Resetting the password works with a valid token."""
# Setup: Manually create a valid reset token
auth_service.token_repository.save_token(
TokenData(token="valid_reset_token", token_type="password_reset", user_id=self.user.id,
expires_at=datetime.now() + timedelta(minutes=10))
)
# Patch the PasswordManager instance to control the hash output
pm = auth_service.password_manager
with patch.object(pm, 'hash_password',
return_value="NEW_HASHED_PASSWORD_FOR_RESET") as mock_hash:
new_password = "NewPassword123!"
result = auth_service.reset_password("valid_reset_token", new_password)
assert result is True
mock_hash.assert_called_once_with(new_password)
# Verification: Check that user data was updated
updated_user = auth_service.user_repository.get_user_by_id(self.user.id)
assert updated_user.hashed_password == "NEW_HASHED_PASSWORD_FOR_RESET"
@patch('src.my_auth.core.email.send_email')
def test_request_email_verification_success(self, mock_send_email: MagicMock, auth_service: AuthService):
"""Success: Requesting verification generates a token and sends an email."""
tm = auth_service.token_manager
with patch.object(tm, 'create_email_verification_token',
return_value="MOCKED_JWT_VERIFY_TOKEN") as mock_create_token:
token_string = auth_service.request_email_verification(self.user.email)
assert token_string == "MOCKED_JWT_VERIFY_TOKEN"
mock_create_token.assert_called_once_with(self.user.email)
mock_send_email.assert_called_once()
def test_verify_email_success(self, auth_service: AuthService):
"""Success: Verification updates the user's status."""
# The token_manager is mocked in conftest, so we must access its real create method
# or rely on the mock's return value to get a token string to use in the call.
# Since we need a real token for the decode logic to pass, we need to bypass the mock here.
# We will temporarily use the real TokenManager to create a valid, decodable token.
# This requires an *unmocked* token manager instance, which is tricky in this setup.
# Alternative: Temporarily inject a real TokenManager for this test (or rely on a non-mocked method)
# Assuming TokenManager.create_email_verification_token can be mocked to return a static string
# and TokenManager.decode_email_verification_token can be patched to simulate success.
# Since the method calls decode_email_verification_token internally, we mock the output of the decode step.
# Setup: Ensure user is unverified
auth_service.user_repository.update_user(self.user.id, UserUpdate(is_verified=False))
tm = auth_service.token_manager
# Mock the decode step to ensure it returns the email used for verification
with patch.object(tm, 'decode_email_verification_token', return_value=self.user.email) as mock_decode:
# Test (we use a dummy token string as the decode step is mocked)
result = auth_service.verify_email("dummy_verification_token")
assert result is True
mock_decode.assert_called_once()
# Verification: User is verified
updated_user = auth_service.user_repository.get_user_by_id(self.user.id)
assert updated_user.is_verified is True
# class TestAuthServiceResetVerification(object):
# """Tests for password reset and email verification flows."""
#
# @pytest.fixture(autouse=True)
# def setup_user(self, auth_service: AuthService, test_user_data_create: UserCreate):
# """Sets up a registered user using a mock hash for speed."""
#
# pm = auth_service.password_manager
# original_hash = pm.hash_password.return_value
#
# # Temporarily set hash for setup
# pm.hash_password.return_value = "HASHED_PASS"
# user = auth_service.register(test_user_data_create)
# self.user = user
#
# # Restore hash mock
# pm.hash_password.return_value = original_hash
#
# @patch('src.my_auth.core.email.send_email')
# def test_request_password_reset_success(self, mock_send_email: MagicMock, auth_service: AuthService):
# """Success: Requesting a password reset generates a token and sends an email."""
#
# tm = auth_service.token_manager
# with patch.object(tm, 'create_password_reset_token',
# return_value="MOCKED_RESET_TOKEN") as mock_create_token:
# token_string = auth_service.request_password_reset(self.user.email)
#
# assert token_string == "MOCKED_RESET_TOKEN"
# mock_create_token.assert_called_once()
# mock_send_email.assert_called_once()
#
# def test_reset_password_success(self, auth_service: AuthService):
# """Success: Resetting the password works with a valid token."""
#
# # Setup: Manually create a valid reset token
# auth_service.token_repository.save_token(
# TokenData(token="valid_reset_token", token_type="password_reset", user_id=self.user.id,
# expires_at=datetime.now() + timedelta(minutes=10))
# )
#
# # Patch the PasswordManager instance to control the hash output
# pm = auth_service.password_manager
# with patch.object(pm, 'hash_password',
# return_value="NEW_HASHED_PASSWORD_FOR_RESET") as mock_hash:
# new_password = "NewPassword123!"
# result = auth_service.reset_password("valid_reset_token", new_password)
#
# assert result is True
# mock_hash.assert_called_once_with(new_password)
#
# # Verification: Check that user data was updated
# updated_user = auth_service.user_repository.get_user_by_id(self.user.id)
# assert updated_user.hashed_password == "NEW_HASHED_PASSWORD_FOR_RESET"
#
# @patch('src.my_auth.core.email.send_email')
# def test_request_email_verification_success(self, mock_send_email: MagicMock, auth_service: AuthService):
# """Success: Requesting verification generates a token and sends an email."""
#
# tm = auth_service.token_manager
# with patch.object(tm, 'create_email_verification_token',
# return_value="MOCKED_JWT_VERIFY_TOKEN") as mock_create_token:
# token_string = auth_service.request_email_verification(self.user.email)
#
# assert token_string == "MOCKED_JWT_VERIFY_TOKEN"
# mock_create_token.assert_called_once_with(self.user.email)
# mock_send_email.assert_called_once()
#
# def test_verify_email_success(self, auth_service: AuthService):
# """Success: Verification updates the user's status."""
#
# # The token_manager is mocked in conftest, so we must access its real create method
# # or rely on the mock's return value to get a token string to use in the call.
# # Since we need a real token for the decode logic to pass, we need to bypass the mock here.
#
# # We will temporarily use the real TokenManager to create a valid, decodable token.
# # This requires an *unmocked* token manager instance, which is tricky in this setup.
#
# # Alternative: Temporarily inject a real TokenManager for this test (or rely on a non-mocked method)
#
# # Assuming TokenManager.create_email_verification_token can be mocked to return a static string
# # and TokenManager.decode_email_verification_token can be patched to simulate success.
#
# # Since the method calls decode_email_verification_token internally, we mock the output of the decode step.
#
# # Setup: Ensure user is unverified
# auth_service.user_repository.update_user(self.user.id, UserUpdate(is_verified=False))
#
# tm = auth_service.token_manager
#
# # Mock the decode step to ensure it returns the email used for verification
# with patch.object(tm, 'decode_email_verification_token', return_value=self.user.email) as mock_decode:
# # Test (we use a dummy token string as the decode step is mocked)
# result = auth_service.verify_email("dummy_verification_token")
#
# assert result is True
# mock_decode.assert_called_once()
#
# # Verification: User is verified
# updated_user = auth_service.user_repository.get_user_by_id(self.user.id)
# assert updated_user.is_verified is True

View File

@@ -0,0 +1,224 @@
# tests/core/test_token_manager.py
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from jose import jwt
from src.my_auth.core.token import TokenManager
from src.my_auth.exceptions import InvalidTokenError, ExpiredTokenError
from src.my_auth.models.user import UserInDB # Assuming you have a fixture for this
@pytest.fixture
def token_manager():
"""Provides a TokenManager instance with known, short expiration times for testing."""
return TokenManager(
jwt_secret="TEST_SECRET_KEY",
jwt_algorithm="HS256",
access_token_expire_minutes=1,
refresh_token_expire_days=7,
password_reset_token_expire_minutes=15
)
class TestTokenManagerInitialization:
"""Tests for TokenManager setup and configuration."""
def test_init_success(self):
"""Should initialize successfully with required parameters."""
tm = TokenManager(jwt_secret="MySecret")
assert tm.jwt_secret == "MySecret"
assert tm.jwt_algorithm == "HS256"
assert tm.access_token_expire_minutes == 30
assert tm.refresh_token_expire_days == 7
assert tm.password_reset_token_expire_minutes == 15
def test_init_failure_empty_secret(self):
"""Should raise ValueError if JWT secret is empty."""
with pytest.raises(ValueError, match="JWT secret cannot be empty"):
TokenManager(jwt_secret="")
class TestTokenCreation:
"""Tests creation methods for different token types."""
def test_create_access_token_format_and_expiration(self, token_manager: TokenManager, test_user_in_db: UserInDB):
"""Should create a valid JWT with correct payload and expiration."""
token = token_manager.create_access_token(test_user_in_db)
# 1. Assert token is a string (encoded)
assert isinstance(token, str)
# 2. Decode and check payload content
payload = jwt.decode(token, token_manager.jwt_secret, algorithms=[token_manager.jwt_algorithm])
assert payload["sub"] == test_user_in_db.id
assert payload["email"] == test_user_in_db.email
assert payload["type"] == "access"
# 3. Check expiration (should be within a small window of the expected time)
now = datetime.now()
expected_exp_dt = now + timedelta(minutes=token_manager.access_token_expire_minutes)
# Check if expiration is within +/- 1 second of the expected value
assert abs(payload["exp"] - int(expected_exp_dt.timestamp())) <= 1
def test_create_refresh_token_format(self, token_manager: TokenManager):
"""Should create a random hex string of length 64."""
token = token_manager.create_refresh_token()
assert isinstance(token, str)
assert len(token) == 64
assert all(c in '0123456789abcdef' for c in token)
def test_create_password_reset_token_format(self, token_manager: TokenManager):
"""Should create a random hex string of length 64."""
token = token_manager.create_password_reset_token()
assert isinstance(token, str)
assert len(token) == 64
def test_create_email_verification_token_format(self, token_manager: TokenManager):
"""Should create a JWT with email and 'email_verification' type."""
email = "verify@example.com"
token = token_manager.create_email_verification_token(email)
# Decode and check payload content
payload = jwt.decode(token, token_manager.jwt_secret, algorithms=[token_manager.jwt_algorithm])
assert payload["email"] == email
assert payload["type"] == "email_verification"
# Expiration check (set to 7 days in the implementation)
now = datetime.now()
expected_exp_dt = now + timedelta(days=7)
assert abs(payload["exp"] - int(expected_exp_dt.timestamp())) <= 1
class TestTokenExpirationCalculations:
"""Tests for token expiration date methods."""
# We patch datetime.now() to ensure stable calculations
@patch('src.my_auth.core.token.datetime')
def test_get_refresh_token_expiration(self, mock_datetime, token_manager: TokenManager):
"""Should calculate refresh token expiration correctly."""
# Set a fixed starting time
start_time = datetime(2025, 1, 1, 10, 0, 0)
mock_datetime.now = MagicMock(return_value=start_time)
expected_exp = start_time + timedelta(days=token_manager.refresh_token_expire_days)
actual_exp = token_manager.get_refresh_token_expiration()
assert actual_exp == expected_exp
@patch('src.my_auth.core.token.datetime')
def test_get_password_reset_token_expiration(self, mock_datetime, token_manager: TokenManager):
"""Should calculate password reset token expiration correctly."""
start_time = datetime(2025, 1, 1, 10, 0, 0)
mock_datetime.now = MagicMock(return_value=start_time)
expected_exp = start_time + timedelta(minutes=token_manager.password_reset_token_expire_minutes)
actual_exp = token_manager.get_password_reset_token_expiration()
assert actual_exp == expected_exp
class TestTokenDecodingAndValidation:
"""Tests decoding and validation logic for JWT tokens."""
# --- Access Token Tests ---
def test_decode_access_token_success(self, token_manager: TokenManager, test_user_in_db: UserInDB):
"""Should successfully decode a valid access token."""
token = token_manager.create_access_token(test_user_in_db)
payload = token_manager.decode_access_token(token)
assert payload.sub == test_user_in_db.id
assert payload.email == test_user_in_db.email
assert payload.type == "access"
def test_i_cannot_decode_expired_access_token(self, token_manager: TokenManager, test_user_in_db: UserInDB):
"""
Should raise ExpiredTokenError when decoding an expired token.
"""
from jose import jwt
from datetime import datetime, timedelta
# Create an already expired token (1 hour ago)
expired_time = datetime.now() - timedelta(hours=1)
payload = {
"sub": test_user_in_db.id,
"email": test_user_in_db.email,
"exp": int(expired_time.timestamp()),
"type": "access"
}
expired_token = jwt.encode(
payload,
token_manager.jwt_secret,
algorithm=token_manager.jwt_algorithm
)
# Should raise ExpiredTokenError
with pytest.raises(ExpiredTokenError, match="Access token has expired"):
token_manager.decode_access_token(expired_token)
def test_decode_access_token_invalid_signature(self, token_manager: TokenManager, test_user_in_db: UserInDB):
"""Should raise InvalidTokenError if the signature is bad."""
token = token_manager.create_access_token(test_user_in_db)
# Flip the last character to invalidate the signature
invalid_token = token[:-1] + ('A' if token[-1] != 'A' else 'B')
with pytest.raises(InvalidTokenError, match="Invalid access token"):
token_manager.decode_access_token(invalid_token)
def test_decode_access_token_wrong_type(self, token_manager: TokenManager):
"""Should raise InvalidTokenError if token is not 'access' type."""
# Create an email verification token, but try to decode it as an access token
wrong_token = token_manager.create_email_verification_token("wrong@type.com")
with pytest.raises(InvalidTokenError, match="Invalid token type"):
token_manager.decode_access_token(wrong_token)
# --- Email Verification Token Tests ---
def test_decode_email_verification_token_success(self, token_manager: TokenManager):
"""Should successfully decode a valid email verification token."""
email = "valid_email@test.com"
token = token_manager.create_email_verification_token(email)
decoded_email = token_manager.decode_email_verification_token(token)
assert decoded_email == email
def test_decode_email_verification_token_expired(self, token_manager: TokenManager):
"""Should raise ExpiredTokenError if the token is old (7 days set in creation)."""
# This test requires mocking time, but given the 7-day expiration,
# we can simulate an expired token by manually encoding one.
# Manually encode an expired token
expired_payload = {
"email": "old@example.com",
"exp": int((datetime.now() - timedelta(days=1)).timestamp()), # Expired yesterday
"type": "email_verification"
}
expired_token = jwt.encode(expired_payload, token_manager.jwt_secret, algorithm=token_manager.jwt_algorithm)
with pytest.raises(ExpiredTokenError, match="Email verification token has expired"):
token_manager.decode_email_verification_token(expired_token)
def test_decode_email_verification_token_wrong_type(self, token_manager: TokenManager, test_user_in_db: UserInDB):
"""Should raise InvalidTokenError if token is not 'email_verification' type."""
# Create an access token, but try to decode it as an email token
wrong_token = token_manager.create_access_token(test_user_in_db)
with pytest.raises(InvalidTokenError, match="Invalid token type"):
token_manager.decode_email_verification_token(wrong_token)