Updated README.md
This commit is contained in:
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
515
tests/api/test_api_routes.py
Normal file
515
tests/api/test_api_routes.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
224
tests/core/test_token_manager.py
Normal file
224
tests/core/test_token_manager.py
Normal 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)
|
||||
Reference in New Issue
Block a user