From a2b32bc847fd2a9c4e5c3aa7b6f940c8c30a781c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vit=C3=B3ria=20Silva?= Date: Fri, 19 Dec 2025 11:25:57 +0000 Subject: [PATCH] Add tests for identity_providers module and update .gitignore Added comprehensive unit tests for the identity_providers module, including CRUD operations, schema validation, and utility functions. Updated .gitignore to exclude deeper __pycache__ directories. Removed session test files and old __pycache__ files from the repository. --- .gitignore | 3 + .../tests/auth/identity_providers/__init__.py | 1 + .../auth/identity_providers/test_crud.py | 677 ++++++++++ .../auth/identity_providers/test_schema.py | 812 ++++++++++++ .../auth/identity_providers/test_utils.py | 493 +++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 251 -> 0 bytes .../test_crud.cpython-313-pytest-8.4.2.pyc | Bin 26098 -> 0 bytes .../test_router.cpython-313-pytest-8.4.2.pyc | Bin 20454 -> 0 bytes .../test_utils.cpython-313-pytest-8.4.2.pyc | Bin 15776 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 241 -> 0 bytes .../test_crud.cpython-313-pytest-8.4.2.pyc | Bin 31966 -> 0 bytes .../test_utils.cpython-313-pytest-8.4.2.pyc | Bin 4944 -> 0 bytes backend/tests/auth/test_constants.py | 181 +++ backend/tests/auth/test_schema.py | 311 +++++ backend/tests/auth/test_security.py | 287 +++++ backend/tests/auth/test_utils.py | 398 ++++++ backend/tests/session/__init__.py | 3 - backend/tests/session/test_router.py | 994 -------------- backend/tests/session/test_utils.py | 1140 ----------------- 19 files changed, 3163 insertions(+), 2137 deletions(-) create mode 100644 backend/tests/auth/identity_providers/__init__.py create mode 100644 backend/tests/auth/identity_providers/test_crud.py create mode 100644 backend/tests/auth/identity_providers/test_schema.py create mode 100644 backend/tests/auth/identity_providers/test_utils.py delete mode 100644 backend/tests/auth/mfa_backup_codes/__pycache__/__init__.cpython-313.pyc delete mode 100644 backend/tests/auth/mfa_backup_codes/__pycache__/test_crud.cpython-313-pytest-8.4.2.pyc delete mode 100644 backend/tests/auth/mfa_backup_codes/__pycache__/test_router.cpython-313-pytest-8.4.2.pyc delete mode 100644 backend/tests/auth/mfa_backup_codes/__pycache__/test_utils.cpython-313-pytest-8.4.2.pyc delete mode 100644 backend/tests/auth/oauth_state/__pycache__/__init__.cpython-313.pyc delete mode 100644 backend/tests/auth/oauth_state/__pycache__/test_crud.cpython-313-pytest-8.4.2.pyc delete mode 100644 backend/tests/auth/oauth_state/__pycache__/test_utils.cpython-313-pytest-8.4.2.pyc create mode 100644 backend/tests/auth/test_constants.py create mode 100644 backend/tests/auth/test_schema.py create mode 100644 backend/tests/auth/test_security.py create mode 100644 backend/tests/auth/test_utils.py delete mode 100644 backend/tests/session/__init__.py delete mode 100644 backend/tests/session/test_router.py delete mode 100644 backend/tests/session/test_utils.py diff --git a/.gitignore b/.gitignore index db01ef663..2ecad6177 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ backend/app/*/*/*/__pycache__/ backend/app/*.pyc backend/tests/__pycache__/ backend/tests/*/__pycache__/ +backend/tests/*/*/__pycache__/ +backend/tests/*/*/*/__pycache__/ +backend/tests/*/*/*/*/__pycache__/ # Tests backend/.coverage diff --git a/backend/tests/auth/identity_providers/__init__.py b/backend/tests/auth/identity_providers/__init__.py new file mode 100644 index 000000000..a6f7a5daf --- /dev/null +++ b/backend/tests/auth/identity_providers/__init__.py @@ -0,0 +1 @@ +"""Tests for identity_providers module.""" diff --git a/backend/tests/auth/identity_providers/test_crud.py b/backend/tests/auth/identity_providers/test_crud.py new file mode 100644 index 000000000..cfb6b6f84 --- /dev/null +++ b/backend/tests/auth/identity_providers/test_crud.py @@ -0,0 +1,677 @@ +"""Tests for identity_providers.crud module.""" + +import pytest +from unittest.mock import MagicMock, patch +from fastapi import HTTPException + +from auth.identity_providers import crud as idp_crud +from auth.identity_providers.schema import ( + IdentityProviderCreate, + IdentityProviderUpdate, +) +from auth.identity_providers.models import IdentityProvider + + +class TestGetIdentityProvider: + """Test suite for get_identity_provider function.""" + + def test_get_identity_provider_success(self, mock_db): + """Test successfully retrieving an identity provider by ID. + + Args: + mock_db: Mocked database session + + Asserts: + - Correct database query is executed + - Identity provider is returned + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_idp.id = 1 + mock_idp.name = "Test Provider" + mock_db.query.return_value.filter.return_value.first.return_value = mock_idp + + # Act + result = idp_crud.get_identity_provider(1, mock_db) + + # Assert + assert result == mock_idp + mock_db.query.assert_called_once() + mock_db.query.return_value.filter.assert_called_once() + + def test_get_identity_provider_not_found(self, mock_db): + """Test retrieving non-existent identity provider. + + Args: + mock_db: Mocked database session + + Asserts: + - None is returned when provider not found + """ + # Arrange + mock_db.query.return_value.filter.return_value.first.return_value = None + + # Act + result = idp_crud.get_identity_provider(999, mock_db) + + # Assert + assert result is None + + def test_get_identity_provider_database_error(self, mock_db): + """Test get_identity_provider handles database errors. + + Args: + mock_db: Mocked database session + + Asserts: + - HTTPException with 500 status is raised on database error + """ + # Arrange + mock_db.query.side_effect = Exception("Database error") + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.get_identity_provider(1, mock_db) + + assert exc_info.value.status_code == 500 + assert "Internal Server Error" in str(exc_info.value.detail) + + +class TestGetIdentityProviderBySlug: + """Test suite for get_identity_provider_by_slug function.""" + + def test_get_identity_provider_by_slug_success(self, mock_db): + """Test successfully retrieving an identity provider by slug. + + Args: + mock_db: Mocked database session + + Asserts: + - Correct database query is executed + - Identity provider is returned + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_idp.slug = "test-provider" + mock_db.query.return_value.filter.return_value.first.return_value = mock_idp + + # Act + result = idp_crud.get_identity_provider_by_slug("test-provider", mock_db) + + # Assert + assert result == mock_idp + mock_db.query.assert_called_once() + + def test_get_identity_provider_by_slug_not_found(self, mock_db): + """Test retrieving non-existent identity provider by slug. + + Args: + mock_db: Mocked database session + + Asserts: + - None is returned when provider not found + """ + # Arrange + mock_db.query.return_value.filter.return_value.first.return_value = None + + # Act + result = idp_crud.get_identity_provider_by_slug("nonexistent", mock_db) + + # Assert + assert result is None + + def test_get_identity_provider_by_slug_database_error(self, mock_db): + """Test get_identity_provider_by_slug handles database errors. + + Args: + mock_db: Mocked database session + + Asserts: + - HTTPException with 500 status is raised on database error + """ + # Arrange + mock_db.query.side_effect = Exception("Database error") + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.get_identity_provider_by_slug("test", mock_db) + + assert exc_info.value.status_code == 500 + + +class TestGetAllIdentityProviders: + """Test suite for get_all_identity_providers function.""" + + def test_get_all_identity_providers_success(self, mock_db): + """Test successfully retrieving all identity providers. + + Args: + mock_db: Mocked database session + + Asserts: + - All providers are returned ordered by name + """ + # Arrange + mock_idps = [MagicMock(spec=IdentityProvider) for _ in range(3)] + mock_db.query.return_value.order_by.return_value.all.return_value = mock_idps + + # Act + result = idp_crud.get_all_identity_providers(mock_db) + + # Assert + assert result == mock_idps + assert len(result) == 3 + mock_db.query.assert_called_once() + mock_db.query.return_value.order_by.assert_called_once() + + def test_get_all_identity_providers_empty(self, mock_db): + """Test retrieving all identity providers when none exist. + + Args: + mock_db: Mocked database session + + Asserts: + - Empty list is returned + """ + # Arrange + mock_db.query.return_value.order_by.return_value.all.return_value = [] + + # Act + result = idp_crud.get_all_identity_providers(mock_db) + + # Assert + assert result == [] + + def test_get_all_identity_providers_database_error(self, mock_db): + """Test get_all_identity_providers handles database errors. + + Args: + mock_db: Mocked database session + + Asserts: + - HTTPException with 500 status is raised on database error + """ + # Arrange + mock_db.query.side_effect = Exception("Database error") + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.get_all_identity_providers(mock_db) + + assert exc_info.value.status_code == 500 + + +class TestGetEnabledProviders: + """Test suite for get_enabled_providers function.""" + + def test_get_enabled_providers_success(self, mock_db): + """Test successfully retrieving enabled identity providers. + + Args: + mock_db: Mocked database session + + Asserts: + - Only enabled providers are returned + - Results are ordered by name + """ + # Arrange + mock_idps = [MagicMock(spec=IdentityProvider) for _ in range(2)] + ( + mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value + ) = mock_idps + + # Act + result = idp_crud.get_enabled_providers(mock_db) + + # Assert + assert result == mock_idps + assert len(result) == 2 + mock_db.query.assert_called_once() + mock_db.query.return_value.filter.assert_called_once() + + def test_get_enabled_providers_empty(self, mock_db): + """Test retrieving enabled providers when none exist. + + Args: + mock_db: Mocked database session + + Asserts: + - Empty list is returned + """ + # Arrange + ( + mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value + ) = [] + + # Act + result = idp_crud.get_enabled_providers(mock_db) + + # Assert + assert result == [] + + def test_get_enabled_providers_database_error(self, mock_db): + """Test get_enabled_providers handles database errors. + + Args: + mock_db: Mocked database session + + Asserts: + - HTTPException with 500 status is raised on database error + """ + # Arrange + mock_db.query.side_effect = Exception("Database error") + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.get_enabled_providers(mock_db) + + assert exc_info.value.status_code == 500 + + +class TestCreateIdentityProvider: + """Test suite for create_identity_provider function.""" + + @patch("auth.identity_providers.crud.idp_models.IdentityProvider") + @patch("auth.identity_providers.crud.core_cryptography.encrypt_token_fernet") + @patch("auth.identity_providers.crud.get_identity_provider_by_slug") + def test_create_identity_provider_success( + self, mock_get_by_slug, mock_encrypt, mock_idp_model, mock_db + ): + """Test successfully creating a new identity provider. + + Args: + mock_get_by_slug: Mocked get_identity_provider_by_slug function + mock_encrypt: Mocked encryption function + mock_idp_model: Mocked IdentityProvider model + mock_db: Mocked database session + + Asserts: + - Identity provider is created with encrypted credentials + - Database commit and refresh are called + """ + # Arrange + mock_get_by_slug.return_value = None # No existing provider + mock_encrypt.side_effect = lambda x: f"encrypted_{x}" + + mock_idp_instance = MagicMock() + mock_idp_instance.id = 1 + mock_idp_instance.name = "Test Provider" + mock_idp_model.return_value = mock_idp_instance + + idp_data = IdentityProviderCreate( + name="Test Provider", + slug="test-provider", + client_id="test-client-id", + client_secret="test-secret", + ) + + # Act + result = idp_crud.create_identity_provider(idp_data, mock_db) + + # Assert + mock_get_by_slug.assert_called_once_with("test-provider", mock_db) + assert mock_encrypt.call_count == 2 # Called for client_id and client_secret + mock_db.add.assert_called_once() + mock_db.commit.assert_called_once() + mock_db.refresh.assert_called_once() + assert result == mock_idp_instance + + @patch("auth.identity_providers.crud.get_identity_provider_by_slug") + def test_create_identity_provider_slug_exists(self, mock_get_by_slug, mock_db): + """Test creating identity provider with existing slug. + + Args: + mock_get_by_slug: Mocked get_identity_provider_by_slug function + mock_db: Mocked database session + + Asserts: + - HTTPException with 409 status is raised + - Error message indicates slug conflict + """ + # Arrange + mock_existing = MagicMock(spec=IdentityProvider) + mock_get_by_slug.return_value = mock_existing + + idp_data = IdentityProviderCreate( + name="Test Provider", + slug="existing-slug", + client_id="test-client-id", + client_secret="test-secret", + ) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.create_identity_provider(idp_data, mock_db) + + assert exc_info.value.status_code == 409 + assert "already exists" in str(exc_info.value.detail) + + @patch("auth.identity_providers.crud.core_cryptography.encrypt_token_fernet") + @patch("auth.identity_providers.crud.get_identity_provider_by_slug") + def test_create_identity_provider_database_error( + self, mock_get_by_slug, mock_encrypt, mock_db + ): + """Test create_identity_provider handles database errors. + + Args: + mock_get_by_slug: Mocked get_identity_provider_by_slug function + mock_encrypt: Mocked encryption function + mock_db: Mocked database session + + Asserts: + - HTTPException with 500 status is raised + - Database rollback is called + """ + # Arrange + mock_get_by_slug.return_value = None + mock_encrypt.side_effect = lambda x: f"encrypted_{x}" + mock_db.commit.side_effect = Exception("Database error") + + idp_data = IdentityProviderCreate( + name="Test", + slug="test", + client_id="client-id", + client_secret="secret", + ) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.create_identity_provider(idp_data, mock_db) + + assert exc_info.value.status_code == 500 + mock_db.rollback.assert_called_once() + + +class TestUpdateIdentityProvider: + """Test suite for update_identity_provider function.""" + + @patch("auth.identity_providers.crud.core_cryptography.encrypt_token_fernet") + @patch("auth.identity_providers.crud.get_identity_provider_by_slug") + @patch("auth.identity_providers.crud.get_identity_provider") + def test_update_identity_provider_success( + self, mock_get, mock_get_by_slug, mock_encrypt, mock_db + ): + """Test successfully updating an identity provider. + + Args: + mock_get: Mocked get_identity_provider function + mock_get_by_slug: Mocked get_identity_provider_by_slug function + mock_encrypt: Mocked encryption function + mock_db: Mocked database session + + Asserts: + - Identity provider is updated + - Encrypted fields are encrypted + - Database commit and refresh are called + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_idp.id = 1 + mock_idp.slug = "original-slug" + mock_get.return_value = mock_idp + mock_get_by_slug.return_value = None + mock_encrypt.side_effect = lambda x: f"encrypted_{x}" + + idp_data = IdentityProviderUpdate( + name="Updated Provider", + slug="updated-slug", + client_id="new-client-id", + client_secret="new-secret", + ) + + # Act + result = idp_crud.update_identity_provider(1, idp_data, mock_db) + + # Assert + mock_get.assert_called_once_with(1, mock_db) + mock_get_by_slug.assert_called_once_with("updated-slug", mock_db) + assert mock_encrypt.call_count == 2 + mock_db.commit.assert_called_once() + mock_db.refresh.assert_called_once() + + @patch("auth.identity_providers.crud.get_identity_provider") + def test_update_identity_provider_not_found(self, mock_get, mock_db): + """Test updating non-existent identity provider. + + Args: + mock_get: Mocked get_identity_provider function + mock_db: Mocked database session + + Asserts: + - HTTPException with 404 status is raised + """ + # Arrange + mock_get.return_value = None + idp_data = IdentityProviderUpdate( + name="Updated", slug="updated-slug", client_id="client-123" + ) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.update_identity_provider(999, idp_data, mock_db) + + assert exc_info.value.status_code == 404 + assert "not found" in str(exc_info.value.detail) + + @patch("auth.identity_providers.crud.get_identity_provider_by_slug") + @patch("auth.identity_providers.crud.get_identity_provider") + def test_update_identity_provider_slug_conflict( + self, mock_get, mock_get_by_slug, mock_db + ): + """Test updating identity provider with conflicting slug. + + Args: + mock_get: Mocked get_identity_provider function + mock_get_by_slug: Mocked get_identity_provider_by_slug function + mock_db: Mocked database session + + Asserts: + - HTTPException with 409 status is raised + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_idp.slug = "original-slug" + mock_get.return_value = mock_idp + + mock_existing = MagicMock(spec=IdentityProvider) + mock_existing.slug = "existing-slug" + mock_get_by_slug.return_value = mock_existing + + idp_data = IdentityProviderUpdate( + name="Test", slug="existing-slug", client_id="client-123" + ) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.update_identity_provider(1, idp_data, mock_db) + + assert exc_info.value.status_code == 409 + assert "already exists" in str(exc_info.value.detail) + + @patch("auth.identity_providers.crud.core_cryptography.encrypt_token_fernet") + @patch("auth.identity_providers.crud.get_identity_provider") + def test_update_identity_provider_without_slug_change( + self, mock_get, mock_encrypt, mock_db + ): + """Test updating identity provider without changing slug. + + Args: + mock_get: Mocked get_identity_provider function + mock_encrypt: Mocked encryption function + mock_db: Mocked database session + + Asserts: + - get_identity_provider_by_slug is not called + - Update succeeds + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_idp.slug = "test-slug" + mock_get.return_value = mock_idp + mock_encrypt.side_effect = lambda x: f"encrypted_{x}" + + idp_data = IdentityProviderUpdate( + name="Updated Name", slug="test-slug", client_id="client-123" + ) + + # Act + result = idp_crud.update_identity_provider(1, idp_data, mock_db) + + # Assert + mock_get.assert_called_once_with(1, mock_db) + mock_db.commit.assert_called_once() + + @patch("auth.identity_providers.crud.core_cryptography.encrypt_token_fernet") + @patch("auth.identity_providers.crud.get_identity_provider") + def test_update_identity_provider_database_error( + self, mock_get, mock_encrypt, mock_db + ): + """Test update_identity_provider handles database errors. + + Args: + mock_get: Mocked get_identity_provider function + mock_encrypt: Mocked encryption function + mock_db: Mocked database session + + Asserts: + - HTTPException with 500 status is raised + - Database rollback is called + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_idp.slug = "test-slug" + mock_get.return_value = mock_idp + mock_encrypt.side_effect = lambda x: f"encrypted_{x}" + mock_db.commit.side_effect = Exception("Database error") + + # Use same slug to avoid conflict check + idp_data = IdentityProviderUpdate( + name="Updated", slug="test-slug", client_id="client-123" + ) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.update_identity_provider(1, idp_data, mock_db) + + assert exc_info.value.status_code == 500 + mock_db.rollback.assert_called_once() + + +class TestDeleteIdentityProvider: + """Test suite for delete_identity_provider function.""" + + @patch( + "auth.identity_providers.crud.user_identity_providers_crud.check_user_identity_providers_by_idp_id" + ) + @patch("auth.identity_providers.crud.get_identity_provider") + def test_delete_identity_provider_success( + self, mock_get, mock_check_users, mock_db + ): + """Test successfully deleting an identity provider. + + Args: + mock_get: Mocked get_identity_provider function + mock_check_users: Mocked check for user links + mock_db: Mocked database session + + Asserts: + - Identity provider is deleted + - Database commit is called + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_idp.id = 1 + mock_idp.name = "Test Provider" + mock_get.return_value = mock_idp + mock_check_users.return_value = None # No linked users + + # Act + idp_crud.delete_identity_provider(1, mock_db) + + # Assert + mock_get.assert_called_once_with(1, mock_db) + mock_check_users.assert_called_once_with(1, mock_db) + mock_db.delete.assert_called_once_with(mock_idp) + mock_db.commit.assert_called_once() + + @patch("auth.identity_providers.crud.get_identity_provider") + def test_delete_identity_provider_not_found(self, mock_get, mock_db): + """Test deleting non-existent identity provider. + + Args: + mock_get: Mocked get_identity_provider function + mock_db: Mocked database session + + Asserts: + - HTTPException with 404 status is raised + """ + # Arrange + mock_get.return_value = None + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.delete_identity_provider(999, mock_db) + + assert exc_info.value.status_code == 404 + assert "not found" in str(exc_info.value.detail) + + @patch( + "auth.identity_providers.crud.user_identity_providers_crud.check_user_identity_providers_by_idp_id" + ) + @patch("auth.identity_providers.crud.get_identity_provider") + def test_delete_identity_provider_with_linked_users( + self, mock_get, mock_check_users, mock_db + ): + """Test deleting identity provider with linked users. + + Args: + mock_get: Mocked get_identity_provider function + mock_check_users: Mocked check for user links + mock_db: Mocked database session + + Asserts: + - HTTPException with 409 status is raised + - Error message indicates linked users + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_get.return_value = mock_idp + mock_check_users.return_value = True # Has linked users + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.delete_identity_provider(1, mock_db) + + assert exc_info.value.status_code == 409 + assert "linked users" in str(exc_info.value.detail) + + @patch( + "auth.identity_providers.crud.user_identity_providers_crud.check_user_identity_providers_by_idp_id" + ) + @patch("auth.identity_providers.crud.get_identity_provider") + def test_delete_identity_provider_database_error( + self, mock_get, mock_check_users, mock_db + ): + """Test delete_identity_provider handles database errors. + + Args: + mock_get: Mocked get_identity_provider function + mock_check_users: Mocked check for user links + mock_db: Mocked database session + + Asserts: + - HTTPException with 500 status is raised + - Database rollback is called + """ + # Arrange + mock_idp = MagicMock(spec=IdentityProvider) + mock_get.return_value = mock_idp + mock_check_users.return_value = None + mock_db.delete.side_effect = Exception("Database error") + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + idp_crud.delete_identity_provider(1, mock_db) + + assert exc_info.value.status_code == 500 + mock_db.rollback.assert_called_once() diff --git a/backend/tests/auth/identity_providers/test_schema.py b/backend/tests/auth/identity_providers/test_schema.py new file mode 100644 index 000000000..6556bb91e --- /dev/null +++ b/backend/tests/auth/identity_providers/test_schema.py @@ -0,0 +1,812 @@ +"""Tests for identity_providers.schema module.""" + +import pytest +from pydantic import ValidationError +from datetime import datetime + +from auth.identity_providers.schema import ( + IdentityProviderBase, + IdentityProviderCreate, + IdentityProviderUpdate, + IdentityProvider, + IdentityProviderPublic, + IdentityProviderTemplate, + TokenExchangeRequest, + TokenExchangeResponse, +) + + +class TestIdentityProviderBase: + """Test suite for IdentityProviderBase schema.""" + + def test_valid_identity_provider_base(self): + """Test creating a valid IdentityProviderBase instance. + + Asserts: + - Instance is created successfully + - All fields have correct values + - Default values are applied + """ + # Arrange & Act + idp = IdentityProviderBase( + name="Test Provider", + slug="test-provider", + provider_type="oidc", + enabled=True, + issuer_url="https://auth.example.com", + authorization_endpoint="https://auth.example.com/authorize", + token_endpoint="https://auth.example.com/token", + userinfo_endpoint="https://auth.example.com/userinfo", + jwks_uri="https://auth.example.com/jwks", + scopes="openid profile email", + icon="test-icon", + auto_create_users=True, + sync_user_info=True, + user_mapping={"username": ["preferred_username"], "email": ["email"]}, + client_id="test-client-id", + ) + + # Assert + assert idp.name == "Test Provider" + assert idp.slug == "test-provider" + assert idp.provider_type == "oidc" + assert idp.enabled is True + assert idp.issuer_url == "https://auth.example.com" + assert idp.scopes == "openid profile email" + assert idp.auto_create_users is True + assert idp.sync_user_info is True + assert idp.client_id == "test-client-id" + + def test_identity_provider_base_with_defaults(self): + """Test IdentityProviderBase with default values. + + Asserts: + - Default provider_type is 'oidc' + - Default enabled is False + - Default scopes is 'openid profile email' + - Default auto_create_users is True + - Default sync_user_info is True + """ + # Arrange & Act + idp = IdentityProviderBase(name="Test", slug="test", client_id="client-123") + + # Assert + assert idp.provider_type == "oidc" + assert idp.enabled is False + assert idp.scopes == "openid profile email" + assert idp.auto_create_users is True + assert idp.sync_user_info is True + + def test_slug_validation_lowercase_alphanumeric_hyphens(self): + """Test slug validation accepts valid characters. + + Asserts: + - Lowercase letters, numbers, and hyphens are accepted + """ + # Arrange & Act + idp = IdentityProviderBase( + name="Test", slug="test-provider-123", client_id="client-123" + ) + + # Assert + assert idp.slug == "test-provider-123" + + def test_slug_validation_uppercase_rejected(self): + """Test slug validation rejects uppercase letters. + + Asserts: + - ValidationError is raised for uppercase characters + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderBase( + name="Test", slug="Test-Provider", client_id="client-123" + ) + + assert "Slug must contain only lowercase letters" in str(exc_info.value) + + def test_slug_validation_special_characters_rejected(self): + """Test slug validation rejects special characters. + + Asserts: + - ValidationError is raised for special characters + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderBase( + name="Test", slug="test_provider", client_id="client-123" + ) + + assert "Slug must contain only lowercase letters" in str(exc_info.value) + + def test_provider_type_validation_oidc(self): + """Test provider_type validation accepts 'oidc'. + + Asserts: + - 'oidc' is accepted as valid provider type + """ + # Arrange & Act + idp = IdentityProviderBase( + name="Test", slug="test", provider_type="oidc", client_id="client-123" + ) + + # Assert + assert idp.provider_type == "oidc" + + def test_provider_type_validation_oauth2(self): + """Test provider_type validation accepts 'oauth2'. + + Asserts: + - 'oauth2' is accepted as valid provider type + """ + # Arrange & Act + idp = IdentityProviderBase( + name="Test", slug="test", provider_type="oauth2", client_id="client-123" + ) + + # Assert + assert idp.provider_type == "oauth2" + + def test_provider_type_validation_saml(self): + """Test provider_type validation accepts 'saml'. + + Asserts: + - 'saml' is accepted as valid provider type + """ + # Arrange & Act + idp = IdentityProviderBase( + name="Test", slug="test", provider_type="saml", client_id="client-123" + ) + + # Assert + assert idp.provider_type == "saml" + + def test_provider_type_validation_invalid(self): + """Test provider_type validation rejects invalid types. + + Asserts: + - ValidationError is raised for invalid provider type + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderBase( + name="Test", + slug="test", + provider_type="invalid", + client_id="client-123", + ) + + assert "Provider type must be one of" in str(exc_info.value) + + def test_name_max_length_validation(self): + """Test name field max length validation. + + Asserts: + - ValidationError is raised when name exceeds 100 characters + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderBase(name="x" * 101, slug="test", client_id="client-123") + + assert "String should have at most 100 characters" in str(exc_info.value) + + def test_name_min_length_validation(self): + """Test name field min length validation. + + Asserts: + - ValidationError is raised when name is empty + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderBase(name="", slug="test", client_id="client-123") + + assert "String should have at least 1 character" in str(exc_info.value) + + def test_slug_max_length_validation(self): + """Test slug field max length validation. + + Asserts: + - ValidationError is raised when slug exceeds 50 characters + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderBase(name="Test", slug="x" * 51, client_id="client-123") + + assert "String should have at most 50 characters" in str(exc_info.value) + + def test_optional_fields_can_be_none(self): + """Test optional fields can be None. + + Asserts: + - issuer_url, authorization_endpoint, token_endpoint can be None + - userinfo_endpoint, jwks_uri, icon can be None + - user_mapping can be None + """ + # Arrange & Act + idp = IdentityProviderBase( + name="Test", + slug="test", + issuer_url=None, + authorization_endpoint=None, + token_endpoint=None, + userinfo_endpoint=None, + jwks_uri=None, + icon=None, + user_mapping=None, + client_id="client-123", + ) + + # Assert + assert idp.issuer_url is None + assert idp.authorization_endpoint is None + assert idp.token_endpoint is None + assert idp.userinfo_endpoint is None + assert idp.jwks_uri is None + assert idp.icon is None + assert idp.user_mapping is None + + +class TestIdentityProviderCreate: + """Test suite for IdentityProviderCreate schema.""" + + def test_valid_identity_provider_create(self): + """Test creating a valid IdentityProviderCreate instance. + + Asserts: + - Instance is created successfully + - client_secret is required and set + """ + # Arrange & Act + idp = IdentityProviderCreate( + name="Test Provider", + slug="test-provider", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + # Assert + assert idp.name == "Test Provider" + assert idp.slug == "test-provider" + assert idp.client_id == "test-client-id" + assert idp.client_secret == "test-client-secret" + + def test_client_secret_required(self): + """Test client_secret is required for IdentityProviderCreate. + + Asserts: + - ValidationError is raised when client_secret is missing + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderCreate(name="Test", slug="test", client_id="client-123") + + assert "client_secret" in str(exc_info.value) + assert "Field required" in str(exc_info.value) + + def test_client_secret_min_length(self): + """Test client_secret minimum length validation. + + Asserts: + - ValidationError is raised when client_secret is empty + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderCreate( + name="Test", + slug="test", + client_id="client-123", + client_secret="", + ) + + assert "String should have at least 1 character" in str(exc_info.value) + + def test_client_secret_max_length(self): + """Test client_secret maximum length validation. + + Asserts: + - ValidationError is raised when client_secret exceeds 512 characters + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderCreate( + name="Test", + slug="test", + client_id="client-123", + client_secret="x" * 513, + ) + + assert "String should have at most 512 characters" in str(exc_info.value) + + +class TestIdentityProviderUpdate: + """Test suite for IdentityProviderUpdate schema.""" + + def test_valid_identity_provider_update(self): + """Test creating a valid IdentityProviderUpdate instance. + + Asserts: + - Instance is created successfully + - All fields can be updated + """ + # Arrange & Act + idp = IdentityProviderUpdate( + name="Updated Provider", + slug="updated-provider", + provider_type="oauth2", + enabled=True, + client_id="updated-client-id", + client_secret="updated-secret", + ) + + # Assert + assert idp.name == "Updated Provider" + assert idp.slug == "updated-provider" + assert idp.provider_type == "oauth2" + assert idp.enabled is True + assert idp.client_id == "updated-client-id" + assert idp.client_secret == "updated-secret" + + def test_client_secret_optional(self): + """Test client_secret is optional for IdentityProviderUpdate. + + Asserts: + - Instance can be created without client_secret + - client_secret defaults to None + """ + # Arrange & Act + idp = IdentityProviderUpdate(name="Test", slug="test", client_id="client-123") + + # Assert + assert idp.client_secret is None + + def test_client_secret_can_be_none(self): + """Test client_secret can explicitly be None. + + Asserts: + - client_secret can be set to None + """ + # Arrange & Act + idp = IdentityProviderUpdate( + name="Test", slug="test", client_id="client-123", client_secret=None + ) + + # Assert + assert idp.client_secret is None + + def test_client_secret_min_length_when_provided(self): + """Test client_secret minimum length when provided. + + Asserts: + - ValidationError is raised when client_secret is empty string + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderUpdate( + name="Test", + slug="test", + client_id="client-123", + client_secret="", + ) + + assert "String should have at least 1 character" in str(exc_info.value) + + +class TestIdentityProvider: + """Test suite for IdentityProvider schema.""" + + def test_valid_identity_provider(self): + """Test creating a valid IdentityProvider instance. + + Asserts: + - Instance is created successfully + - All fields including id and timestamps are set + """ + # Arrange + now = datetime.utcnow() + + # Act + idp = IdentityProvider( + id=1, + name="Test Provider", + slug="test-provider", + provider_type="oidc", + enabled=True, + client_id="test-client-id", + created_at=now, + updated_at=now, + ) + + # Assert + assert idp.id == 1 + assert idp.name == "Test Provider" + assert idp.slug == "test-provider" + assert idp.provider_type == "oidc" + assert idp.enabled is True + assert idp.client_id == "test-client-id" + assert idp.created_at == now + assert idp.updated_at == now + + def test_identity_provider_requires_id(self): + """Test id field is required for IdentityProvider. + + Asserts: + - ValidationError is raised when id is missing + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProvider( + name="Test", + slug="test", + client_id="client-123", + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + assert "id" in str(exc_info.value) + assert "Field required" in str(exc_info.value) + + def test_identity_provider_requires_timestamps(self): + """Test created_at and updated_at are required. + + Asserts: + - ValidationError is raised when timestamps are missing + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProvider(id=1, name="Test", slug="test", client_id="client-123") + + assert "created_at" in str(exc_info.value) or "updated_at" in str( + exc_info.value + ) + + def test_serialize_client_id_decrypts_encrypted_value(self): + """Test serialize_client_id decrypts encrypted client_id. + + Asserts: + - Encrypted client_id (starting with 'gAAAAAB') triggers decryption + """ + # Arrange + now = datetime.utcnow() + encrypted_id = "gAAAAABtest_encrypted_token" + + # Act + idp = IdentityProvider( + id=1, + name="Test", + slug="test", + client_id=encrypted_id, + created_at=now, + updated_at=now, + ) + + # Assert - This will be tested with actual encryption in integration tests + assert idp.client_id == encrypted_id + + def test_serialize_client_id_preserves_unencrypted_value(self): + """Test serialize_client_id preserves non-encrypted client_id. + + Asserts: + - Non-encrypted client_id is returned as-is + """ + # Arrange + now = datetime.utcnow() + plain_id = "plain-client-id" + + # Act + idp = IdentityProvider( + id=1, + name="Test", + slug="test", + client_id=plain_id, + created_at=now, + updated_at=now, + ) + + # Assert + assert idp.client_id == plain_id + + def test_serialize_client_id_handles_none(self): + """Test serialize_client_id handles None client_id. + + Asserts: + - None client_id returns None + """ + # Arrange + now = datetime.utcnow() + + # Act + idp = IdentityProvider( + id=1, + name="Test", + slug="test", + client_id=None, + created_at=now, + updated_at=now, + ) + + # Assert + assert idp.client_id is None + + +class TestIdentityProviderPublic: + """Test suite for IdentityProviderPublic schema.""" + + def test_valid_identity_provider_public(self): + """Test creating a valid IdentityProviderPublic instance. + + Asserts: + - Instance is created successfully + - Only public fields are present + """ + # Arrange & Act + idp = IdentityProviderPublic( + id=1, name="Test Provider", slug="test-provider", icon="test-icon" + ) + + # Assert + assert idp.id == 1 + assert idp.name == "Test Provider" + assert idp.slug == "test-provider" + assert idp.icon == "test-icon" + + def test_identity_provider_public_icon_optional(self): + """Test icon field is optional for IdentityProviderPublic. + + Asserts: + - Instance can be created without icon + - icon defaults to None + """ + # Arrange & Act + idp = IdentityProviderPublic(id=1, name="Test", slug="test") + + # Assert + assert idp.icon is None + + def test_identity_provider_public_no_sensitive_fields(self): + """Test IdentityProviderPublic doesn't have sensitive fields. + + Asserts: + - client_id, client_secret are not present + """ + # Arrange & Act + idp = IdentityProviderPublic(id=1, name="Test", slug="test") + + # Assert + assert not hasattr(idp, "client_id") + assert not hasattr(idp, "client_secret") + + +class TestIdentityProviderTemplate: + """Test suite for IdentityProviderTemplate schema.""" + + def test_valid_identity_provider_template(self): + """Test creating a valid IdentityProviderTemplate instance. + + Asserts: + - Instance is created successfully + - All fields are set correctly + """ + # Arrange & Act + template = IdentityProviderTemplate( + template_id="keycloak", + name="Keycloak", + provider_type="oidc", + issuer_url="https://keycloak.example.com/realms/master", + scopes="openid profile email", + icon="keycloak", + user_mapping={"username": ["preferred_username"], "email": ["email"]}, + description="Keycloak OIDC provider", + configuration_notes="Setup instructions here", + ) + + # Assert + assert template.template_id == "keycloak" + assert template.name == "Keycloak" + assert template.provider_type == "oidc" + assert template.issuer_url == "https://keycloak.example.com/realms/master" + assert template.scopes == "openid profile email" + assert template.icon == "keycloak" + assert template.user_mapping == { + "username": ["preferred_username"], + "email": ["email"], + } + assert template.description == "Keycloak OIDC provider" + assert template.configuration_notes == "Setup instructions here" + + def test_template_required_fields(self): + """Test required fields for IdentityProviderTemplate. + + Asserts: + - ValidationError is raised when required fields are missing + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + IdentityProviderTemplate() + + error_str = str(exc_info.value) + assert "template_id" in error_str or "Field required" in error_str + + def test_template_optional_fields(self): + """Test optional fields for IdentityProviderTemplate. + + Asserts: + - Template can be created with only required fields + - Optional fields default to None + """ + # Arrange & Act + template = IdentityProviderTemplate( + template_id="custom", + name="Custom Provider", + provider_type="oidc", + scopes="openid", + description="Custom provider", + ) + + # Assert + assert template.issuer_url is None + assert template.icon is None + assert template.user_mapping is None + assert template.configuration_notes is None + + +class TestTokenExchangeRequest: + """Test suite for TokenExchangeRequest schema.""" + + def test_valid_token_exchange_request(self): + """Test creating a valid TokenExchangeRequest instance. + + Asserts: + - Instance is created successfully with valid code_verifier + """ + # Arrange + # Valid base64url string (43 characters minimum) + code_verifier = "a" * 43 + "B1-_" * 10 + + # Act + request = TokenExchangeRequest(code_verifier=code_verifier) + + # Assert + assert request.code_verifier == code_verifier + + def test_code_verifier_min_length(self): + """Test code_verifier minimum length validation. + + Asserts: + - ValidationError is raised when code_verifier is less than 43 chars + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + TokenExchangeRequest(code_verifier="a" * 42) + + assert "String should have at least 43 characters" in str(exc_info.value) + + def test_code_verifier_max_length(self): + """Test code_verifier maximum length validation. + + Asserts: + - ValidationError is raised when code_verifier exceeds 128 chars + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + TokenExchangeRequest(code_verifier="a" * 129) + + assert "String should have at most 128 characters" in str(exc_info.value) + + def test_code_verifier_format_validation_valid(self): + """Test code_verifier format validation accepts valid base64url. + + Asserts: + - Valid base64url characters (A-Z, a-z, 0-9, -, _) are accepted + """ + # Arrange + valid_verifier = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + ) + + # Act + request = TokenExchangeRequest(code_verifier=valid_verifier) + + # Assert + assert request.code_verifier == valid_verifier + + def test_code_verifier_format_validation_invalid(self): + """Test code_verifier format validation rejects invalid characters. + + Asserts: + - ValidationError is raised for non-base64url characters + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + TokenExchangeRequest( + code_verifier="a" * 43 + "!" + ) # ! is not valid base64url + + assert "code_verifier must be valid base64url" in str(exc_info.value) + + def test_code_verifier_format_validation_special_chars(self): + """Test code_verifier format validation rejects special characters. + + Asserts: + - ValidationError is raised for special characters like +, = + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + TokenExchangeRequest(code_verifier="a" * 43 + "+") + + assert "code_verifier must be valid base64url" in str(exc_info.value) + + +class TestTokenExchangeResponse: + """Test suite for TokenExchangeResponse schema.""" + + def test_valid_token_exchange_response(self): + """Test creating a valid TokenExchangeResponse instance. + + Asserts: + - Instance is created successfully with all required fields + - Default values are applied + """ + # Arrange & Act + response = TokenExchangeResponse( + session_id="session-123", + access_token="access-token-xyz", + refresh_token="refresh-token-abc", + csrf_token="csrf-token-def", + ) + + # Assert + assert response.session_id == "session-123" + assert response.access_token == "access-token-xyz" + assert response.refresh_token == "refresh-token-abc" + assert response.csrf_token == "csrf-token-def" + assert response.expires_in == 900 # Default value + assert response.token_type == "Bearer" # Default value + + def test_token_exchange_response_required_fields(self): + """Test required fields for TokenExchangeResponse. + + Asserts: + - ValidationError is raised when required fields are missing + """ + # Arrange & Act & Assert + with pytest.raises(ValidationError) as exc_info: + TokenExchangeResponse() + + error_str = str(exc_info.value) + assert "session_id" in error_str or "access_token" in error_str + + def test_token_exchange_response_custom_expires_in(self): + """Test custom expires_in value for TokenExchangeResponse. + + Asserts: + - Custom expires_in value can be set + """ + # Arrange & Act + response = TokenExchangeResponse( + session_id="session-123", + access_token="access-token-xyz", + refresh_token="refresh-token-abc", + csrf_token="csrf-token-def", + expires_in=1800, + ) + + # Assert + assert response.expires_in == 1800 + + def test_token_exchange_response_custom_token_type(self): + """Test custom token_type value for TokenExchangeResponse. + + Asserts: + - Custom token_type value can be set + """ + # Arrange & Act + response = TokenExchangeResponse( + session_id="session-123", + access_token="access-token-xyz", + refresh_token="refresh-token-abc", + csrf_token="csrf-token-def", + token_type="Custom", + ) + + # Assert + assert response.token_type == "Custom" diff --git a/backend/tests/auth/identity_providers/test_utils.py b/backend/tests/auth/identity_providers/test_utils.py new file mode 100644 index 000000000..b35f84168 --- /dev/null +++ b/backend/tests/auth/identity_providers/test_utils.py @@ -0,0 +1,493 @@ +"""Tests for identity_providers.utils module.""" + +import pytest +from fastapi import HTTPException + +from auth.identity_providers.utils import ( + validate_pkce_challenge, + validate_pkce_verifier, + _secure_compare, + get_idp_template, + get_idp_templates, +) +from auth.identity_providers.schema import IdentityProviderTemplate + + +class TestValidatePkceChallenge: + """Test suite for validate_pkce_challenge function.""" + + def test_validate_pkce_challenge_success(self): + """Test validating a valid PKCE challenge. + + Asserts: + - Valid S256 challenge passes validation + """ + # Arrange + # Valid base64url-encoded SHA256 hash (43 chars minimum) + code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + code_challenge_method = "S256" + + # Act & Assert (no exception should be raised) + validate_pkce_challenge(code_challenge, code_challenge_method) + + def test_validate_pkce_challenge_plain_method_rejected(self): + """Test PKCE plain method is rejected. + + Asserts: + - HTTPException with 400 status is raised for 'plain' method + """ + # Arrange + code_challenge = "test_challenge" + code_challenge_method = "plain" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_challenge(code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "Only S256 PKCE method is supported" in str(exc_info.value.detail) + + def test_validate_pkce_challenge_min_length_violation(self): + """Test PKCE challenge minimum length validation. + + Asserts: + - HTTPException is raised for challenge shorter than 43 chars + """ + # Arrange + code_challenge = "a" * 42 # Too short + code_challenge_method = "S256" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_challenge(code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "43-128 characters" in str(exc_info.value.detail) + + def test_validate_pkce_challenge_max_length_violation(self): + """Test PKCE challenge maximum length validation. + + Asserts: + - HTTPException is raised for challenge longer than 128 chars + """ + # Arrange + code_challenge = "a" * 129 # Too long + code_challenge_method = "S256" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_challenge(code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "43-128 characters" in str(exc_info.value.detail) + + def test_validate_pkce_challenge_invalid_characters(self): + """Test PKCE challenge with invalid characters. + + Asserts: + - HTTPException is raised for non-base64url characters + """ + # Arrange + code_challenge = "a" * 43 + "!@#$" # Invalid characters + code_challenge_method = "S256" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_challenge(code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "valid base64url" in str(exc_info.value.detail) + + def test_validate_pkce_challenge_valid_base64url_characters(self): + """Test PKCE challenge with all valid base64url characters. + + Asserts: + - All base64url characters (A-Z, a-z, 0-9, -, _) are accepted + """ + # Arrange + code_challenge = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr_-" # 45 chars, valid + ) + code_challenge_method = "S256" + + # Act & Assert (no exception should be raised) + validate_pkce_challenge(code_challenge, code_challenge_method) + + +class TestValidatePkceVerifier: + """Test suite for validate_pkce_verifier function.""" + + def test_validate_pkce_verifier_success(self): + """Test validating a valid PKCE verifier. + + Asserts: + - Valid verifier that matches challenge passes validation + """ + # Arrange + # Valid verifier (RFC 7636 compliant) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + # Corresponding S256 challenge + code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + code_challenge_method = "S256" + + # Act & Assert (no exception should be raised) + validate_pkce_verifier(code_verifier, code_challenge, code_challenge_method) + + def test_validate_pkce_verifier_min_length_violation(self): + """Test PKCE verifier minimum length validation. + + Asserts: + - HTTPException is raised for verifier shorter than 43 chars + """ + # Arrange + code_verifier = "a" * 42 # Too short + code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + code_challenge_method = "S256" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_verifier(code_verifier, code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "43-128 characters" in str(exc_info.value.detail) + + def test_validate_pkce_verifier_max_length_violation(self): + """Test PKCE verifier maximum length validation. + + Asserts: + - HTTPException is raised for verifier longer than 128 chars + """ + # Arrange + code_verifier = "a" * 129 # Too long + code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + code_challenge_method = "S256" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_verifier(code_verifier, code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "43-128 characters" in str(exc_info.value.detail) + + def test_validate_pkce_verifier_invalid_characters(self): + """Test PKCE verifier with invalid characters. + + Asserts: + - HTTPException is raised for non-base64url characters + """ + # Arrange + code_verifier = "a" * 43 + "!@#" # Invalid characters + code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + code_challenge_method = "S256" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_verifier(code_verifier, code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "valid base64url" in str(exc_info.value.detail) + + def test_validate_pkce_verifier_wrong_method(self): + """Test PKCE verifier with wrong challenge method. + + Asserts: + - HTTPException is raised for non-S256 method + """ + # Arrange + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + code_challenge_method = "plain" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_verifier(code_verifier, code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "Only S256 PKCE method is supported" in str(exc_info.value.detail) + + def test_validate_pkce_verifier_mismatch(self): + """Test PKCE verifier that doesn't match challenge. + + Asserts: + - HTTPException is raised for mismatched verifier + """ + # Arrange + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = "wrong_challenge_value_that_does_not_match_verifier" + code_challenge_method = "S256" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + validate_pkce_verifier(code_verifier, code_challenge, code_challenge_method) + + assert exc_info.value.status_code == 400 + assert "Invalid code_verifier" in str(exc_info.value.detail) + + +class TestSecureCompare: + """Test suite for _secure_compare function.""" + + def test_secure_compare_equal_strings(self): + """Test secure comparison of equal strings. + + Asserts: + - Returns True for identical strings + """ + # Arrange + str1 = "test_string_123" + str2 = "test_string_123" + + # Act + result = _secure_compare(str1, str2) + + # Assert + assert result is True + + def test_secure_compare_different_strings(self): + """Test secure comparison of different strings. + + Asserts: + - Returns False for different strings + """ + # Arrange + str1 = "test_string_123" + str2 = "test_string_456" + + # Act + result = _secure_compare(str1, str2) + + # Assert + assert result is False + + def test_secure_compare_different_lengths(self): + """Test secure comparison of strings with different lengths. + + Asserts: + - Returns False for strings of different lengths + """ + # Arrange + str1 = "short" + str2 = "much_longer_string" + + # Act + result = _secure_compare(str1, str2) + + # Assert + assert result is False + + def test_secure_compare_empty_strings(self): + """Test secure comparison of empty strings. + + Asserts: + - Returns True for both empty strings + """ + # Arrange + str1 = "" + str2 = "" + + # Act + result = _secure_compare(str1, str2) + + # Assert + assert result is True + + def test_secure_compare_one_empty_string(self): + """Test secure comparison with one empty string. + + Asserts: + - Returns False when one string is empty + """ + # Arrange + str1 = "non_empty" + str2 = "" + + # Act + result = _secure_compare(str1, str2) + + # Assert + assert result is False + + def test_secure_compare_case_sensitive(self): + """Test secure comparison is case-sensitive. + + Asserts: + - Returns False for strings differing only in case + """ + # Arrange + str1 = "TestString" + str2 = "teststring" + + # Act + result = _secure_compare(str1, str2) + + # Assert + assert result is False + + +class TestGetIdpTemplate: + """Test suite for get_idp_template function.""" + + def test_get_idp_template_keycloak(self): + """Test retrieving Keycloak IdP template. + + Asserts: + - Keycloak template is returned with correct structure + """ + # Act + template = get_idp_template("keycloak") + + # Assert + assert template is not None + assert template["name"] == "Keycloak" + assert template["provider_type"] == "oidc" + assert "issuer_url" in template + assert "scopes" in template + assert "user_mapping" in template + + def test_get_idp_template_authentik(self): + """Test retrieving Authentik IdP template. + + Asserts: + - Authentik template is returned with correct structure + """ + # Act + template = get_idp_template("authentik") + + # Assert + assert template is not None + assert template["name"] == "Authentik" + assert template["provider_type"] == "oidc" + + def test_get_idp_template_authelia(self): + """Test retrieving Authelia IdP template. + + Asserts: + - Authelia template is returned with correct structure + """ + # Act + template = get_idp_template("authelia") + + # Assert + assert template is not None + assert template["name"] == "Authelia" + assert template["provider_type"] == "oidc" + + def test_get_idp_template_casdoor(self): + """Test retrieving Casdoor IdP template. + + Asserts: + - Casdoor template is returned with correct structure + """ + # Act + template = get_idp_template("casdoor") + + # Assert + assert template is not None + assert template["name"] == "Casdoor" + assert template["provider_type"] == "oidc" + + def test_get_idp_template_pocketid(self): + """Test retrieving Pocket ID template. + + Asserts: + - Pocket ID template is returned with correct structure + """ + # Act + template = get_idp_template("pocketid") + + # Assert + assert template is not None + assert template["name"] == "Pocket ID" + assert template["provider_type"] == "oidc" + + def test_get_idp_template_nonexistent(self): + """Test retrieving non-existent IdP template. + + Asserts: + - None is returned for non-existent template + """ + # Act + template = get_idp_template("nonexistent_provider") + + # Assert + assert template is None + + +class TestGetIdpTemplates: + """Test suite for get_idp_templates function.""" + + def test_get_idp_templates_returns_list(self): + """Test get_idp_templates returns a list. + + Asserts: + - Returns a list of IdentityProviderTemplate objects + """ + # Act + templates = get_idp_templates() + + # Assert + assert isinstance(templates, list) + assert len(templates) > 0 + assert all(isinstance(t, IdentityProviderTemplate) for t in templates) + + def test_get_idp_templates_contains_expected_providers(self): + """Test get_idp_templates contains expected providers. + + Asserts: + - List contains Keycloak, Authentik, Authelia, Casdoor, Pocket ID + """ + # Act + templates = get_idp_templates() + + # Assert + template_ids = [t.template_id for t in templates] + assert "keycloak" in template_ids + assert "authentik" in template_ids + assert "authelia" in template_ids + assert "casdoor" in template_ids + assert "pocketid" in template_ids + + def test_get_idp_templates_structure(self): + """Test each template has required structure. + + Asserts: + - Each template has required fields + """ + # Act + templates = get_idp_templates() + + # Assert + for template in templates: + assert hasattr(template, "template_id") + assert hasattr(template, "name") + assert hasattr(template, "provider_type") + assert hasattr(template, "scopes") + assert hasattr(template, "description") + + def test_get_idp_templates_all_oidc(self): + """Test all templates are OIDC providers. + + Asserts: + - All templates have provider_type 'oidc' + """ + # Act + templates = get_idp_templates() + + # Assert + for template in templates: + assert template.provider_type == "oidc" + + def test_get_idp_templates_has_user_mapping(self): + """Test templates include user mapping configuration. + + Asserts: + - Each template has user_mapping with username and email + """ + # Act + templates = get_idp_templates() + + # Assert + for template in templates: + assert template.user_mapping is not None + assert "username" in template.user_mapping + assert "email" in template.user_mapping diff --git a/backend/tests/auth/mfa_backup_codes/__pycache__/__init__.cpython-313.pyc b/backend/tests/auth/mfa_backup_codes/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 2fc796bf486969e487ad0cf1bfdca33cd30d425d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 251 zcmey&%ge<81k>eQGc|$qV-N=h7@>^M96-iYhG2#whIB?vrYfb7)Z&t2g|z%41z$Hu zg`~vf?9u{-VujrNl+v73JwHvxTkP@iDf!9q@hcfVgABTrsUKRLT2!o`m7kbj zmRXWtl$ltZnNyaipORXppPpHwpPH9aT9lZXr&~~zpOu=75YPu%0hHA*0a>h{SXz>y zpPQB#53xBOY_ooRd}dx|NqoFsLFFwD8;CpXir9cQgZxnp^1}yaMn=Y43>rl&Kn?)E CBuUx; diff --git a/backend/tests/auth/mfa_backup_codes/__pycache__/test_crud.cpython-313-pytest-8.4.2.pyc b/backend/tests/auth/mfa_backup_codes/__pycache__/test_crud.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index a755420a2441eadf63c0ffb3c24b24190ccadcef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26098 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 diff --git a/backend/tests/auth/mfa_backup_codes/__pycache__/test_router.cpython-313-pytest-8.4.2.pyc b/backend/tests/auth/mfa_backup_codes/__pycache__/test_router.cpython-313-pytest-8.4.2.pyc deleted file mode 100644 index 46bc059dcd929fa4f8e215690952c492b2983e4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20454 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 0 + assert isinstance(auth_constants.JWT_ACCESS_TOKEN_EXPIRE_MINUTES, int) + + def test_refresh_token_expiry_is_positive(self): + """Test that refresh token expiry is a positive integer.""" + assert auth_constants.JWT_REFRESH_TOKEN_EXPIRE_DAYS > 0 + assert isinstance(auth_constants.JWT_REFRESH_TOKEN_EXPIRE_DAYS, int) + + def test_secret_key_is_set(self): + """Test that SECRET_KEY is configured.""" + assert auth_constants.JWT_SECRET_KEY is not None + assert isinstance(auth_constants.JWT_SECRET_KEY, str) + assert len(auth_constants.JWT_SECRET_KEY) >= 32 + + def test_session_timeout_constants(self): + """Test that session timeout constants are valid.""" + assert isinstance(auth_constants.SESSION_IDLE_TIMEOUT_ENABLED, bool) + assert auth_constants.SESSION_IDLE_TIMEOUT_HOURS >= 0 + assert isinstance(auth_constants.SESSION_IDLE_TIMEOUT_HOURS, int) + assert auth_constants.SESSION_ABSOLUTE_TIMEOUT_HOURS > 0 + assert isinstance(auth_constants.SESSION_ABSOLUTE_TIMEOUT_HOURS, int) + + +class TestScopeConstants: + """Test scope configuration constants.""" + + def test_users_regular_scope_defined(self): + """Test that users regular scope is properly defined.""" + assert auth_constants.USERS_REGULAR_SCOPE is not None + assert isinstance(auth_constants.USERS_REGULAR_SCOPE, tuple) + assert "profile" in auth_constants.USERS_REGULAR_SCOPE + assert "users:read" in auth_constants.USERS_REGULAR_SCOPE + + def test_users_admin_scope_defined(self): + """Test that users admin scope is properly defined.""" + assert auth_constants.USERS_ADMIN_SCOPE is not None + assert isinstance(auth_constants.USERS_ADMIN_SCOPE, tuple) + assert "users:write" in auth_constants.USERS_ADMIN_SCOPE + assert "sessions:read" in auth_constants.USERS_ADMIN_SCOPE + assert "sessions:write" in auth_constants.USERS_ADMIN_SCOPE + + def test_gears_scope_defined(self): + """Test that gears scope is properly defined.""" + assert auth_constants.GEARS_SCOPE is not None + assert isinstance(auth_constants.GEARS_SCOPE, tuple) + assert "gears:read" in auth_constants.GEARS_SCOPE + assert "gears:write" in auth_constants.GEARS_SCOPE + + def test_activities_scope_defined(self): + """Test that activities scope is properly defined.""" + assert auth_constants.ACTIVITIES_SCOPE is not None + assert isinstance(auth_constants.ACTIVITIES_SCOPE, tuple) + assert "activities:read" in auth_constants.ACTIVITIES_SCOPE + assert "activities:write" in auth_constants.ACTIVITIES_SCOPE + + def test_health_scope_defined(self): + """Test that health scope is properly defined.""" + assert auth_constants.HEALTH_SCOPE is not None + assert isinstance(auth_constants.HEALTH_SCOPE, tuple) + assert "health:read" in auth_constants.HEALTH_SCOPE + assert "health:write" in auth_constants.HEALTH_SCOPE + assert "health_targets:read" in auth_constants.HEALTH_SCOPE + assert "health_targets:write" in auth_constants.HEALTH_SCOPE + + def test_identity_providers_scope_defined(self): + """Test that identity providers scopes are properly defined.""" + assert auth_constants.IDENTITY_PROVIDERS_REGULAR_SCOPE is not None + assert isinstance(auth_constants.IDENTITY_PROVIDERS_REGULAR_SCOPE, tuple) + assert ( + "identity_providers:read" in auth_constants.IDENTITY_PROVIDERS_REGULAR_SCOPE + ) + + assert auth_constants.IDENTITY_PROVIDERS_ADMIN_SCOPE is not None + assert isinstance(auth_constants.IDENTITY_PROVIDERS_ADMIN_SCOPE, tuple) + assert ( + "identity_providers:write" in auth_constants.IDENTITY_PROVIDERS_ADMIN_SCOPE + ) + + def test_server_settings_scope_defined(self): + """Test that server settings scopes are properly defined.""" + assert auth_constants.SERVER_SETTINGS_REGULAR_SCOPE is not None + assert isinstance(auth_constants.SERVER_SETTINGS_REGULAR_SCOPE, tuple) + + assert auth_constants.SERVER_SETTINGS_ADMIN_SCOPE is not None + assert isinstance(auth_constants.SERVER_SETTINGS_ADMIN_SCOPE, tuple) + assert "server_settings:read" in auth_constants.SERVER_SETTINGS_ADMIN_SCOPE + assert "server_settings:write" in auth_constants.SERVER_SETTINGS_ADMIN_SCOPE + + def test_scope_dict_contains_all_scopes(self): + """Test that SCOPE_DICT contains descriptions for all defined scopes.""" + assert auth_constants.SCOPE_DICT is not None + assert isinstance(auth_constants.SCOPE_DICT, dict) + + # Check key scopes are documented + expected_scopes = [ + "profile", + "users:read", + "users:write", + "gears:read", + "gears:write", + "activities:read", + "activities:write", + "health:read", + "health:write", + "server_settings:read", + "server_settings:write", + ] + + for scope in expected_scopes: + assert scope in auth_constants.SCOPE_DICT + assert isinstance(auth_constants.SCOPE_DICT[scope], str) + assert len(auth_constants.SCOPE_DICT[scope]) > 0 + + def test_regular_access_scope_composition(self): + """Test that REGULAR_ACCESS_SCOPE includes all expected regular scopes.""" + assert auth_constants.REGULAR_ACCESS_SCOPE is not None + assert isinstance(auth_constants.REGULAR_ACCESS_SCOPE, tuple) + + # Should include users regular scope + for scope in auth_constants.USERS_REGULAR_SCOPE: + assert scope in auth_constants.REGULAR_ACCESS_SCOPE + + # Should include gears scope + for scope in auth_constants.GEARS_SCOPE: + assert scope in auth_constants.REGULAR_ACCESS_SCOPE + + # Should include activities scope + for scope in auth_constants.ACTIVITIES_SCOPE: + assert scope in auth_constants.REGULAR_ACCESS_SCOPE + + # Should include health scope + for scope in auth_constants.HEALTH_SCOPE: + assert scope in auth_constants.REGULAR_ACCESS_SCOPE + + def test_admin_access_scope_composition(self): + """Test that ADMIN_ACCESS_SCOPE includes all regular and admin scopes.""" + assert auth_constants.ADMIN_ACCESS_SCOPE is not None + assert isinstance(auth_constants.ADMIN_ACCESS_SCOPE, tuple) + + # Should include all regular scopes + for scope in auth_constants.REGULAR_ACCESS_SCOPE: + assert scope in auth_constants.ADMIN_ACCESS_SCOPE + + # Should include users admin scope + for scope in auth_constants.USERS_ADMIN_SCOPE: + assert scope in auth_constants.ADMIN_ACCESS_SCOPE + + # Should include identity providers admin scope + for scope in auth_constants.IDENTITY_PROVIDERS_ADMIN_SCOPE: + assert scope in auth_constants.ADMIN_ACCESS_SCOPE + + # Should include server settings admin scope + for scope in auth_constants.SERVER_SETTINGS_ADMIN_SCOPE: + assert scope in auth_constants.ADMIN_ACCESS_SCOPE + + def test_admin_scope_is_superset_of_regular(self): + """Test that admin scope contains all permissions from regular scope.""" + regular_set = set(auth_constants.REGULAR_ACCESS_SCOPE) + admin_set = set(auth_constants.ADMIN_ACCESS_SCOPE) + + assert regular_set.issubset( + admin_set + ), "Admin scope should contain all regular scope permissions" diff --git a/backend/tests/auth/test_schema.py b/backend/tests/auth/test_schema.py new file mode 100644 index 000000000..f4e82e1de --- /dev/null +++ b/backend/tests/auth/test_schema.py @@ -0,0 +1,311 @@ +""" +Tests for auth.schema module. + +This module tests Pydantic schemas and dependency classes for authentication, +including login requests, MFA management, and failed attempt tracking. +""" + +import pytest +from datetime import datetime, timedelta, timezone +from pydantic import ValidationError + +import auth.schema as auth_schema + + +class TestLoginRequest: + """Tests for LoginRequest Pydantic model.""" + + def test_login_request_valid(self): + """Test valid login request.""" + request = auth_schema.LoginRequest(username="testuser", password="Password1!") + assert request.username == "testuser" + assert request.password == "Password1!" + + def test_login_request_username_too_short(self): + """Test login request with empty username.""" + with pytest.raises(ValidationError) as exc_info: + auth_schema.LoginRequest(username="", password="Password1!") + assert "username" in str(exc_info.value) + + def test_login_request_username_too_long(self): + """Test login request with username exceeding max length.""" + with pytest.raises(ValidationError) as exc_info: + auth_schema.LoginRequest(username="a" * 251, password="Password1!") + assert "username" in str(exc_info.value) + + def test_login_request_password_too_short(self): + """Test login request with password less than 8 characters.""" + with pytest.raises(ValidationError) as exc_info: + auth_schema.LoginRequest(username="testuser", password="Pass1!") + assert "password" in str(exc_info.value) + + +class TestMFALoginRequest: + """Tests for MFALoginRequest Pydantic model.""" + + def test_mfa_login_request_valid(self): + """Test valid MFA login request with 6-digit code.""" + request = auth_schema.MFALoginRequest(username="testuser", mfa_code="123456") + assert request.username == "testuser" + assert request.mfa_code == "123456" + + def test_mfa_login_request_invalid_code_format_letters(self): + """Test MFA login request with non-numeric code.""" + with pytest.raises(ValidationError) as exc_info: + auth_schema.MFALoginRequest(username="testuser", mfa_code="12345a") + assert "mfa_code" in str(exc_info.value) + + def test_mfa_login_request_invalid_code_too_short(self): + """Test MFA login request with code less than 6 digits.""" + with pytest.raises(ValidationError) as exc_info: + auth_schema.MFALoginRequest(username="testuser", mfa_code="12345") + assert "mfa_code" in str(exc_info.value) + + def test_mfa_login_request_invalid_code_too_long(self): + """Test MFA login request with code more than 6 digits.""" + with pytest.raises(ValidationError) as exc_info: + auth_schema.MFALoginRequest(username="testuser", mfa_code="1234567") + assert "mfa_code" in str(exc_info.value) + + +class TestMFARequiredResponse: + """Tests for MFARequiredResponse Pydantic model.""" + + def test_mfa_required_response_defaults(self): + """Test MFA required response with default values.""" + response = auth_schema.MFARequiredResponse(username="testuser") + assert response.mfa_required is True + assert response.username == "testuser" + assert response.message == "MFA verification required" + + def test_mfa_required_response_custom_message(self): + """Test MFA required response with custom message.""" + response = auth_schema.MFARequiredResponse( + username="testuser", message="Custom MFA message" + ) + assert response.mfa_required is True + assert response.message == "Custom MFA message" + + def test_mfa_required_response_explicit_false(self): + """Test MFA required response with explicit False.""" + response = auth_schema.MFARequiredResponse( + mfa_required=False, username="testuser" + ) + assert response.mfa_required is False + + +class TestPendingMFALogin: + """Tests for PendingMFALogin class.""" + + def test_add_and_get_pending_login(self): + """Test adding and retrieving pending MFA login.""" + store = auth_schema.PendingMFALogin() + store.add_pending_login("testuser", 123) + assert store.get_pending_login("testuser") == 123 + + def test_get_pending_login_not_found(self): + """Test getting non-existent pending login returns None.""" + store = auth_schema.PendingMFALogin() + assert store.get_pending_login("nonexistent") is None + + def test_has_pending_login(self): + """Test checking if username has pending login.""" + store = auth_schema.PendingMFALogin() + store.add_pending_login("testuser", 123) + assert store.has_pending_login("testuser") is True + assert store.has_pending_login("nonexistent") is False + + def test_delete_pending_login(self): + """Test deleting pending login.""" + store = auth_schema.PendingMFALogin() + store.add_pending_login("testuser", 123) + store.delete_pending_login("testuser") + assert store.get_pending_login("testuser") is None + + def test_delete_nonexistent_pending_login(self): + """Test deleting non-existent pending login doesn't raise error.""" + store = auth_schema.PendingMFALogin() + store.delete_pending_login("nonexistent") # Should not raise + + def test_clear_all(self): + """Test clearing all pending logins.""" + store = auth_schema.PendingMFALogin() + store.add_pending_login("user1", 1) + store.add_pending_login("user2", 2) + store.clear_all() + assert store.get_pending_login("user1") is None + assert store.get_pending_login("user2") is None + + def test_is_not_locked_out_initially(self): + """Test user is not locked out initially.""" + store = auth_schema.PendingMFALogin() + assert store.is_locked_out("testuser") is False + + def test_lockout_after_5_failures(self): + """Test 5-minute lockout after 5 failed attempts.""" + store = auth_schema.PendingMFALogin() + for _ in range(5): + store.record_failed_attempt("testuser") + assert store.is_locked_out("testuser") is True + lockout_time = store.get_lockout_time("testuser") + assert lockout_time is not None + assert lockout_time > datetime.now(timezone.utc) + + def test_lockout_after_10_failures(self): + """Test 30-minute lockout after 10 failed attempts.""" + store = auth_schema.PendingMFALogin() + for _ in range(10): + store.record_failed_attempt("testuser") + assert store.is_locked_out("testuser") is True + + def test_lockout_after_15_failures(self): + """Test 2-hour lockout after 15 failed attempts.""" + store = auth_schema.PendingMFALogin() + for _ in range(15): + store.record_failed_attempt("testuser") + assert store.is_locked_out("testuser") is True + + def test_failed_attempt_count_doesnt_increment_while_locked(self): + """Test failed attempt counter doesn't increment during lockout.""" + store = auth_schema.PendingMFALogin() + for _ in range(5): + store.record_failed_attempt("testuser") + # Try to increment during lockout + count_before = store.record_failed_attempt("testuser") + count_after = store.record_failed_attempt("testuser") + assert count_before == count_after + + def test_reset_failed_attempts(self): + """Test resetting failed attempts on successful verification.""" + store = auth_schema.PendingMFALogin() + store.record_failed_attempt("testuser") + store.record_failed_attempt("testuser") + store.reset_failed_attempts("testuser") + assert store.is_locked_out("testuser") is False + assert store.get_lockout_time("testuser") is None + + def test_get_lockout_time_returns_none_when_not_locked(self): + """Test get_lockout_time returns None when user not locked.""" + store = auth_schema.PendingMFALogin() + assert store.get_lockout_time("testuser") is None + + def test_clear_all_clears_failed_attempts(self): + """Test clear_all() clears both pending logins and failed attempts.""" + store = auth_schema.PendingMFALogin() + store.add_pending_login("testuser", 123) + for _ in range(5): + store.record_failed_attempt("testuser") + store.clear_all() + assert store.is_locked_out("testuser") is False + + +class TestFailedLoginAttempts: + """Tests for FailedLoginAttempts class.""" + + def test_is_not_locked_out_initially(self): + """Test user is not locked out initially.""" + tracker = auth_schema.FailedLoginAttempts() + assert tracker.is_locked_out("testuser") is False + + def test_lockout_after_5_failures(self): + """Test 5-minute lockout after 5 failed login attempts.""" + tracker = auth_schema.FailedLoginAttempts() + for _ in range(5): + tracker.record_failed_attempt("testuser") + assert tracker.is_locked_out("testuser") is True + lockout_time = tracker.get_lockout_time("testuser") + assert lockout_time is not None + assert lockout_time > datetime.now(timezone.utc) + + def test_lockout_after_10_failures(self): + """Test 30-minute lockout after 10 failed login attempts.""" + tracker = auth_schema.FailedLoginAttempts() + for _ in range(10): + tracker.record_failed_attempt("testuser") + assert tracker.is_locked_out("testuser") is True + + def test_lockout_after_20_failures(self): + """Test 24-hour lockout after 20 failed login attempts.""" + tracker = auth_schema.FailedLoginAttempts() + for _ in range(20): + tracker.record_failed_attempt("testuser") + assert tracker.is_locked_out("testuser") is True + + def test_failed_attempt_count_returns_correctly(self): + """Test record_failed_attempt returns current count.""" + tracker = auth_schema.FailedLoginAttempts() + count1 = tracker.record_failed_attempt("testuser") + count2 = tracker.record_failed_attempt("testuser") + count3 = tracker.record_failed_attempt("testuser") + assert count1 == 1 + assert count2 == 2 + assert count3 == 3 + + def test_failed_attempt_count_doesnt_increment_while_locked(self): + """Test failed attempt counter doesn't increment during lockout.""" + tracker = auth_schema.FailedLoginAttempts() + for _ in range(5): + tracker.record_failed_attempt("testuser") + # Try to increment during lockout + count_before = tracker.record_failed_attempt("testuser") + count_after = tracker.record_failed_attempt("testuser") + assert count_before == count_after + + def test_reset_attempts(self): + """Test resetting failed attempts on successful login.""" + tracker = auth_schema.FailedLoginAttempts() + tracker.record_failed_attempt("testuser") + tracker.record_failed_attempt("testuser") + tracker.reset_attempts("testuser") + assert tracker.is_locked_out("testuser") is False + assert tracker.get_lockout_time("testuser") is None + + def test_get_lockout_time_returns_none_when_not_locked(self): + """Test get_lockout_time returns None when user not locked.""" + tracker = auth_schema.FailedLoginAttempts() + assert tracker.get_lockout_time("testuser") is None + + def test_clear_all(self): + """Test clearing all failed attempt records.""" + tracker = auth_schema.FailedLoginAttempts() + tracker.record_failed_attempt("user1") + tracker.record_failed_attempt("user2") + tracker.clear_all() + assert tracker.is_locked_out("user1") is False + assert tracker.is_locked_out("user2") is False + + def test_different_users_tracked_independently(self): + """Test different users have independent failed attempt tracking.""" + tracker = auth_schema.FailedLoginAttempts() + for _ in range(3): + tracker.record_failed_attempt("user1") + for _ in range(5): + tracker.record_failed_attempt("user2") + assert tracker.is_locked_out("user1") is False + assert tracker.is_locked_out("user2") is True + + +class TestDependencyFunctions: + """Tests for dependency injection functions.""" + + def test_get_pending_mfa_store(self): + """Test get_pending_mfa_store returns PendingMFALogin instance.""" + store = auth_schema.get_pending_mfa_store() + assert isinstance(store, auth_schema.PendingMFALogin) + + def test_get_pending_mfa_store_returns_singleton(self): + """Test get_pending_mfa_store returns same instance.""" + store1 = auth_schema.get_pending_mfa_store() + store2 = auth_schema.get_pending_mfa_store() + assert store1 is store2 + + def test_get_failed_login_attempts(self): + """Test get_failed_login_attempts returns FailedLoginAttempts instance.""" + tracker = auth_schema.get_failed_login_attempts() + assert isinstance(tracker, auth_schema.FailedLoginAttempts) + + def test_get_failed_login_attempts_returns_singleton(self): + """Test get_failed_login_attempts returns same instance.""" + tracker1 = auth_schema.get_failed_login_attempts() + tracker2 = auth_schema.get_failed_login_attempts() + assert tracker1 is tracker2 diff --git a/backend/tests/auth/test_security.py b/backend/tests/auth/test_security.py new file mode 100644 index 000000000..ec8a0883d --- /dev/null +++ b/backend/tests/auth/test_security.py @@ -0,0 +1,287 @@ +"""Tests for auth.security module.""" + +import pytest +from fastapi import HTTPException +from fastapi.security import SecurityScopes + +import auth.security as auth_security +import auth.token_manager as auth_token_manager + + +class TestGetToken: + """Test get_token function for token retrieval logic.""" + + def test_get_access_token_from_header(self): + """Test access token retrieval from Authorization header.""" + result = auth_security.get_token( + non_cookie_token="test_token", + cookie_token=None, + client_type="web", + token_type="access", + ) + assert result == "test_token" + + def test_get_access_token_missing_raises_error(self): + """Test that missing access token raises 401.""" + with pytest.raises(HTTPException) as exc_info: + auth_security.get_token( + non_cookie_token=None, + cookie_token=None, + client_type="web", + token_type="access", + ) + assert exc_info.value.status_code == 401 + assert "Access token missing" in exc_info.value.detail + + def test_get_refresh_token_from_cookie_for_web(self): + """Test refresh token retrieval from cookie for web client.""" + result = auth_security.get_token( + non_cookie_token=None, + cookie_token="refresh_cookie_token", + client_type="web", + token_type="refresh", + ) + assert result == "refresh_cookie_token" + + def test_get_refresh_token_from_header_for_mobile(self): + """Test refresh token retrieval from header for mobile client.""" + result = auth_security.get_token( + non_cookie_token="refresh_header_token", + cookie_token=None, + client_type="mobile", + token_type="refresh", + ) + assert result == "refresh_header_token" + + def test_get_refresh_token_missing_for_web_raises_error(self): + """Test that missing refresh token from cookie for web raises 401.""" + with pytest.raises(HTTPException) as exc_info: + auth_security.get_token( + non_cookie_token=None, + cookie_token=None, + client_type="web", + token_type="refresh", + ) + assert exc_info.value.status_code == 401 + assert "Refresh token missing from cookie" in exc_info.value.detail + + def test_get_refresh_token_missing_for_mobile_raises_error(self): + """Test that missing refresh token from header for mobile raises 401.""" + with pytest.raises(HTTPException) as exc_info: + auth_security.get_token( + non_cookie_token=None, + cookie_token=None, + client_type="mobile", + token_type="refresh", + ) + assert exc_info.value.status_code == 401 + assert ( + "Refresh token missing from Authorization header" in exc_info.value.detail + ) + + def test_invalid_token_type_raises_error(self): + """Test that invalid token type raises 403.""" + with pytest.raises(HTTPException) as exc_info: + auth_security.get_token( + non_cookie_token="test_token", + cookie_token=None, + client_type="web", + token_type="invalid_type", + ) + assert exc_info.value.status_code == 403 + assert "Invalid client type or token type" in exc_info.value.detail + + +class TestAccessTokenValidation: + """Test access token validation functions.""" + + def test_validate_access_token_success(self, token_manager, sample_user_read): + """Test successful access token validation.""" + # Create a valid token + _, access_token = token_manager.create_token( + "session-id", sample_user_read, auth_token_manager.TokenType.ACCESS + ) + + # Should not raise an exception + try: + auth_security.validate_access_token(access_token, token_manager) + except HTTPException: + pytest.fail("Valid token should not raise HTTPException") + + def test_validate_access_token_with_expired_token(self, token_manager): + """Test that expired token raises HTTPException.""" + expired_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzaWQiOiJzZXNzaW9uLWlkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwic3ViIjoxLCJzY29wZSI6WyJwcm9maWxlIl0sImlhdCI6MTc1OTk1MzE4NSwibmJmIjoxNzU5OTUzMTg1LCJleHAiOjE3NTk5NTQwODUsImp0aSI6Ijc5ZjY0MmVkLTQ3M2QtNDEwZi1hYzI1LTIyNjEwNTlhMzg2MiJ9.VSizGzvIIi_EJYD_YmfZBEBE_9aJbhLW-25cD1kEOeM" + + with pytest.raises(HTTPException) as exc_info: + auth_security.validate_access_token(expired_token, token_manager) + assert exc_info.value.status_code == 401 + + def test_validate_access_token_with_invalid_token(self, token_manager): + """Test that invalid token raises HTTPException.""" + invalid_token = "invalid.token.here" + + with pytest.raises(HTTPException) as exc_info: + auth_security.validate_access_token(invalid_token, token_manager) + assert exc_info.value.status_code == 401 + + +class TestGetSubFromAccessToken: + """Test extracting user ID from access token.""" + + def test_get_sub_from_valid_token(self, token_manager, sample_user_read): + """Test extracting user ID from valid access token.""" + _, access_token = token_manager.create_token( + "session-id", sample_user_read, auth_token_manager.TokenType.ACCESS + ) + + sub = auth_security.get_sub_from_access_token(access_token, token_manager) + assert sub == sample_user_read.id + assert isinstance(sub, int) + + def test_get_sub_from_invalid_token_raises_error(self, token_manager): + """Test that invalid token raises HTTPException.""" + invalid_token = "invalid.token.here" + + with pytest.raises(HTTPException) as exc_info: + auth_security.get_sub_from_access_token(invalid_token, token_manager) + assert exc_info.value.status_code == 401 + + +class TestGetSidFromAccessToken: + """Test extracting session ID from access token.""" + + def test_get_sid_from_valid_token(self, token_manager, sample_user_read): + """Test extracting session ID from valid access token.""" + session_id = "test-session-123" + _, access_token = token_manager.create_token( + session_id, sample_user_read, auth_token_manager.TokenType.ACCESS + ) + + sid = auth_security.get_sid_from_access_token(access_token, token_manager) + assert sid == session_id + assert isinstance(sid, str) + + def test_get_sid_from_invalid_token_raises_error(self, token_manager): + """Test that invalid token raises HTTPException.""" + invalid_token = "invalid.token.here" + + with pytest.raises(HTTPException) as exc_info: + auth_security.get_sid_from_access_token(invalid_token, token_manager) + assert exc_info.value.status_code == 401 + + +class TestRefreshTokenValidation: + """Test refresh token validation functions.""" + + def test_validate_refresh_token_success(self, token_manager, sample_user_read): + """Test successful refresh token validation.""" + _, refresh_token = token_manager.create_token( + "session-id", sample_user_read, auth_token_manager.TokenType.REFRESH + ) + + # Should not raise an exception + try: + auth_security.validate_refresh_token(refresh_token, token_manager) + except HTTPException: + pytest.fail("Valid refresh token should not raise HTTPException") + + def test_validate_refresh_token_with_expired_token(self, token_manager): + """Test that expired refresh token raises HTTPException.""" + expired_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzaWQiOiJzZXNzaW9uLWlkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwic3ViIjoxLCJzY29wZSI6WyJwcm9maWxlIl0sImlhdCI6MTc1OTk1MzE4NSwibmJmIjoxNzU5OTUzMTg1LCJleHAiOjE3NTk5NTQwODUsImp0aSI6Ijc5ZjY0MmVkLTQ3M2QtNDEwZi1hYzI1LTIyNjEwNTlhMzg2MiJ9.VSizGzvIIi_EJYD_YmfZBEBE_9aJbhLW-25cD1kEOeM" + + with pytest.raises(HTTPException) as exc_info: + auth_security.validate_refresh_token(expired_token, token_manager) + assert exc_info.value.status_code == 401 + + +class TestGetSubFromRefreshToken: + """Test extracting user ID from refresh token.""" + + def test_get_sub_from_valid_refresh_token(self, token_manager, sample_user_read): + """Test extracting user ID from valid refresh token.""" + _, refresh_token = token_manager.create_token( + "session-id", sample_user_read, auth_token_manager.TokenType.REFRESH + ) + + sub = auth_security.get_sub_from_refresh_token(refresh_token, token_manager) + assert sub == sample_user_read.id + assert isinstance(sub, int) + + +class TestGetSidFromRefreshToken: + """Test extracting session ID from refresh token.""" + + def test_get_sid_from_valid_refresh_token(self, token_manager, sample_user_read): + """Test extracting session ID from valid refresh token.""" + session_id = "test-session-456" + _, refresh_token = token_manager.create_token( + session_id, sample_user_read, auth_token_manager.TokenType.REFRESH + ) + + sid = auth_security.get_sid_from_refresh_token(refresh_token, token_manager) + assert sid == session_id + assert isinstance(sid, str) + + +class TestCheckScopes: + """Test scope validation function.""" + + def test_check_scopes_with_valid_scopes(self, token_manager, sample_user_read): + """Test that valid scopes pass validation.""" + _, access_token = token_manager.create_token( + "session-id", sample_user_read, auth_token_manager.TokenType.ACCESS + ) + + security_scopes = SecurityScopes(scopes=["profile", "users:read"]) + + # Should not raise an exception + try: + auth_security.check_scopes(access_token, token_manager, security_scopes) + except HTTPException: + pytest.fail("Valid scopes should not raise HTTPException") + + def test_check_scopes_with_missing_scope(self, token_manager, sample_user_read): + """Test that missing required scope raises 403.""" + _, access_token = token_manager.create_token( + "session-id", sample_user_read, auth_token_manager.TokenType.ACCESS + ) + + # Request a scope that the user doesn't have + security_scopes = SecurityScopes(scopes=["admin:write"]) + + with pytest.raises(HTTPException) as exc_info: + auth_security.check_scopes(access_token, token_manager, security_scopes) + assert exc_info.value.status_code == 403 + assert "Missing permissions" in exc_info.value.detail + + def test_check_scopes_with_no_required_scopes( + self, token_manager, sample_user_read + ): + """Test that no required scopes passes validation.""" + _, access_token = token_manager.create_token( + "session-id", sample_user_read, auth_token_manager.TokenType.ACCESS + ) + + security_scopes = SecurityScopes(scopes=[]) + + # Should not raise an exception + try: + auth_security.check_scopes(access_token, token_manager, security_scopes) + except HTTPException: + pytest.fail("Empty required scopes should not raise HTTPException") + + +class TestGetAndReturnTokens: + """Test simple token return functions.""" + + def test_get_and_return_access_token(self): + """Test that access token is returned unchanged.""" + test_token = "test_access_token" + result = auth_security.get_and_return_access_token(test_token) + assert result == test_token + + def test_get_and_return_refresh_token(self): + """Test that refresh token is returned unchanged.""" + test_token = "test_refresh_token" + result = auth_security.get_and_return_refresh_token(test_token) + assert result == test_token diff --git a/backend/tests/auth/test_utils.py b/backend/tests/auth/test_utils.py new file mode 100644 index 000000000..a5f70c2ea --- /dev/null +++ b/backend/tests/auth/test_utils.py @@ -0,0 +1,398 @@ +"""Tests for auth.utils module.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch +import pytest +from fastapi import HTTPException, Response + +import auth.utils as auth_utils +import auth.token_manager as auth_token_manager +import users.user.schema as user_schema + + +class TestAuthenticateUser: + """Test user authentication function.""" + + def test_authenticate_user_success( + self, password_hasher, mock_db, sample_user_read + ): + """Test successful user authentication.""" + # Arrange + username = "testuser" + password = "TestPassword123!" + + # Create a user with hashed password + hashed_password = password_hasher.hash_password(password) + mock_user = MagicMock() + mock_user.id = sample_user_read.id + mock_user.password = hashed_password + mock_user.username = username + + # Mock the CRUD function to return our user + with patch("auth.utils.users_crud.authenticate_user", return_value=mock_user): + # Act + result = auth_utils.authenticate_user( + username, password, password_hasher, mock_db + ) + + # Assert + assert result == mock_user + + def test_authenticate_user_invalid_username(self, password_hasher, mock_db): + """Test authentication with invalid username raises 401.""" + with patch("auth.utils.users_crud.authenticate_user", return_value=None): + with pytest.raises(HTTPException) as exc_info: + auth_utils.authenticate_user( + "nonexistent", "password", password_hasher, mock_db + ) + assert exc_info.value.status_code == 401 + assert "Invalid username" in exc_info.value.detail + + def test_authenticate_user_invalid_password(self, password_hasher, mock_db): + """Test authentication with invalid password raises 401.""" + # Arrange + username = "testuser" + correct_password = "CorrectPassword123!" + wrong_password = "WrongPassword123!" + + hashed_password = password_hasher.hash_password(correct_password) + mock_user = MagicMock() + mock_user.password = hashed_password + + with patch("auth.utils.users_crud.authenticate_user", return_value=mock_user): + with pytest.raises(HTTPException) as exc_info: + auth_utils.authenticate_user( + username, wrong_password, password_hasher, mock_db + ) + assert exc_info.value.status_code == 401 + assert "Invalid password" in exc_info.value.detail + + def test_authenticate_user_updates_password_hash_if_needed( + self, password_hasher, mock_db, sample_user_read + ): + """Test that password hash is updated if algorithm changed.""" + # Arrange + username = "testuser" + password = "TestPassword123!" + + # Use a different hasher to simulate old hash + from pwdlib.hashers.bcrypt import BcryptHasher + + old_hasher_instance = BcryptHasher() + old_hash = old_hasher_instance.hash(password) + + mock_user = MagicMock() + mock_user.id = sample_user_read.id + mock_user.password = old_hash + mock_user.username = username + + with patch("auth.utils.users_crud.authenticate_user", return_value=mock_user): + with patch("auth.utils.users_crud.edit_user_password") as mock_edit: + # Act + result = auth_utils.authenticate_user( + username, password, password_hasher, mock_db + ) + + # Assert + assert result == mock_user + # Password should be updated since we're using different hasher + mock_edit.assert_called_once() + + +class TestCreateTokens: + """Test token creation function.""" + + def test_create_tokens_generates_all_tokens(self, token_manager, sample_user_read): + """Test that create_tokens generates all required tokens.""" + # Act + ( + session_id, + access_token_exp, + access_token, + refresh_token_exp, + refresh_token, + csrf_token, + ) = auth_utils.create_tokens(sample_user_read, token_manager) + + # Assert + assert session_id is not None + assert isinstance(session_id, str) + assert len(session_id) > 0 + + assert access_token_exp is not None + assert isinstance(access_token_exp, datetime) + assert access_token_exp > datetime.now(timezone.utc) + + assert access_token is not None + assert isinstance(access_token, str) + assert len(access_token) > 0 + + assert refresh_token_exp is not None + assert isinstance(refresh_token_exp, datetime) + assert refresh_token_exp > datetime.now(timezone.utc) + + assert refresh_token is not None + assert isinstance(refresh_token, str) + assert len(refresh_token) > 0 + + assert csrf_token is not None + assert isinstance(csrf_token, str) + assert len(csrf_token) >= 32 + + def test_create_tokens_with_custom_session_id( + self, token_manager, sample_user_read + ): + """Test that create_tokens uses provided session ID.""" + # Arrange + custom_session_id = "custom-session-123" + + # Act + ( + session_id, + _, + _, + _, + _, + _, + ) = auth_utils.create_tokens(sample_user_read, token_manager, custom_session_id) + + # Assert + assert session_id == custom_session_id + + def test_create_tokens_refresh_expires_after_access( + self, token_manager, sample_user_read + ): + """Test that refresh token expires after access token.""" + # Act + ( + _, + access_token_exp, + _, + refresh_token_exp, + _, + _, + ) = auth_utils.create_tokens(sample_user_read, token_manager) + + # Assert + assert refresh_token_exp > access_token_exp + + def test_create_tokens_generates_unique_tokens( + self, token_manager, sample_user_read + ): + """Test that multiple calls generate unique tokens.""" + # Act + (_, _, access_token1, _, refresh_token1, csrf_token1) = ( + auth_utils.create_tokens(sample_user_read, token_manager) + ) + (_, _, access_token2, _, refresh_token2, csrf_token2) = ( + auth_utils.create_tokens(sample_user_read, token_manager) + ) + + # Assert + assert access_token1 != access_token2 + assert refresh_token1 != refresh_token2 + assert csrf_token1 != csrf_token2 + + +class TestCompleteLogin: + """Test complete_login function.""" + + def test_complete_login_for_web_client( + self, password_hasher, token_manager, mock_db, sample_user_read, mock_request + ): + """Test complete_login for web client sets cookies and returns tokens.""" + # Arrange + response = Response() + client_type = "web" + + with patch("auth.utils.session_utils.create_session"): + # Act + result = auth_utils.complete_login( + response, + mock_request, + sample_user_read, + client_type, + password_hasher, + token_manager, + mock_db, + ) + + # Assert + assert "session_id" in result + assert "access_token" in result + assert "csrf_token" in result + assert "token_type" in result + assert "expires_in" in result + + assert result["token_type"] == "bearer" + assert isinstance(result["expires_in"], int) + + # Check that refresh token cookie was set + assert "endurain_refresh_token" in response.headers.get("set-cookie", "") + + def test_complete_login_for_mobile_client( + self, password_hasher, token_manager, mock_db, sample_user_read, mock_request + ): + """Test complete_login for mobile client returns tokens.""" + # Arrange + response = Response() + client_type = "mobile" + + with patch("auth.utils.session_utils.create_session"): + # Act + result = auth_utils.complete_login( + response, + mock_request, + sample_user_read, + client_type, + password_hasher, + token_manager, + mock_db, + ) + + # Assert + assert "session_id" in result + assert "access_token" in result + assert "csrf_token" in result + assert result["token_type"] == "bearer" + + def test_complete_login_creates_session( + self, password_hasher, token_manager, mock_db, sample_user_read, mock_request + ): + """Test that complete_login creates a session in the database.""" + # Arrange + response = Response() + client_type = "web" + + with patch("auth.utils.session_utils.create_session") as mock_create_session: + # Act + result = auth_utils.complete_login( + response, + mock_request, + sample_user_read, + client_type, + password_hasher, + token_manager, + mock_db, + ) + + # Assert + mock_create_session.assert_called_once() + call_args = mock_create_session.call_args + + # Verify session_id matches + assert call_args[0][0] == result["session_id"] + # Verify user was passed + assert call_args[0][1] == sample_user_read + # Verify request was passed + assert call_args[0][2] == mock_request + + def test_complete_login_invalid_client_type_raises_error( + self, password_hasher, token_manager, mock_db, sample_user_read, mock_request + ): + """Test that invalid client type raises 403.""" + # Arrange + response = Response() + invalid_client_type = "invalid" + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + auth_utils.complete_login( + response, + mock_request, + sample_user_read, + invalid_client_type, + password_hasher, + token_manager, + mock_db, + ) + assert exc_info.value.status_code == 403 + assert "Invalid client type" in exc_info.value.detail + + def test_complete_login_sets_secure_cookie_for_https( + self, password_hasher, token_manager, mock_db, sample_user_read, mock_request + ): + """Test that secure flag is set on cookie when using HTTPS.""" + # Arrange + response = Response() + client_type = "web" + + with patch("auth.utils.session_utils.create_session"): + with patch.dict("os.environ", {"FRONTEND_PROTOCOL": "https"}): + # Act + auth_utils.complete_login( + response, + mock_request, + sample_user_read, + client_type, + password_hasher, + token_manager, + mock_db, + ) + + # Assert + set_cookie_header = response.headers.get("set-cookie", "") + assert "secure" in set_cookie_header.lower() + + def test_complete_login_cookie_attributes( + self, password_hasher, token_manager, mock_db, sample_user_read, mock_request + ): + """Test that refresh token cookie has correct security attributes.""" + # Arrange + response = Response() + client_type = "web" + + with patch("auth.utils.session_utils.create_session"): + # Act + auth_utils.complete_login( + response, + mock_request, + sample_user_read, + client_type, + password_hasher, + token_manager, + mock_db, + ) + + # Assert + set_cookie_header = response.headers.get("set-cookie", "") + assert "endurain_refresh_token" in set_cookie_header + assert "httponly" in set_cookie_header.lower() + assert "samesite=strict" in set_cookie_header.lower() + assert "path=/" in set_cookie_header.lower() + + def test_complete_login_returns_different_tokens_on_each_call( + self, password_hasher, token_manager, mock_db, sample_user_read, mock_request + ): + """Test that each login generates unique tokens.""" + # Arrange + response1 = Response() + response2 = Response() + client_type = "web" + + with patch("auth.utils.session_utils.create_session"): + # Act + result1 = auth_utils.complete_login( + response1, + mock_request, + sample_user_read, + client_type, + password_hasher, + token_manager, + mock_db, + ) + + result2 = auth_utils.complete_login( + response2, + mock_request, + sample_user_read, + client_type, + password_hasher, + token_manager, + mock_db, + ) + + # Assert + assert result1["session_id"] != result2["session_id"] + assert result1["access_token"] != result2["access_token"] + assert result1["csrf_token"] != result2["csrf_token"] diff --git a/backend/tests/session/__init__.py b/backend/tests/session/__init__.py deleted file mode 100644 index f9c3fe443..000000000 --- a/backend/tests/session/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests for Endurain session module backend application. -""" diff --git a/backend/tests/session/test_router.py b/backend/tests/session/test_router.py deleted file mode 100644 index fd5950259..000000000 --- a/backend/tests/session/test_router.py +++ /dev/null @@ -1,994 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from fastapi import HTTPException, status - - -class TestLoginEndpointSecurity: - """ - Test suite for verifying the security and behavior of the login endpoint. - - This class contains tests that cover various scenarios for the login endpoint, including: - - Successful login without Multi-Factor Authentication (MFA) for different client types. - - Login attempts when MFA is required, ensuring the correct response and MFA flow. - - Handling of invalid client types, ensuring forbidden access is enforced. - - Login attempts with invalid credentials, ensuring proper error handling. - - Login attempts with inactive users, ensuring access is denied as expected. - - Each test uses extensive mocking to simulate authentication, user activity checks, MFA status, and session/token creation, allowing for isolated and reliable testing of the endpoint's logic and security requirements. - """ - - @pytest.mark.parametrize( - "client_type, expected_status, returns_tokens", - [ - ("web", status.HTTP_200_OK, False), - ("mobile", status.HTTP_200_OK, True), - ], - ) - def test_login_without_mfa( - self, - fast_api_app, - fast_api_client, - sample_user_read, - client_type, - expected_status, - returns_tokens, - ): - """ - Test the login endpoint behavior when Multi-Factor Authentication (MFA) is not enabled for the user. - - This test verifies that: - - The login process completes successfully without requiring MFA. - - The correct response is returned based on whether tokens are expected. - - The appropriate authentication, user activity, and MFA checks are patched and simulated. - - The fake store is not called during the process. - - Args: - fast_api_app: The FastAPI application instance under test. - fast_api_client: The test client for making HTTP requests to the FastAPI app. - sample_user_read: A sample user object returned by the authentication mock. - client_type: The type of client making the request (used in headers and app state). - expected_status: The expected HTTP status code of the response. - returns_tokens: Boolean indicating if the endpoint should return tokens or just a session ID. - """ - fast_api_app.state._client_type = client_type - with patch("session.router.auth_utils.authenticate_user") as mock_auth, patch( - "session.router.users_utils.check_user_is_active" - ), patch( - "session.router.profile_utils.is_mfa_enabled_for_user" - ) as mock_mfa, patch( - "session.router.auth_utils.complete_login" - ) as mock_complete: - mock_auth.return_value = sample_user_read - mock_mfa.return_value = False - mock_complete.return_value = ( - {"session_id": "test-session"} - if not returns_tokens - else { - "access_token": "token", - "refresh_token": "refresh", - "session_id": "session", - "token_type": "Bearer", - "expires_in": 900, - } - ) - - resp = fast_api_client.post( - "/auth/login", - data={"username": "testuser", "password": "secret"}, - headers={"X-Client-Type": client_type}, - ) - assert resp.status_code == expected_status - body = resp.json() - if returns_tokens: - assert body["access_token"] == "token" - assert body["refresh_token"] == "refresh" - assert body["session_id"] == "session" - assert body["token_type"] == "Bearer" - assert isinstance(body["expires_in"], int) - else: - assert body == {"session_id": "test-session"} - assert fast_api_app.state.fake_store.calls == [] - - @pytest.mark.parametrize( - "client_type, expected_status", - [ - ("web", status.HTTP_202_ACCEPTED), - ("mobile", status.HTTP_200_OK), - ], - ) - def test_login_with_mfa_required( - self, - fast_api_app, - fast_api_client, - sample_user_read, - client_type, - expected_status, - ): - """ - Test the login endpoint when Multi-Factor Authentication (MFA) is required. - - This test verifies that when a user with MFA enabled attempts to log in, - the API responds with the correct status code and indicates that MFA is required. - It mocks the authentication, user activity check, and MFA status check to simulate - the scenario where MFA is enabled for the user. - - Args: - fast_api_app: The FastAPI application instance under test. - fast_api_client: The test client for making HTTP requests to the FastAPI app. - sample_user_read: A sample user object returned by the authentication mock. - client_type: The type of client making the request (used in headers). - expected_status: The expected HTTP status code for the response. - - Asserts: - - The response status code matches the expected status. - - The response JSON contains 'mfa_required' set to True. - - The response JSON contains the correct 'username'. - - The fake_store in the app state records the correct call. - """ - fast_api_app.state._client_type = client_type - with patch("session.router.auth_utils.authenticate_user") as mock_auth, patch( - "session.router.users_utils.check_user_is_active" - ), patch("session.router.profile_utils.is_mfa_enabled_for_user") as mock_mfa: - mock_auth.return_value = sample_user_read - mock_mfa.return_value = True - - resp = fast_api_client.post( - "/auth/login", - data={"username": "testuser", "password": "secret"}, - headers={"X-Client-Type": client_type}, - ) - assert resp.status_code == expected_status - body = resp.json() - assert body["mfa_required"] is True - assert body["username"] == "testuser" - assert fast_api_app.state.fake_store.calls == [ - ("testuser", sample_user_read.id) - ] - - def test_invalid_client_type_forbidden( - self, fast_api_app, fast_api_client, sample_user_read - ): - """ - Test that a login attempt with an invalid client type returns a 403 Forbidden response. - - This test sets the application's client type to "desktop" and mocks the authentication, - user activity check, MFA status, token creation, and session creation utilities. It then - sends a POST request to the "/auth/login" endpoint with the "X-Client-Type" header set to "desktop". - The test asserts that the response status code is 403 Forbidden and the response detail - indicates an invalid client type. - - Args: - fast_api_app: The FastAPI application instance. - fast_api_client: The test client for making HTTP requests. - sample_user_read: A sample user object returned by the authentication mock. - """ - fast_api_app.state._client_type = "desktop" - with patch("session.router.auth_utils.authenticate_user") as mock_auth, patch( - "session.router.users_utils.check_user_is_active" - ), patch( - "session.router.profile_utils.is_mfa_enabled_for_user" - ) as mock_mfa, patch( - "session.router.auth_utils.create_tokens" - ) as mock_create_tokens, patch( - "session.router.session_utils.create_session" - ) as mock_create_session: - mock_auth.return_value = sample_user_read - mock_mfa.return_value = False - - mock_create_tokens.return_value = ( - "sid", - object(), - "acc", - object(), - "ref", - "csrf", - ) - mock_create_session.return_value = None - - resp = fast_api_client.post( - "/auth/login", - data={"username": "x", "password": "y"}, - headers={"X-Client-Type": "desktop"}, - ) - - assert resp.status_code == status.HTTP_403_FORBIDDEN - assert resp.json()["detail"] == "Invalid client type" - - def test_login_with_invalid_credentials(self, password_hasher, mock_db): - """ - Test that the login endpoint raises an HTTPException with status code 401 - when invalid credentials are provided. Mocks the authenticate_user function - to simulate authentication failure and verifies that the exception is raised - with the correct status code and detail. - """ - with patch("session.router.auth_utils.authenticate_user") as mock_auth: - mock_auth.side_effect = HTTPException( - status_code=401, detail="Invalid username" - ) - - with pytest.raises(HTTPException) as exc_info: - mock_auth("invalid", "password", password_hasher, mock_db) - - assert exc_info.value.status_code == 401 - - def test_login_with_inactive_user(self, sample_inactive_user): - """ - Test that the login endpoint raises an HTTPException with status code 403 - when attempting to authenticate an inactive user. - - This test mocks the authentication and user activity check utilities to simulate - the scenario where a user is found but is inactive. It asserts that the correct - exception is raised with the expected status code. - """ - with patch("session.router.auth_utils.authenticate_user") as mock_auth: - with patch("session.router.users_utils.check_user_is_active") as mock_check: - mock_auth.return_value = sample_inactive_user - mock_check.side_effect = HTTPException( - status_code=403, detail="User is inactive" - ) - - with pytest.raises(HTTPException) as exc_info: - mock_check(sample_inactive_user) - - assert exc_info.value.status_code == 403 - - -class TestMFAVerifyEndpoint: - """ - Test suite for the MFA verification endpoint (/auth/mfa/verify). - - This class contains tests that cover various scenarios for the MFA verification endpoint, including: - - Successful MFA verification and login for different client types (web and mobile). - - Handling of cases where no pending MFA login is found. - - Handling of invalid MFA codes. - - Handling of cases where the user is not found after MFA verification. - - Handling of inactive users after MFA verification. - - Handling of invalid client types during MFA verification. - - Each test uses mocking to simulate the MFA verification flow, user lookup, and session creation. - """ - - @pytest.mark.parametrize( - "client_type, expected_status, returns_tokens", - [ - ("web", status.HTTP_200_OK, False), - ("mobile", status.HTTP_200_OK, True), - ], - ) - def test_mfa_verify_success( - self, - fast_api_app, - fast_api_client, - sample_user_read, - client_type, - expected_status, - returns_tokens, - ): - """ - Test successful MFA verification and login completion. - - This test verifies that when a valid MFA code is provided for a pending login, - the API successfully completes the login process and returns appropriate tokens - based on the client type. - - Args: - fast_api_app: The FastAPI application instance under test. - fast_api_client: The test client for making HTTP requests. - sample_user_read: A sample user object. - client_type: The type of client ("web" or "mobile"). - expected_status: The expected HTTP status code. - returns_tokens: Boolean indicating if tokens should be returned. - """ - fast_api_app.state._client_type = client_type - - # Setup pending MFA login - pending_store = fast_api_app.state.fake_store - pending_store._store = {"testuser": sample_user_read.id} - - with patch( - "session.router.profile_utils.verify_user_mfa" - ) as mock_verify_mfa, patch( - "session.router.users_crud.get_user_by_id" - ) as mock_get_user, patch( - "session.router.users_utils.check_user_is_active" - ), patch( - "session.router.auth_utils.complete_login" - ) as mock_complete: - mock_verify_mfa.return_value = True - mock_get_user.return_value = sample_user_read - mock_complete.return_value = ( - {"session_id": "test-session"} - if not returns_tokens - else { - "access_token": "token", - "refresh_token": "refresh", - "session_id": "session", - "token_type": "Bearer", - "expires_in": 900, - } - ) - - resp = fast_api_client.post( - "/auth/mfa/verify", - json={"username": "testuser", "mfa_code": "123456"}, - headers={"X-Client-Type": client_type}, - ) - - assert resp.status_code == expected_status - body = resp.json() - if returns_tokens: - assert body["access_token"] == "token" - assert body["refresh_token"] == "refresh" - assert body["session_id"] == "session" - else: - assert body["session_id"] == "test-session" - - def test_mfa_verify_no_pending_login(self, fast_api_app, fast_api_client): - """ - Test MFA verification when no pending login is found. - - This test verifies that when attempting to verify MFA without a pending login, - the API returns a 400 Bad Request error. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.fake_store._store = {} - - resp = fast_api_client.post( - "/auth/mfa/verify", - json={"username": "testuser", "mfa_code": "123456"}, - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_400_BAD_REQUEST - assert "No pending MFA login" in resp.json()["detail"] - - def test_mfa_verify_invalid_code( - self, fast_api_app, fast_api_client, sample_user_read - ): - """ - Test MFA verification with an invalid MFA code. - - This test verifies that when an invalid MFA code is provided, - the API returns a 401 Unauthorized error. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.fake_store._store = {"testuser": sample_user_read.id} - - with patch("session.router.profile_utils.verify_user_mfa") as mock_verify_mfa: - mock_verify_mfa.return_value = False - - resp = fast_api_client.post( - "/auth/mfa/verify", - json={"username": "testuser", "mfa_code": "999999"}, - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_401_UNAUTHORIZED - assert "Invalid MFA code" in resp.json()["detail"] - - def test_mfa_verify_user_not_found( - self, fast_api_app, fast_api_client, sample_user_read - ): - """ - Test MFA verification when user is not found after verification. - - This test verifies that when a user cannot be found in the database - after MFA verification, the API returns a 404 Not Found error and - cleans up the pending login. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.fake_store._store = {"testuser": sample_user_read.id} - - with patch( - "session.router.profile_utils.verify_user_mfa" - ) as mock_verify_mfa, patch( - "session.router.users_crud.get_user_by_id" - ) as mock_get_user: - mock_verify_mfa.return_value = True - mock_get_user.return_value = None - - resp = fast_api_client.post( - "/auth/mfa/verify", - json={"username": "testuser", "mfa_code": "123456"}, - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_404_NOT_FOUND - assert "User not found" in resp.json()["detail"] - - def test_mfa_verify_inactive_user( - self, fast_api_app, fast_api_client, sample_inactive_user - ): - """ - Test MFA verification with an inactive user. - - This test verifies that when an inactive user attempts to complete MFA login, - the API returns a 403 Forbidden error. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.fake_store._store = {"inactive": sample_inactive_user.id} - - with patch( - "session.router.profile_utils.verify_user_mfa" - ) as mock_verify_mfa, patch( - "session.router.users_crud.get_user_by_id" - ) as mock_get_user, patch( - "session.router.users_utils.check_user_is_active" - ) as mock_check_active: - mock_verify_mfa.return_value = True - mock_get_user.return_value = sample_inactive_user - mock_check_active.side_effect = HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive" - ) - - resp = fast_api_client.post( - "/auth/mfa/verify", - json={"username": "inactive", "mfa_code": "123456"}, - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_403_FORBIDDEN - assert "User is inactive" in resp.json()["detail"] - - -class TestRefreshTokenEndpoint: - """ - Test suite for the refresh token endpoint (/auth/refresh). - - This class contains tests that cover various scenarios for the token refresh endpoint, including: - - Successful token refresh for different client types (web and mobile). - - Handling of session not found errors. - - Handling of invalid refresh token hash mismatches. - - Handling of inactive users during refresh. - - Handling of invalid client types during refresh. - - Each test uses mocking to simulate token validation, session retrieval, and token creation. - """ - - @pytest.mark.parametrize( - "client_type, expected_status, returns_tokens", - [ - ("web", status.HTTP_200_OK, False), - ("mobile", status.HTTP_200_OK, True), - ], - ) - def test_refresh_token_success( - self, - fast_api_app, - fast_api_client, - sample_user_read, - password_hasher, - client_type, - expected_status, - returns_tokens, - ): - """ - Test successful token refresh. - - This test verifies that when a valid refresh token is provided, - the API successfully creates new tokens and returns them based on client type. - - Args: - fast_api_app: The FastAPI application instance under test. - fast_api_client: The test client for making HTTP requests. - sample_user_read: A sample user object. - password_hasher: The password hasher instance. - mock_db: Mock database session. - client_type: The type of client ("web" or "mobile"). - expected_status: The expected HTTP status code. - returns_tokens: Boolean indicating if tokens should be returned. - """ - fast_api_app.state._client_type = client_type - fast_api_app.state.mock_user_id = sample_user_read.id - fast_api_app.state.mock_session_id = "test-session-id" - fast_api_app.state.mock_refresh_token = "refresh_token_value" - - mock_session = MagicMock() - mock_session.id = "test-session-id" - mock_session.refresh_token = password_hasher.hash_password( - "refresh_token_value" - ) - - with patch( - "session.router.session_crud.get_session_by_id", return_value=mock_session - ), patch( - "session.router.users_crud.get_user_by_id", return_value=sample_user_read - ), patch( - "session.router.users_utils.check_user_is_active" - ), patch( - "session.router.auth_utils.create_tokens" - ) as mock_create_tokens, patch( - "session.router.session_utils.edit_session" - ), patch( - "session.router.auth_utils.create_response_with_tokens", - side_effect=lambda r, a, rf, c: r, - ): - # Set up proper mock for create_tokens with timestamp - mock_access_exp = MagicMock() - mock_access_exp.timestamp.return_value = 1234567890 - mock_refresh_exp = MagicMock() - mock_create_tokens.return_value = ( - "new-session-id", - mock_access_exp, - "new_access_token", - mock_refresh_exp, - "new_refresh_token", - "new_csrf_token", - ) - - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_refresh_token", "refresh_token_value") - - resp = fast_api_client.post( - "/auth/refresh", - headers={"X-Client-Type": client_type}, - ) - - assert resp.status_code == expected_status - body = resp.json() - if returns_tokens: - assert body["access_token"] == "new_access_token" - assert body["refresh_token"] == "new_refresh_token" - assert body["session_id"] == "new-session-id" - assert body["token_type"] == "bearer" - else: - assert body["session_id"] == "new-session-id" - - def test_refresh_token_session_not_found(self, fast_api_app, fast_api_client): - """ - Test token refresh when session is not found. - - This test verifies that when attempting to refresh with a session ID - that doesn't exist, the API returns a 404 Not Found error. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.mock_user_id = 1 - fast_api_app.state.mock_session_id = "nonexistent-session" - fast_api_app.state.mock_refresh_token = "refresh_token_value" - - with patch("session.router.session_crud.get_session_by_id", return_value=None): - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_refresh_token", "refresh_token_value") - - resp = fast_api_client.post( - "/auth/refresh", - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_404_NOT_FOUND - assert "Session not found" in resp.json()["detail"] - - def test_refresh_token_invalid_hash( - self, fast_api_app, fast_api_client, password_hasher - ): - """ - Test token refresh with invalid refresh token hash. - - This test verifies that when the refresh token hash doesn't match - the stored hash, the API returns a 401 Unauthorized error. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.mock_user_id = 1 - fast_api_app.state.mock_session_id = "test-session-id" - fast_api_app.state.mock_refresh_token = "wrong_token_value" - - mock_session = MagicMock() - mock_session.id = "test-session-id" - mock_session.refresh_token = password_hasher.hash_password("different_token") - - with patch( - "session.router.session_crud.get_session_by_id", return_value=mock_session - ): - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_refresh_token", "wrong_token_value") - - resp = fast_api_client.post( - "/auth/refresh", - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_401_UNAUTHORIZED - assert "Invalid refresh token" in resp.json()["detail"] - - def test_refresh_token_inactive_user( - self, fast_api_app, fast_api_client, sample_inactive_user, password_hasher - ): - """ - Test token refresh with an inactive user. - - This test verifies that when an inactive user attempts to refresh tokens, - the API returns a 403 Forbidden error. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.mock_user_id = sample_inactive_user.id - fast_api_app.state.mock_session_id = "test-session-id" - fast_api_app.state.mock_refresh_token = "refresh_token_value" - - mock_session = MagicMock() - mock_session.id = "test-session-id" - mock_session.refresh_token = password_hasher.hash_password( - "refresh_token_value" - ) - - with patch( - "session.router.session_crud.get_session_by_id", return_value=mock_session - ), patch( - "session.router.users_crud.get_user_by_id", - return_value=sample_inactive_user, - ), patch( - "session.router.users_utils.check_user_is_active", - side_effect=HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive" - ), - ): - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_refresh_token", "refresh_token_value") - - resp = fast_api_client.post( - "/auth/refresh", - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_403_FORBIDDEN - - def test_refresh_token_invalid_client_type( - self, fast_api_app, fast_api_client, sample_user_read, password_hasher - ): - """ - Test token refresh with an invalid client type. - - This test verifies that when an invalid client type is provided, - the API returns a 403 Forbidden error. - """ - fast_api_app.state._client_type = "desktop" - fast_api_app.state.mock_user_id = sample_user_read.id - fast_api_app.state.mock_session_id = "test-session-id" - fast_api_app.state.mock_refresh_token = "refresh_token_value" - - mock_session = MagicMock() - mock_session.id = "test-session-id" - mock_session.refresh_token = password_hasher.hash_password( - "refresh_token_value" - ) - - with patch( - "session.router.session_crud.get_session_by_id", return_value=mock_session - ), patch( - "session.router.users_crud.get_user_by_id", return_value=sample_user_read - ), patch( - "session.router.users_utils.check_user_is_active" - ), patch( - "session.router.auth_utils.create_tokens" - ) as mock_create_tokens, patch( - "session.router.session_utils.edit_session" - ): - # Set up proper mock for create_tokens with timestamp - mock_access_exp = MagicMock() - mock_access_exp.timestamp.return_value = 1234567890 - mock_refresh_exp = MagicMock() - mock_create_tokens.return_value = ( - "new-session-id", - mock_access_exp, - "new_access_token", - mock_refresh_exp, - "new_refresh_token", - "new_csrf_token", - ) - - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_refresh_token", "refresh_token_value") - - resp = fast_api_client.post( - "/auth/refresh", - headers={"X-Client-Type": "desktop"}, - ) - - assert resp.status_code == status.HTTP_403_FORBIDDEN - assert "Invalid client type" in resp.json()["detail"] - - -class TestLogoutEndpoint: - """ - Test suite for the logout endpoint (/auth/logout). - - This class contains tests that cover various scenarios for the logout endpoint, including: - - Successful logout for different client types (web and mobile). - - Cookie clearing for web clients. - - Handling of invalid refresh tokens during logout. - - Handling of session not found during logout (should still succeed). - - Handling of invalid client types during logout. - - Each test uses mocking to simulate token validation, session retrieval, and session deletion. - """ - - @pytest.mark.parametrize( - "client_type, expected_status", - [ - ("web", status.HTTP_200_OK), - ("mobile", status.HTTP_200_OK), - ], - ) - def test_logout_success( - self, - fast_api_app, - fast_api_client, - password_hasher, - client_type, - expected_status, - ): - """ - Test successful logout. - - This test verifies that when a valid access and refresh token are provided, - the API successfully deletes the session and returns a success message. - - Args: - fast_api_app: The FastAPI application instance under test. - fast_api_client: The test client for making HTTP requests. - password_hasher: The password hasher instance. - client_type: The type of client ("web" or "mobile"). - expected_status: The expected HTTP status code. - """ - fast_api_app.state._client_type = client_type - fast_api_app.state.mock_session_id = "test-session-id" - fast_api_app.state.mock_user_id = 1 - fast_api_app.state.mock_refresh_token = "refresh_token_value" - - mock_session = MagicMock() - mock_session.id = "test-session-id" - mock_session.refresh_token = password_hasher.hash_password( - "refresh_token_value" - ) - - with patch( - "session.router.session_crud.get_session_by_id", return_value=mock_session - ), patch("session.router.session_crud.delete_session") as mock_delete: - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_access_token", "access_token") - fast_api_client.cookies.set("endurain_refresh_token", "refresh_token_value") - - resp = fast_api_client.post( - "/auth/logout", - headers={"X-Client-Type": client_type}, - ) - - assert resp.status_code == expected_status - assert resp.json()["message"] == "Logout successful" - mock_delete.assert_called_once() - - # Check cookies are cleared for web clients - if client_type == "web": - # The response should have set-cookie headers to clear cookies - assert "set-cookie" in resp.headers or resp.cookies - - def test_logout_invalid_refresh_token( - self, fast_api_app, fast_api_client, password_hasher - ): - """ - Test logout with an invalid refresh token. - - This test verifies that when the refresh token hash doesn't match, - the API returns a 401 Unauthorized error. - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.mock_session_id = "test-session-id" - fast_api_app.state.mock_user_id = 1 - fast_api_app.state.mock_refresh_token = "wrong_token_value" - - mock_session = MagicMock() - mock_session.id = "test-session-id" - mock_session.refresh_token = password_hasher.hash_password("different_token") - - with patch( - "session.router.session_crud.get_session_by_id", return_value=mock_session - ): - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_access_token", "access_token") - fast_api_client.cookies.set("endurain_refresh_token", "wrong_token_value") - - resp = fast_api_client.post( - "/auth/logout", - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_401_UNAUTHORIZED - assert "Invalid refresh token" in resp.json()["detail"] - - def test_logout_session_not_found_still_succeeds( - self, fast_api_app, fast_api_client - ): - """ - Test logout when session is not found (should still succeed). - - This test verifies that when attempting to logout with a session ID - that doesn't exist, the API still returns success (idempotent operation). - """ - fast_api_app.state._client_type = "web" - fast_api_app.state.mock_session_id = "nonexistent-session" - fast_api_app.state.mock_user_id = 1 - fast_api_app.state.mock_refresh_token = "refresh_token_value" - - with patch("session.router.session_crud.get_session_by_id", return_value=None): - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_access_token", "access_token") - fast_api_client.cookies.set("endurain_refresh_token", "refresh_token_value") - - resp = fast_api_client.post( - "/auth/logout", - headers={"X-Client-Type": "web"}, - ) - - assert resp.status_code == status.HTTP_200_OK - assert resp.json()["message"] == "Logout successful" - - def test_logout_invalid_client_type(self, fast_api_app, fast_api_client): - """ - Test logout with an invalid client type. - - This test verifies that when an invalid client type is provided, - the API returns a 401 Unauthorized error (client type validation - happens after authentication in the dependency chain). - """ - fast_api_app.state._client_type = "desktop" - fast_api_app.state.mock_session_id = "test-session-id" - fast_api_app.state.mock_user_id = 1 - fast_api_app.state.mock_refresh_token = "refresh_token_value" - - mock_session = MagicMock() - mock_session.id = "test-session-id" - - with patch( - "session.router.session_crud.get_session_by_id", return_value=mock_session - ): - # Set cookies on client instance (new API) - fast_api_client.cookies.set("endurain_access_token", "access_token") - fast_api_client.cookies.set("endurain_refresh_token", "refresh_token_value") - - resp = fast_api_client.post( - "/auth/logout", - headers={"X-Client-Type": "desktop"}, - ) - - # Client type validation happens in the header_client_type_scheme dependency - # which runs after authentication, so we get 401 due to invalid client type - # being rejected by the scheme validator - assert resp.status_code == status.HTTP_401_UNAUTHORIZED - - -class TestSessionsEndpoints: - """ - Test suite for the sessions management endpoints. - - This class contains tests for: - - GET /sessions/user/{user_id} - Retrieve all sessions for a user - - DELETE /sessions/{session_id}/user/{user_id} - Delete a specific session - - Each test uses mocking to simulate authentication, authorization, and database operations. - """ - - def test_read_sessions_user_success( - self, fast_api_app, fast_api_client, sample_user_read - ): - """ - Test successful retrieval of user sessions. - - This test verifies that when a valid access token is provided with - appropriate scopes, the API returns all sessions for the user. - """ - fast_api_app.state._client_type = "web" - - mock_sessions = [ - { - "id": "session-1", - "user_id": sample_user_read.id, - "device_type": "desktop", - "browser": "Chrome", - }, - { - "id": "session-2", - "user_id": sample_user_read.id, - "device_type": "mobile", - "browser": "Safari", - }, - ] - - with patch( - "session.router.session_crud.get_user_sessions", return_value=mock_sessions - ) as mock_get_sessions: - resp = fast_api_client.get( - f"/sessions/user/{sample_user_read.id}", - headers={ - "X-Client-Type": "web", - "Authorization": "Bearer access_token", - }, - ) - - assert resp.status_code == status.HTTP_200_OK - assert len(resp.json()) == 2 - assert resp.json()[0]["id"] == "session-1" - assert resp.json()[1]["id"] == "session-2" - mock_get_sessions.assert_called_once() - - def test_read_sessions_user_empty_list( - self, fast_api_app, fast_api_client, sample_user_read - ): - """ - Test retrieval of user sessions when no sessions exist. - - This test verifies that when a user has no active sessions, - the API returns an empty list. - """ - fast_api_app.state._client_type = "web" - - with patch("session.router.session_crud.get_user_sessions", return_value=[]): - resp = fast_api_client.get( - f"/sessions/user/{sample_user_read.id}", - headers={ - "X-Client-Type": "web", - "Authorization": "Bearer access_token", - }, - ) - - assert resp.status_code == status.HTTP_200_OK - assert resp.json() == [] - - def test_delete_session_success( - self, fast_api_app, fast_api_client, sample_user_read - ): - """ - Test successful deletion of a user session. - - This test verifies that when a valid access token is provided with - appropriate scopes, the API successfully deletes the specified session. - """ - fast_api_app.state._client_type = "web" - session_id = "session-to-delete" - - with patch( - "session.router.session_crud.delete_session", return_value=True - ) as mock_delete: - resp = fast_api_client.delete( - f"/sessions/{session_id}/user/{sample_user_read.id}", - headers={ - "X-Client-Type": "web", - "Authorization": "Bearer access_token", - }, - ) - - assert resp.status_code == status.HTTP_200_OK - # Verify delete_session was called with the correct session_id and user_id - # (the third argument is the database session which we don't need to verify) - assert mock_delete.called - call_args = mock_delete.call_args[0] - assert call_args[0] == session_id - assert call_args[1] == sample_user_read.id - - def test_delete_session_not_found( - self, fast_api_app, fast_api_client, sample_user_read - ): - """ - Test deletion of a non-existent session. - - This test verifies that when attempting to delete a session that doesn't exist, - the API handles it appropriately (implementation-dependent behavior). - """ - fast_api_app.state._client_type = "web" - session_id = "nonexistent-session" - - with patch( - "session.router.session_crud.delete_session", - side_effect=HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" - ), - ): - resp = fast_api_client.delete( - f"/sessions/{session_id}/user/{sample_user_read.id}", - headers={ - "X-Client-Type": "web", - "Authorization": "Bearer access_token", - }, - ) - - assert resp.status_code == status.HTTP_404_NOT_FOUND - assert "Session not found" in resp.json()["detail"] diff --git a/backend/tests/session/test_utils.py b/backend/tests/session/test_utils.py deleted file mode 100644 index c3e56805a..000000000 --- a/backend/tests/session/test_utils.py +++ /dev/null @@ -1,1140 +0,0 @@ -from datetime import datetime, timezone, timedelta -from unittest.mock import MagicMock, patch - -import pytest -from fastapi import HTTPException, Response - -from pwdlib.hashers.bcrypt import BcryptHasher -from pwdlib import PasswordHash - -import auth.password_hasher as auth_password_hasher -import auth.utils as auth_utils -import session.utils as session_utils - - -class TestAuthenticationSecurity: - """ - Test suite for authentication and session management utilities. - - This class contains comprehensive tests for the authentication and session-related - functions in the `session_utils` module. It covers scenarios including: - - - User authentication with valid and invalid credentials. - - Password hash upgrades and security edge cases (e.g., empty or whitespace passwords). - - SQL injection protection in authentication. - - Token creation and validation, including session ID uniqueness and CSRF token generation. - - Login completion logic for different client types (web, mobile) and error handling for invalid types. - - User agent parsing for various device/browser/OS combinations. - - IP address extraction from HTTP request headers and client information. - - Session object creation and editing, ensuring correct field population and updates. - - Each test is designed to verify correct behavior, security, and robustness of the authentication/session logic, - using mocks and fixtures to isolate dependencies and simulate real-world scenarios. - """ - - def test_create_session_object_contains_required_fields( - self, sample_user_read, mock_request - ): - """ - Test that the session object created by `session_utils.create_session_object` contains all required fields. - - This test verifies that: - - The session ID matches the provided value. - - The user ID matches the sample user. - - The refresh token hash is correctly set. - - The IP address, device type, operating system, browser, and creation timestamp are all populated. - - The expiration timestamp matches the provided value. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - - Asserts: - - Session fields are correctly set and not None where required. - """ - session_id = "test-session-id" - refresh_token_hash = "hashed_refresh_token" - expires_at = datetime.now(timezone.utc) - - session = session_utils.create_session_object( - session_id, sample_user_read, mock_request, refresh_token_hash, expires_at - ) - - assert session.id == session_id, "Session ID should match" - assert session.user_id == sample_user_read.id, "User ID should match" - assert ( - session.refresh_token == refresh_token_hash - ), "Refresh token hash should match" - assert session.ip_address is not None, "IP address should be set" - assert session.device_type is not None, "Device type should be set" - assert session.operating_system is not None, "OS should be set" - assert session.browser is not None, "Browser should be set" - assert session.created_at is not None, "Created timestamp should be set" - assert session.expires_at == expires_at, "Expiration should match" - - def test_edit_session_object_preserves_and_updates_fields( - self, sample_user_read, mock_request - ): - """ - Test that the `edit_session_object` function preserves existing session fields while updating necessary ones. - - This test verifies that: - - The session ID and user ID remain unchanged from the original session. - - The refresh token hash is updated to the new value. - - Device information (IP address, device type, OS, browser) is updated based on the new request. - - The created_at timestamp is preserved from the original session. - - The expires_at timestamp is updated to the new expiration time. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - - Asserts: - - Session ID and user ID remain unchanged. - - Refresh token is updated to the new hash. - - Device information is updated from the request. - - Original created_at timestamp is preserved. - - Expiration timestamp is updated to the new value. - """ - # Create an existing session object - original_session_id = "existing-session-id" - original_created_at = datetime.now(timezone.utc) - timedelta(days=1) - old_refresh_token_hash = "old_hashed_refresh_token" - old_expires_at = datetime.now(timezone.utc) + timedelta(days=7) - - existing_session = MagicMock() - existing_session.id = original_session_id - existing_session.user_id = sample_user_read.id - existing_session.refresh_token = old_refresh_token_hash - existing_session.created_at = original_created_at - existing_session.expires_at = old_expires_at - - # New values for the update - new_refresh_token_hash = "new_hashed_refresh_token" - new_expires_at = datetime.now(timezone.utc) + timedelta(days=30) - - # Edit the session - updated_session = session_utils.edit_session_object( - mock_request, new_refresh_token_hash, new_expires_at, existing_session - ) - - # Verify preserved fields - assert ( - updated_session.id == original_session_id - ), "Session ID should be preserved" - assert ( - updated_session.user_id == sample_user_read.id - ), "User ID should be preserved" - assert ( - updated_session.created_at == original_created_at - ), "Created timestamp should be preserved" - - # Verify updated fields - assert ( - updated_session.refresh_token == new_refresh_token_hash - ), "Refresh token hash should be updated" - assert ( - updated_session.expires_at == new_expires_at - ), "Expiration should be updated" - - # Verify fields updated from request - assert updated_session.ip_address is not None, "IP address should be set" - assert updated_session.device_type is not None, "Device type should be set" - assert ( - updated_session.operating_system is not None - ), "Operating system should be set" - assert updated_session.browser is not None, "Browser should be set" - - def test_authenticate_user_with_valid_credentials( - self, password_hasher, mock_db, sample_user_read - ): - """ - Test that the `auth_utils.authenticate_user` function successfully authenticates a user with valid credentials. - This test: - - Hashes a sample password using the provided password hasher. - - Mocks a user ORM object with the hashed password and sample user data. - - Patches the `auth_utils.authenticate_user` function in the users CRUD utility to return the mocked user. - - Calls the actual `auth_utils.authenticate_user` function with valid credentials. - - Asserts that authentication succeeds and the returned user matches the expected sample user. - Args: - password_hasher: Fixture or mock for password hashing utilities. - mock_db: Mocked database session or connection. - sample_user_read: Sample user data object for comparison. - Asserts: - - The authentication result is not None. - - The returned user's ID matches the sample user's ID. - - The returned user's username matches the sample user's username. - """ - password = "TestPassword123!" - hashed_password = password_hasher.hash_password(password) - - # Create ORM-like object with password attribute - mock_user_orm = MagicMock() - mock_user_orm.id = sample_user_read.id - mock_user_orm.username = sample_user_read.username - mock_user_orm.password = hashed_password - - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - mock_auth.return_value = mock_user_orm - - result = auth_utils.authenticate_user( - "testuser", password, password_hasher, mock_db - ) - - assert result is not None, "Authentication should succeed" - assert result.id == sample_user_read.id, "Should return correct user" - assert result.username == sample_user_read.username, "Username should match" - - def test_authenticate_user_with_invalid_username(self, password_hasher, mock_db): - """ - Test that the `auth_utils.authenticate_user` function raises an HTTPException with status code 401 - when provided with an invalid (nonexistent) username. Ensures that the exception detail - contains information about the username. - """ - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - mock_auth.return_value = None - - with pytest.raises(HTTPException) as exc_info: - auth_utils.authenticate_user( - "nonexistent", "password", password_hasher, mock_db - ) - - assert exc_info.value.status_code == 401 - assert "username" in exc_info.value.detail.lower() - - def test_authenticate_user_with_invalid_password( - self, password_hasher, mock_db, sample_user_read - ): - """ - Test that the `auth_utils.authenticate_user` function raises an HTTPException with status code 401 - when an incorrect password is provided for an existing user. - This test mocks the user retrieval and password hashing process, simulating a scenario - where the user exists but the provided password does not match the stored hash. - It asserts that the exception raised contains a detail mentioning "password". - """ - correct_password = "CorrectPassword123!" - wrong_password = "WrongPassword123!" - - # Create ORM-like object with password attribute - mock_user_orm = MagicMock() - mock_user_orm.id = sample_user_read.id - mock_user_orm.username = sample_user_read.username - mock_user_orm.password = password_hasher.hash_password(correct_password) - - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - mock_auth.return_value = mock_user_orm - - with pytest.raises(HTTPException) as exc_info: - auth_utils.authenticate_user( - "testuser", wrong_password, password_hasher, mock_db - ) - - assert exc_info.value.status_code == 401 - assert "password" in exc_info.value.detail.lower() - - def test_authenticate_user_updates_hash_if_needed( - self, password_hasher, mock_db, sample_user_read - ): - """ - Test that the `auth_utils.authenticate_user` function updates the user's password hash if the current hash is outdated. - This test simulates a scenario where a user's password is hashed with an old hasher. It mocks the authentication and password update functions to verify that authentication succeeds and that the system is prepared to update the password hash if necessary. - Args: - self: The test case instance. - password_hasher: The current password hasher instance used for verification and hashing. - mock_db: A mocked database session or connection. - sample_user_read: A sample user object with user attributes for testing. - Asserts: - - The authentication process returns a non-None result, indicating successful authentication. - """ - password = "TestPassword123!" - - old_hasher = auth_password_hasher.PasswordHasher(PasswordHash([BcryptHasher()])) - - # Create ORM-like object with password attribute - mock_user_orm = MagicMock() - mock_user_orm.id = sample_user_read.id - mock_user_orm.username = sample_user_read.username - mock_user_orm.password = old_hasher.hash_password(password) - - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - with patch("session.utils.users_crud.edit_user_password") as _mock_edit: - mock_auth.return_value = mock_user_orm - - result = auth_utils.authenticate_user( - "testuser", password, password_hasher, mock_db - ) - - assert result is not None, "Authentication should succeed" - - def test_authentication_sql_injection_protection(self, password_hasher, mock_db): - """ - Tests that the authentication function is protected against SQL injection attacks. - - This test attempts to authenticate using a list of malicious usernames commonly used in SQL injection attacks. - It mocks the `authenticate_user` function to always return None, simulating failed authentication. - For each malicious username, it asserts that an HTTPException with status code 401 is raised, - indicating that the authentication attempt was correctly rejected. - - Args: - password_hasher: The password hasher fixture or mock used for password verification. - mock_db: The mock database session or connection. - - Raises: - AssertionError: If the authentication does not raise an HTTPException with status code 401. - """ - malicious_usernames = [ - "admin' OR '1'='1", - "admin'--", - "admin' /*", - "' OR 1=1--", - "admin'; DROP TABLE users--", - ] - - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - mock_auth.return_value = None - - for username in malicious_usernames: - with pytest.raises(HTTPException) as exc_info: - auth_utils.authenticate_user( - username, "password", password_hasher, mock_db - ) - assert exc_info.value.status_code == 401 - - def test_empty_password_authentication( - self, password_hasher, mock_db, sample_user_read - ): - """ - Test that authentication fails with an empty password. - - This test verifies that when an empty password is provided to the authentication - function, an HTTPException with status code 401 is raised, indicating unauthorized - access. It mocks the user ORM object and the authentication function to simulate - the authentication process without accessing the real database. - """ - # Create ORM-like object with password attribute - mock_user_orm = MagicMock() - mock_user_orm.id = sample_user_read.id - mock_user_orm.username = sample_user_read.username - mock_user_orm.password = password_hasher.hash_password("RealPassword123!") - - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - mock_auth.return_value = mock_user_orm - - with pytest.raises(HTTPException) as exc_info: - auth_utils.authenticate_user("testuser", "", password_hasher, mock_db) - - assert exc_info.value.status_code == 401 - - def test_empty_username_authentication(self, password_hasher, mock_db): - """ - Test that authentication fails with an empty username. - - This test verifies that when an empty string is provided as the username to the - `authenticate_user` function, an HTTPException with status code 401 is raised, - indicating unauthorized access. The database call returns None for empty username, - simulating that no user is found. - """ - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - # Mock database to return None for empty username (user not found) - mock_auth.return_value = None - - with pytest.raises(HTTPException) as exc_info: - auth_utils.authenticate_user( - "", "RealPassword123!", password_hasher, mock_db - ) - - assert exc_info.value.status_code == 401 - - def test_whitespace_password_authentication( - self, password_hasher, mock_db, sample_user_read - ): - """ - Test that authentication fails with a password consisting only of whitespace. - - This test mocks the user authentication process to ensure that when a user attempts - to authenticate with a password containing only whitespace characters, the - authentication utility raises an HTTPException with a 401 status code. - - Args: - password_hasher: Fixture or mock for password hashing utilities. - mock_db: Mocked database session or connection. - sample_user_read: Sample user object with id and username attributes. - - Asserts: - - HTTPException is raised with status code 401 when a whitespace-only password is used. - """ - # Create ORM-like object with password attribute - mock_user_orm = MagicMock() - mock_user_orm.id = sample_user_read.id - mock_user_orm.username = sample_user_read.username - mock_user_orm.password = password_hasher.hash_password("RealPassword123!") - - with patch("session.utils.users_crud.authenticate_user") as mock_auth: - mock_auth.return_value = mock_user_orm - - with pytest.raises(HTTPException) as exc_info: - auth_utils.authenticate_user( - "testuser", " ", password_hasher, mock_db - ) - - assert exc_info.value.status_code == 401 - - def test_create_tokens_generates_all_required_tokens( - self, token_manager, sample_user_read - ): - """ - Test that the `auth_utils.create_tokens` function generates all required tokens and their expirations. - - This test verifies that: - - A session ID is generated. - - Access and refresh tokens are generated. - - A CSRF token is generated. - - The expiration times for both access and refresh tokens are set in the future. - - Args: - token_manager: The token manager instance used to generate tokens. - sample_user_read: A sample user object for whom the tokens are generated. - - Asserts: - - Session ID, access token, refresh token, and CSRF token are not None. - - Access and refresh token expiration times are greater than the current UTC time. - """ - ( - session_id, - access_token_exp, - access_token, - refresh_token_exp, - refresh_token, - csrf_token, - ) = auth_utils.create_tokens(sample_user_read, token_manager) - - assert session_id is not None, "Session ID should be generated" - assert access_token is not None, "Access token should be generated" - assert refresh_token is not None, "Refresh token should be generated" - assert csrf_token is not None, "CSRF token should be generated" - assert access_token_exp > datetime.now( - timezone.utc - ), "Access token expiration should be in future" - assert refresh_token_exp > datetime.now( - timezone.utc - ), "Refresh token expiration should be in future" - - def test_create_tokens_uses_provided_session_id( - self, token_manager, sample_user_read - ): - """ - Test that the `auth_utils.create_tokens` function uses the provided session ID when one is supplied. - - Args: - token_manager: The token manager fixture or mock used to generate tokens. - sample_user_read: A sample user object used for token creation. - - Asserts: - The returned session ID from `auth_utils.create_tokens` matches the provided session ID. - """ - provided_session_id = "custom-session-id-123" - - ( - session_id, - _, - _, - _, - _, - _, - ) = auth_utils.create_tokens( - sample_user_read, token_manager, session_id=provided_session_id - ) - - assert session_id == provided_session_id, "Should use provided session ID" - - def test_create_tokens_generates_unique_session_ids( - self, token_manager, sample_user_read - ): - """ - Test that the `auth_utils.create_tokens` function generates unique session IDs for each invocation. - - This test calls `auth_utils.create_tokens` multiple times with the same user and token manager, - collects the returned session IDs, and asserts that all session IDs are unique. - - Args: - token_manager: The token manager instance used to generate tokens. - sample_user_read: The user object for whom the tokens are generated. - - Asserts: - The number of unique session IDs generated equals the number of invocations, - ensuring that each session receives a distinct session ID. - """ - session_ids = set() - for _ in range(10): - session_id, _, _, _, _, _ = auth_utils.create_tokens( - sample_user_read, token_manager - ) - session_ids.add(session_id) - - assert len(session_ids) == 10, "Session IDs should be unique" - - def test_complete_login_for_web_client( - self, password_hasher, token_manager, mock_db, sample_user_read, mock_request - ): - """ - Tests the `complete_login` function for the web client scenario. - - This test verifies that when a login is completed for a web client: - - The response contains a "session_id" key. - - The "session_id" is a string. - - The `create_session` utility is called exactly once. - - Mocks are used for dependencies such as password hasher, token manager, database, and request objects. - """ - response = Response() - mock_request.headers["X-Client-Type"] = "web" - - with patch("session.utils.create_session") as mock_create_session: - result = auth_utils.complete_login( - response, - mock_request, - sample_user_read, - "web", - password_hasher, - token_manager, - mock_db, - ) - - assert "session_id" in result, "Should return session_id for web" - assert isinstance(result["session_id"], str), "Session ID should be string" - mock_create_session.assert_called_once() - - def test_complete_login_for_mobile_client( - self, password_hasher, token_manager, mock_db, sample_user_read, mock_request - ): - """ - Test the `complete_login` function for mobile clients. - - This test verifies that when a login is completed for a mobile client: - - The response contains an "access_token", "refresh_token", "session_id", "token_type", and "expires_in". - - The "token_type" is set to "Bearer". - - The session creation utility (`create_session`) is called exactly once. - - Mocks: - - `password_hasher`: Mocked password hasher dependency. - - `token_manager`: Mocked token manager dependency. - - `mock_db`: Mocked database session. - - `sample_user_read`: Sample user object for authentication. - - `mock_request`: Mocked request object with "X-Client-Type" header set to "mobile". - """ - response = Response() - mock_request.headers["X-Client-Type"] = "mobile" - - with patch("session.utils.create_session") as mock_create_session: - result = auth_utils.complete_login( - response, - mock_request, - sample_user_read, - "mobile", - password_hasher, - token_manager, - mock_db, - ) - - assert "access_token" in result, "Should return access_token for mobile" - assert "refresh_token" in result, "Should return refresh_token for mobile" - assert "session_id" in result, "Should return session_id for mobile" - assert "token_type" in result, "Should return token_type for mobile" - assert "expires_in" in result, "Should return expires_in for mobile" - assert result["token_type"] == "Bearer", "Token type should be Bearer" - mock_create_session.assert_called_once() - - def test_complete_login_with_invalid_client_type( - self, password_hasher, token_manager, mock_db, sample_user_read, mock_request - ): - """ - Test that the `complete_login` function raises an HTTPException with status code 403 - when provided with an invalid client type. - - Args: - password_hasher: Fixture or mock for password hashing functionality. - token_manager: Fixture or mock for token management. - mock_db: Mocked database session or connection. - sample_user_read: Sample user object for testing. - mock_request: Mocked request object. - - Asserts: - - An HTTPException is raised. - - The exception has a status code of 403. - - The exception detail message contains the phrase "client type". - """ - response = Response() - - with patch("session.utils.create_session"): - with pytest.raises(HTTPException) as exc_info: - auth_utils.complete_login( - response, - mock_request, - sample_user_read, - "invalid_type", - password_hasher, - token_manager, - mock_db, - ) - - assert exc_info.value.status_code == 403 - assert "client type" in exc_info.value.detail.lower() - - def test_parse_user_agent_chrome_desktop(self): - """ - Test that the `parse_user_agent` function correctly identifies a Chrome browser running on a Windows desktop. - - This test verifies that: - - The device type is detected as PC. - - The browser is identified as Chrome. - - The operating system is identified as Windows. - - Assertions: - - device_info.device_type == session_utils.DeviceType.PC - - "Chrome" in device_info.browser - - "Windows" in device_info.operating_system - """ - user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - - device_info = session_utils.parse_user_agent(user_agent) - - assert ( - device_info.device_type == session_utils.DeviceType.PC - ), "Should detect as PC" - assert "Chrome" in device_info.browser, "Should detect Chrome browser" - assert "Windows" in device_info.operating_system, "Should detect Windows OS" - - def test_parse_user_agent_firefox_desktop(self): - """ - Test that the parse_user_agent function correctly identifies a Firefox browser running on a Linux desktop. - - This test verifies that: - - The device type is detected as PC. - - The browser is identified as Firefox. - - The operating system is identified as Linux. - - Assertions: - - device_info.device_type == session_utils.DeviceType.PC - - "Firefox" in device_info.browser - - "Linux" in device_info.operating_system - """ - user_agent = ( - "Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0" - ) - - device_info = session_utils.parse_user_agent(user_agent) - - assert ( - device_info.device_type == session_utils.DeviceType.PC - ), "Should detect as PC" - assert "Firefox" in device_info.browser, "Should detect Firefox browser" - assert "Linux" in device_info.operating_system, "Should detect Linux OS" - - def test_parse_user_agent_safari_mac(self): - """ - Test that the parse_user_agent function correctly identifies a Safari browser on macOS. - - This test verifies that: - - The device type is detected as PC when the user agent string corresponds to Safari on a Mac. - - The operating system string contains "Mac OS X", confirming macOS detection. - - Assertions: - - device_info.device_type == session_utils.DeviceType.PC - - "Safari" in device_info.browser - - "Mac OS X" in device_info.operating_system - """ - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15" - - device_info = session_utils.parse_user_agent(user_agent) - - assert ( - device_info.device_type == session_utils.DeviceType.PC - ), "Should detect as PC" - assert "Safari" in device_info.browser, "Should detect Safari browser" - assert "Mac OS X" in device_info.operating_system, "Should detect macOS" - - def test_parse_user_agent_mobile_ios(self): - """ - Test that the `parse_user_agent` function correctly identifies an iOS mobile device from a typical iPhone user agent string. - - This test verifies: - - The device type is detected as mobile. - - The browser is identified as Safari. - - The operating system is recognized as iOS. - """ - user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1" - - device_info = session_utils.parse_user_agent(user_agent) - - assert ( - device_info.device_type == session_utils.DeviceType.MOBILE - ), "Should detect as mobile" - assert "Safari" in device_info.browser, "Should detect Safari browser" - assert "iOS" in device_info.operating_system, "Should detect iOS" - - def test_parse_user_agent_mobile_android(self): - """ - Test that the `parse_user_agent` function correctly identifies an Android mobile device from a given user agent string. - - This test verifies that: - - The device type is detected as mobile. - - The browser is identified as Chrome. - - The operating system is identified as Android. - """ - user_agent = "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36" - - device_info = session_utils.parse_user_agent(user_agent) - - assert ( - device_info.device_type == session_utils.DeviceType.MOBILE - ), "Should detect as mobile" - assert "Chrome" in device_info.browser, "Should detect Chrome browser" - assert "Android" in device_info.operating_system, "Should detect Android" - - def test_parse_user_agent_tablet(self): - """ - Test that the `parse_user_agent` function correctly identifies an iPad user agent string as a tablet device, - detects the Safari browser, and recognizes the iOS operating system. - - Assertions: - - The device type should be identified as `DeviceType.TABLET`. - - The browser name should include "Safari". - - The operating system should include "iOS". - """ - user_agent = "Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1" - - device_info = session_utils.parse_user_agent(user_agent) - - assert ( - device_info.device_type == session_utils.DeviceType.TABLET - ), "Should detect as tablet" - assert "Safari" in device_info.browser, "Should detect Safari browser" - assert "iOS" in device_info.operating_system, "Should detect iOS" - - def test_parse_user_agent_empty_string(self): - """ - Test that the `parse_user_agent` function correctly handles an empty user agent string. - - This test verifies that: - - The function does not return None when given an empty string. - - The returned device type is one of PC, MOBILE, or TABLET. - - The browser and operating system fields in the returned object are not None. - """ - device_info = session_utils.parse_user_agent("") - - assert device_info is not None, "Should handle empty user agent" - assert device_info.device_type in [ - session_utils.DeviceType.PC, - session_utils.DeviceType.MOBILE, - session_utils.DeviceType.TABLET, - ], "Should return valid device type" - assert device_info.browser is not None, "Browser should not be None" - assert device_info.operating_system is not None, "OS should not be None" - - def test_get_ip_address_from_x_forwarded_for(self, mock_request): - """ - Test that the `get_ip_address` function correctly extracts the first IP address from the - 'X-Forwarded-For' HTTP header in the request. Ensures that when multiple IPs are present, - the function returns the left-most (original client) IP address. - """ - mock_request.headers = { - "X-Forwarded-For": "203.0.113.195, 70.41.3.18, 150.172.238.178" - } - - ip = session_utils.get_ip_address(mock_request) - - assert ip == "203.0.113.195", "Should extract first IP from X-Forwarded-For" - - def test_get_ip_address_from_x_real_ip(self, mock_request): - """ - Test that the get_ip_address function correctly extracts the IP address from the 'X-Real-IP' header in the request. - - Args: - mock_request: A mock request object with headers set for testing. - - Asserts: - The returned IP address matches the value provided in the 'X-Real-IP' header. - """ - mock_request.headers = {"X-Real-IP": "192.0.2.1"} - - ip = session_utils.get_ip_address(mock_request) - - assert ip == "192.0.2.1", "Should extract IP from X-Real-IP" - - def test_get_ip_address_priority(self, mock_request): - """ - Tests that the `get_ip_address` function correctly prioritizes the 'X-Forwarded-For' header - over the 'X-Real-IP' header when both are present in the request headers. - """ - mock_request.headers = { - "X-Forwarded-For": "203.0.113.195", - "X-Real-IP": "192.0.2.1", - } - - ip = session_utils.get_ip_address(mock_request) - - assert ip == "203.0.113.195", "X-Forwarded-For should take priority" - - def test_get_ip_address_from_client_host(self, mock_request): - """ - Test that the get_ip_address function returns the client's host IP address - when no relevant headers are present in the request. - - Args: - mock_request: A mock request object with headers and client.host attributes. - - Asserts: - The returned IP address matches the client.host value when headers are empty. - """ - mock_request.headers = {} - mock_request.client.host = "127.0.0.1" - - ip = session_utils.get_ip_address(mock_request) - - assert ip == "127.0.0.1", "Should fall back to client.host" - - def test_get_ip_address_with_no_client(self, mock_request): - """ - Test that get_ip_address returns 'unknown' when the request has no client information. - - This test sets up a mock request object with empty headers and a None client, - then verifies that the session_utils.get_ip_address function returns 'unknown' - as expected in the absence of client data. - """ - mock_request.headers = {} - mock_request.client = None - - ip = session_utils.get_ip_address(mock_request) - - assert ip == "unknown", "Should return 'unknown' when no client info" - - def test_create_session_creates_and_stores_session( - self, sample_user_read, mock_request, password_hasher, mock_db - ): - """ - Test that the `session_utils.create_session` function creates a new session and stores it in the database. - - This test verifies that: - - A session object is created with the correct session ID and user information. - - The refresh token is hashed before storage. - - The session expiration is set correctly. - - The session CRUD create function is called with the correct parameters. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - password_hasher: Fixture for password hashing utilities. - mock_db: Mocked database session. - - Asserts: - - session_crud.create_session is called once with the correct session object and database. - - The session object contains the expected session ID, user ID, and hashed refresh token. - """ - session_id = "test-session-id-123" - refresh_token = "test-refresh-token" - - with patch("session.utils.session_crud.create_session") as mock_create: - session_utils.create_session( - session_id, - sample_user_read, - mock_request, - refresh_token, - password_hasher, - mock_db, - ) - - # Verify create_session was called - assert mock_create.call_count == 1, "create_session should be called once" - - # Get the session object that was passed to create_session - call_args = mock_create.call_args - session_obj = call_args[0][0] - db_arg = call_args[0][1] - - # Verify the session object has correct fields - assert session_obj.id == session_id, "Session ID should match" - assert ( - session_obj.user_id == sample_user_read.id - ), "User ID should match sample user" - assert db_arg == mock_db, "Database session should be passed" - - # Verify refresh token is hashed (should not be plain text) - assert ( - session_obj.refresh_token != refresh_token - ), "Refresh token should be hashed" - assert ( - len(session_obj.refresh_token) > 0 - ), "Hashed refresh token should not be empty" - - # Verify the refresh token can be verified - assert password_hasher.verify( - refresh_token, session_obj.refresh_token - ), "Hashed token should verify against original" - - def test_create_session_sets_expiration_correctly( - self, sample_user_read, mock_request, password_hasher, mock_db - ): - """ - Test that the `session_utils.create_session` function sets the correct expiration time. - - This test verifies that: - - The session expiration is set to the expected number of days in the future. - - The expiration timestamp is a valid datetime object. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - password_hasher: Fixture for password hashing utilities. - mock_db: Mocked database session. - - Asserts: - - The session expiration is set correctly based on JWT_REFRESH_TOKEN_EXPIRE_DAYS. - """ - session_id = "test-session-id-exp" - refresh_token = "test-refresh-token" - - with patch("session.utils.session_crud.create_session") as mock_create: - with patch("session.utils.auth_constants") as mock_constants: - # Set the expiration days - mock_constants.JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 - - session_utils.create_session( - session_id, - sample_user_read, - mock_request, - refresh_token, - password_hasher, - mock_db, - ) - - # Get the session object - session_obj = mock_create.call_args[0][0] - - # Verify expiration is set and is in the future - assert session_obj.expires_at is not None, "Expiration should be set" - assert isinstance( - session_obj.expires_at, datetime - ), "Expiration should be a datetime" - assert session_obj.expires_at > datetime.now( - timezone.utc - ), "Expiration should be in the future" - - def test_edit_session_updates_refresh_token( - self, sample_user_read, mock_request, password_hasher, mock_db - ): - """ - Test that the `session_utils.edit_session` function updates the refresh token correctly. - - This test verifies that: - - The existing session's refresh token is updated to a new hashed value. - - The new refresh token is hashed before storage. - - The session CRUD edit function is called with the correct parameters. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - password_hasher: Fixture for password hashing utilities. - mock_db: Mocked database session. - - Asserts: - - session_crud.edit_session is called once with the updated session object. - - The refresh token is updated and hashed correctly. - """ - # Create an existing session mock - existing_session = MagicMock() - existing_session.id = "existing-session-123" - existing_session.user_id = sample_user_read.id - existing_session.refresh_token = "old-hashed-token" - existing_session.created_at = datetime.now(timezone.utc) - timedelta(days=5) - existing_session.expires_at = datetime.now(timezone.utc) + timedelta(days=25) - - new_refresh_token = "new-refresh-token" - - with patch("session.utils.session_crud.edit_session") as mock_edit: - session_utils.edit_session( - existing_session, - mock_request, - new_refresh_token, - password_hasher, - mock_db, - ) - - # Verify edit_session was called - assert mock_edit.call_count == 1, "edit_session should be called once" - - # Get the updated session object - call_args = mock_edit.call_args - updated_session = call_args[0][0] - db_arg = call_args[0][1] - - # Verify database argument - assert db_arg == mock_db, "Database session should be passed" - - # Verify session ID is preserved - assert ( - updated_session.id == existing_session.id - ), "Session ID should be preserved" - - # Verify refresh token is updated and hashed - assert ( - updated_session.refresh_token != existing_session.refresh_token - ), "Refresh token should be updated" - assert ( - updated_session.refresh_token != new_refresh_token - ), "Refresh token should be hashed" - assert password_hasher.verify( - new_refresh_token, updated_session.refresh_token - ), "New hashed token should verify against new token" - - def test_edit_session_updates_expiration( - self, sample_user_read, mock_request, password_hasher, mock_db - ): - """ - Test that the `session_utils.edit_session` function updates the expiration time. - - This test verifies that: - - The session's expiration time is updated to a new future date. - - The expiration is recalculated based on the current time plus refresh token expiry days. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - password_hasher: Fixture for password hashing utilities. - mock_db: Mocked database session. - - Asserts: - - The session expiration is updated to a new value in the future. - """ - # Create an existing session with old expiration - old_expiration = datetime.now(timezone.utc) + timedelta(days=10) - existing_session = MagicMock() - existing_session.id = "existing-session-456" - existing_session.user_id = sample_user_read.id - existing_session.refresh_token = "old-hashed-token" - existing_session.created_at = datetime.now(timezone.utc) - timedelta(days=20) - existing_session.expires_at = old_expiration - - new_refresh_token = "new-refresh-token" - - with patch("session.utils.session_crud.edit_session") as mock_edit: - session_utils.edit_session( - existing_session, - mock_request, - new_refresh_token, - password_hasher, - mock_db, - ) - - # Get the updated session object - updated_session = mock_edit.call_args[0][0] - - # Verify expiration is updated - assert ( - updated_session.expires_at != old_expiration - ), "Expiration should be updated" - assert updated_session.expires_at > datetime.now( - timezone.utc - ), "New expiration should be in the future" - assert isinstance( - updated_session.expires_at, datetime - ), "Expiration should be a datetime" - - def test_edit_session_preserves_created_at( - self, sample_user_read, mock_request, password_hasher, mock_db - ): - """ - Test that the `session_utils.edit_session` function preserves the original created_at timestamp. - - This test verifies that: - - The session's created_at timestamp is not modified during the edit operation. - - The created_at timestamp remains the same as the original session. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - password_hasher: Fixture for password hashing utilities. - mock_db: Mocked database session. - - Asserts: - - The created_at timestamp is preserved from the original session. - """ - # Create an existing session with a specific created_at time - original_created_at = datetime.now(timezone.utc) - timedelta(days=15) - existing_session = MagicMock() - existing_session.id = "existing-session-789" - existing_session.user_id = sample_user_read.id - existing_session.refresh_token = "old-hashed-token" - existing_session.created_at = original_created_at - existing_session.expires_at = datetime.now(timezone.utc) + timedelta(days=15) - - new_refresh_token = "new-refresh-token" - - with patch("session.utils.session_crud.edit_session") as mock_edit: - session_utils.edit_session( - existing_session, - mock_request, - new_refresh_token, - password_hasher, - mock_db, - ) - - # Get the updated session object - updated_session = mock_edit.call_args[0][0] - - # Verify created_at is preserved - assert ( - updated_session.created_at == original_created_at - ), "created_at timestamp should be preserved" - - def test_edit_session_updates_device_information( - self, sample_user_read, mock_request, password_hasher, mock_db - ): - """ - Test that the `session_utils.edit_session` function updates device information from the new request. - - This test verifies that: - - Device information (IP address, device type, OS, browser) is updated based on the new request. - - The session reflects the current request context after editing. - - Args: - sample_user_read: A sample user object used for testing. - mock_request: A mock request object simulating an HTTP request. - password_hasher: Fixture for password hashing utilities. - mock_db: Mocked database session. - - Asserts: - - Device information fields are set and not None after the edit. - """ - # Create an existing session - existing_session = MagicMock() - existing_session.id = "existing-session-device" - existing_session.user_id = sample_user_read.id - existing_session.refresh_token = "old-hashed-token" - existing_session.created_at = datetime.now(timezone.utc) - timedelta(days=1) - existing_session.expires_at = datetime.now(timezone.utc) + timedelta(days=29) - existing_session.ip_address = "192.168.1.1" - existing_session.device_type = "Mobile" - existing_session.operating_system = "iOS" - existing_session.browser = "Safari" - - new_refresh_token = "new-refresh-token" - - with patch("session.utils.session_crud.edit_session") as mock_edit: - session_utils.edit_session( - existing_session, - mock_request, - new_refresh_token, - password_hasher, - mock_db, - ) - - # Get the updated session object - updated_session = mock_edit.call_args[0][0] - - # Verify device information is set (updated from mock_request) - assert updated_session.ip_address is not None, "IP address should be set" - assert updated_session.device_type is not None, "Device type should be set" - assert ( - updated_session.operating_system is not None - ), "Operating system should be set" - assert updated_session.browser is not None, "Browser should be set"