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

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)