From 91755ee5e63dbf4674067dba07540ab45f5187af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:11:34 +0000 Subject: [PATCH 1/3] Initial plan From acb8951dd9e005c4ddd3e2745011c3eda300b39e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:16:55 +0000 Subject: [PATCH 2/3] test: expand auth and encryption test coverage with 299 new test lines Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- tests/test_auth.py | 190 ++++++++++++++++++++++++++++++++++++++- tests/test_encryption.py | 110 +++++++++++++++++++++++ 2 files changed, 299 insertions(+), 1 deletion(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 8b1d8523..c2090146 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,11 @@ """Tests for app/auth.py module.""" import hashlib +import os import pytest from unittest.mock import MagicMock, AsyncMock, patch from starlette.testclient import TestClient -from fastapi import Request +from fastapi import Request, status +from starlette.responses import RedirectResponse from app.auth import get_current_user, get_gravatar_url, require_login @@ -26,6 +28,13 @@ class TestGetCurrentUser: result = get_current_user(mock_request) assert result is None + def test_returns_none_when_user_is_none(self): + """Test that get_current_user returns None when user is None.""" + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": None} + result = get_current_user(mock_request) + assert result is None + @pytest.mark.unit class TestGetGravatarUrl: @@ -65,6 +74,103 @@ class TestRequireLogin: # When AUTH_ENABLED is False, the decorator returns the function unchanged assert decorated is my_func + @pytest.mark.asyncio + async def test_redirects_to_login_when_no_user(self): + """Test that require_login redirects to /login when no user in session and auth is enabled.""" + with patch("app.auth.AUTH_ENABLED", True): + # Import fresh to get patched AUTH_ENABLED + from app.auth import require_login + + @require_login + async def protected_endpoint(request: Request): + return {"message": "success"} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + mock_request.url.__str__ = MagicMock(return_value="http://test.com/protected") + + result = await protected_endpoint(mock_request) + + assert isinstance(result, RedirectResponse) + assert result.status_code == status.HTTP_302_FOUND + assert "/login" in str(result.headers.get("location")) + + @pytest.mark.asyncio + async def test_saves_redirect_url_when_not_authenticated(self): + """Test that require_login saves the original URL in session.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + async def protected_endpoint(request: Request): + return {"message": "success"} + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_request.url = MagicMock() + original_url = "http://test.com/protected/page?param=value" + mock_request.url.__str__ = MagicMock(return_value=original_url) + + await protected_endpoint(mock_request) + + assert mock_request.session.get("redirect_after_login") == original_url + + @pytest.mark.asyncio + async def test_allows_access_when_user_in_session(self): + """Test that require_login allows access when user is in session.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + async def protected_endpoint(request: Request): + return {"message": "success", "user": request.session.get("user")} + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"id": "test_user", "name": "Test"}} + + result = await protected_endpoint(mock_request) + + assert result["message"] == "success" + assert result["user"]["id"] == "test_user" + + @pytest.mark.asyncio + async def test_handles_async_functions(self): + """Test that require_login correctly wraps async functions.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + async def async_endpoint(request: Request, param: str): + return {"message": "async", "param": param} + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"id": "test"}} + + result = await async_endpoint(mock_request, param="test_value") + + assert result["message"] == "async" + assert result["param"] == "test_value" + + def test_handles_sync_functions(self): + """Test that require_login correctly wraps sync functions.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + def sync_endpoint(request: Request, param: str): + return {"message": "sync", "param": param} + + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"id": "test"}} + + # Call the decorated sync function + import asyncio + result = asyncio.run(sync_endpoint(request=mock_request, param="test_value")) + + assert result["message"] == "sync" + assert result["param"] == "test_value" + @pytest.mark.integration class TestWhoamiEndpoint: @@ -84,3 +190,85 @@ class TestWhoamiEndpoint: assert response.status_code == 200 data = response.json() assert "message" in data + + +@pytest.mark.integration +class TestAuthEndpoints: + """Integration tests for authentication endpoints.""" + + def test_login_page_not_available_when_auth_disabled(self, client): + """Test that login page returns 404 when auth is disabled (default in tests).""" + # In the test environment, AUTH_ENABLED is False by default + response = client.get("/login") + # When auth is disabled, the auth routes are not registered + assert response.status_code == 404 + + def test_oauth_login_not_available_when_auth_disabled(self, client): + """Test that /oauth-login returns 404 when auth is disabled.""" + response = client.get("/oauth-login") + assert response.status_code == 404 + + def test_logout_not_available_when_auth_disabled(self, client): + """Test that /logout returns 404 when auth is disabled.""" + response = client.get("/logout") + assert response.status_code == 404 + + def test_auth_post_not_available_when_auth_disabled(self, client): + """Test that POST /auth returns 404 when auth is disabled.""" + response = client.post("/auth", data={"username": "admin", "password": "test"}) + assert response.status_code == 404 + + +@pytest.mark.unit +class TestSessionValidation: + """Tests for session validation edge cases.""" + + def test_empty_user_object(self): + """Test get_current_user with empty user object.""" + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {}} + result = get_current_user(mock_request) + assert result == {} + + def test_user_object_missing_id(self): + """Test session with user missing id field.""" + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"name": "Test", "email": "test@example.com"}} + result = get_current_user(mock_request) + # Should still return the user object even if id is missing + assert result["name"] == "Test" + assert "id" not in result + + def test_user_object_with_extra_fields(self): + """Test session with user having extra fields.""" + mock_request = MagicMock(spec=Request) + user = { + "id": "123", + "name": "Test", + "email": "test@example.com", + "is_admin": True, + "groups": ["admin"], + "picture": "https://example.com/pic.jpg", + } + mock_request.session = {"user": user} + result = get_current_user(mock_request) + assert result == user + + @pytest.mark.asyncio + async def test_require_login_with_user_missing_required_fields(self): + """Test require_login with user object missing typical fields.""" + with patch("app.auth.AUTH_ENABLED", True): + from app.auth import require_login + + @require_login + async def protected_endpoint(request: Request): + return {"message": "success"} + + # User object exists but is minimal + mock_request = MagicMock(spec=Request) + mock_request.session = {"user": {"id": "123"}} # Missing name, email, etc. + + result = await protected_endpoint(mock_request) + + # Should still allow access as long as user key exists + assert result["message"] == "success" diff --git a/tests/test_encryption.py b/tests/test_encryption.py index 686d8ed8..16c2b9a2 100644 --- a/tests/test_encryption.py +++ b/tests/test_encryption.py @@ -229,3 +229,113 @@ class TestEncryptionIntegration: assert encrypted != original assert encrypted.startswith("enc:") assert decrypted == original + + def test_real_encryption_round_trip(self): + """Test actual encryption/decryption with real cryptography library.""" + from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available + + # Skip if encryption is not available + if not is_encryption_available(): + pytest.skip("Encryption not available (cryptography library not installed)") + + original_value = "my_super_secret_password_123" + + # Encrypt the value + encrypted = encrypt_value(original_value) + + # Should be encrypted (has enc: prefix) + assert encrypted.startswith("enc:") + assert encrypted != original_value + + # Decrypt should return original value + decrypted = decrypt_value(encrypted) + assert decrypted == original_value + + def test_encryption_with_special_characters(self): + """Test encryption with special characters and symbols.""" + from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available + + if not is_encryption_available(): + pytest.skip("Encryption not available") + + original = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`" + encrypted = encrypt_value(original) + decrypted = decrypt_value(encrypted) + + assert decrypted == original + + def test_encryption_with_unicode(self): + """Test encryption with unicode characters.""" + from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available + + if not is_encryption_available(): + pytest.skip("Encryption not available") + + original = "Hello 世界 🌍 Привет мир" + encrypted = encrypt_value(original) + decrypted = decrypt_value(encrypted) + + assert decrypted == original + + def test_encryption_with_long_string(self): + """Test encryption with very long strings.""" + from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available + + if not is_encryption_available(): + pytest.skip("Encryption not available") + + # Create a long string (1000 characters) + original = "A" * 1000 + encrypted = encrypt_value(original) + decrypted = decrypt_value(encrypted) + + assert decrypted == original + assert len(decrypted) == 1000 + + def test_encryption_with_newlines_and_whitespace(self): + """Test encryption preserves newlines and whitespace.""" + from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available + + if not is_encryption_available(): + pytest.skip("Encryption not available") + + original = "line1\n line2\t\ttabbed\r\nline3 " + encrypted = encrypt_value(original) + decrypted = decrypt_value(encrypted) + + assert decrypted == original + + def test_encryption_with_json_string(self): + """Test encryption with JSON string.""" + from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available + + if not is_encryption_available(): + pytest.skip("Encryption not available") + + original = '{"key": "value", "nested": {"array": [1, 2, 3]}}' + encrypted = encrypt_value(original) + decrypted = decrypt_value(encrypted) + + assert decrypted == original + + def test_multiple_encrypt_same_value_produces_different_ciphertext(self): + """Test that encrypting the same value twice produces different ciphertext (if using random IV).""" + from app.utils.encryption import encrypt_value, is_encryption_available + + if not is_encryption_available(): + pytest.skip("Encryption not available") + + original = "same_value" + encrypted1 = encrypt_value(original) + encrypted2 = encrypt_value(original) + + # Both should be encrypted + assert encrypted1.startswith("enc:") + assert encrypted2.startswith("enc:") + + # Fernet uses timestamp-based encryption, so they might be different + # (depending on timing). This test documents the behavior. + # We'll just verify both decrypt correctly + from app.utils.encryption import decrypt_value + assert decrypt_value(encrypted1) == original + assert decrypt_value(encrypted2) == original From 42cac76c0e8955946a51c74fdf56f0675928625b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:19:41 +0000 Subject: [PATCH 3/3] style: format test files with black and isort, remove unused imports Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- tests/test_auth.py | 58 ++++++++++++++++++----------------- tests/test_encryption.py | 66 +++++++++++++++++++++------------------- 2 files changed, 64 insertions(+), 60 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index c2090146..aaa6f389 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,9 +1,9 @@ """Tests for app/auth.py module.""" + import hashlib -import os +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import MagicMock, AsyncMock, patch -from starlette.testclient import TestClient from fastapi import Request, status from starlette.responses import RedirectResponse @@ -66,10 +66,11 @@ class TestRequireLogin: def test_noop_when_auth_disabled(self): """Test that require_login is a no-op when AUTH_ENABLED is False.""" + # AUTH_ENABLED is False in test environment def my_func(): return "hello" - + decorated = require_login(my_func) # When AUTH_ENABLED is False, the decorator returns the function unchanged assert decorated is my_func @@ -80,18 +81,18 @@ class TestRequireLogin: with patch("app.auth.AUTH_ENABLED", True): # Import fresh to get patched AUTH_ENABLED from app.auth import require_login - + @require_login async def protected_endpoint(request: Request): return {"message": "success"} - + mock_request = MagicMock(spec=Request) mock_request.session = {} mock_request.url = MagicMock() mock_request.url.__str__ = MagicMock(return_value="http://test.com/protected") - + result = await protected_endpoint(mock_request) - + assert isinstance(result, RedirectResponse) assert result.status_code == status.HTTP_302_FOUND assert "/login" in str(result.headers.get("location")) @@ -101,19 +102,19 @@ class TestRequireLogin: """Test that require_login saves the original URL in session.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login - + @require_login async def protected_endpoint(request: Request): return {"message": "success"} - + mock_request = MagicMock(spec=Request) mock_request.session = {} mock_request.url = MagicMock() original_url = "http://test.com/protected/page?param=value" mock_request.url.__str__ = MagicMock(return_value=original_url) - + await protected_endpoint(mock_request) - + assert mock_request.session.get("redirect_after_login") == original_url @pytest.mark.asyncio @@ -121,16 +122,16 @@ class TestRequireLogin: """Test that require_login allows access when user is in session.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login - + @require_login async def protected_endpoint(request: Request): return {"message": "success", "user": request.session.get("user")} - + mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test_user", "name": "Test"}} - + result = await protected_endpoint(mock_request) - + assert result["message"] == "success" assert result["user"]["id"] == "test_user" @@ -139,16 +140,16 @@ class TestRequireLogin: """Test that require_login correctly wraps async functions.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login - + @require_login async def async_endpoint(request: Request, param: str): return {"message": "async", "param": param} - + mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test"}} - + result = await async_endpoint(mock_request, param="test_value") - + assert result["message"] == "async" assert result["param"] == "test_value" @@ -156,18 +157,19 @@ class TestRequireLogin: """Test that require_login correctly wraps sync functions.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login - + @require_login def sync_endpoint(request: Request, param: str): return {"message": "sync", "param": param} - + mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "test"}} - + # Call the decorated sync function import asyncio + result = asyncio.run(sync_endpoint(request=mock_request, param="test_value")) - + assert result["message"] == "sync" assert result["param"] == "test_value" @@ -259,16 +261,16 @@ class TestSessionValidation: """Test require_login with user object missing typical fields.""" with patch("app.auth.AUTH_ENABLED", True): from app.auth import require_login - + @require_login async def protected_endpoint(request: Request): return {"message": "success"} - + # User object exists but is minimal mock_request = MagicMock(spec=Request) mock_request.session = {"user": {"id": "123"}} # Missing name, email, etc. - + result = await protected_endpoint(mock_request) - + # Should still allow access as long as user key exists assert result["message"] == "success" diff --git a/tests/test_encryption.py b/tests/test_encryption.py index 16c2b9a2..5c059b10 100644 --- a/tests/test_encryption.py +++ b/tests/test_encryption.py @@ -4,8 +4,9 @@ Tests for app/utils/encryption.py Tests encryption/decryption functionality for sensitive settings. """ +from unittest.mock import Mock, patch + import pytest -from unittest.mock import Mock, patch, MagicMock @pytest.mark.unit @@ -205,7 +206,7 @@ class TestEncryptionIntegration: @patch("app.utils.encryption._get_cipher_suite") def test_encrypt_decrypt_cycle(self, mock_cipher): """Test that encrypting and then decrypting returns original value""" - from app.utils.encryption import encrypt_value, decrypt_value + from app.utils.encryption import decrypt_value, encrypt_value # Mock a simple reversible encryption mock_fernet = Mock() @@ -232,110 +233,111 @@ class TestEncryptionIntegration: def test_real_encryption_round_trip(self): """Test actual encryption/decryption with real cryptography library.""" - from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available - + from app.utils.encryption import decrypt_value, encrypt_value, is_encryption_available + # Skip if encryption is not available if not is_encryption_available(): pytest.skip("Encryption not available (cryptography library not installed)") - + original_value = "my_super_secret_password_123" - + # Encrypt the value encrypted = encrypt_value(original_value) - + # Should be encrypted (has enc: prefix) assert encrypted.startswith("enc:") assert encrypted != original_value - + # Decrypt should return original value decrypted = decrypt_value(encrypted) assert decrypted == original_value def test_encryption_with_special_characters(self): """Test encryption with special characters and symbols.""" - from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available - + from app.utils.encryption import decrypt_value, encrypt_value, is_encryption_available + if not is_encryption_available(): pytest.skip("Encryption not available") - + original = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`" encrypted = encrypt_value(original) decrypted = decrypt_value(encrypted) - + assert decrypted == original def test_encryption_with_unicode(self): """Test encryption with unicode characters.""" - from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available - + from app.utils.encryption import decrypt_value, encrypt_value, is_encryption_available + if not is_encryption_available(): pytest.skip("Encryption not available") - + original = "Hello 世界 🌍 Привет мир" encrypted = encrypt_value(original) decrypted = decrypt_value(encrypted) - + assert decrypted == original def test_encryption_with_long_string(self): """Test encryption with very long strings.""" - from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available - + from app.utils.encryption import decrypt_value, encrypt_value, is_encryption_available + if not is_encryption_available(): pytest.skip("Encryption not available") - + # Create a long string (1000 characters) original = "A" * 1000 encrypted = encrypt_value(original) decrypted = decrypt_value(encrypted) - + assert decrypted == original assert len(decrypted) == 1000 def test_encryption_with_newlines_and_whitespace(self): """Test encryption preserves newlines and whitespace.""" - from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available - + from app.utils.encryption import decrypt_value, encrypt_value, is_encryption_available + if not is_encryption_available(): pytest.skip("Encryption not available") - + original = "line1\n line2\t\ttabbed\r\nline3 " encrypted = encrypt_value(original) decrypted = decrypt_value(encrypted) - + assert decrypted == original def test_encryption_with_json_string(self): """Test encryption with JSON string.""" - from app.utils.encryption import encrypt_value, decrypt_value, is_encryption_available - + from app.utils.encryption import decrypt_value, encrypt_value, is_encryption_available + if not is_encryption_available(): pytest.skip("Encryption not available") - + original = '{"key": "value", "nested": {"array": [1, 2, 3]}}' encrypted = encrypt_value(original) decrypted = decrypt_value(encrypted) - + assert decrypted == original def test_multiple_encrypt_same_value_produces_different_ciphertext(self): """Test that encrypting the same value twice produces different ciphertext (if using random IV).""" from app.utils.encryption import encrypt_value, is_encryption_available - + if not is_encryption_available(): pytest.skip("Encryption not available") - + original = "same_value" encrypted1 = encrypt_value(original) encrypted2 = encrypt_value(original) - + # Both should be encrypted assert encrypted1.startswith("enc:") assert encrypted2.startswith("enc:") - + # Fernet uses timestamp-based encryption, so they might be different # (depending on timing). This test documents the behavior. # We'll just verify both decrypt correctly from app.utils.encryption import decrypt_value + assert decrypt_value(encrypted1) == original assert decrypt_value(encrypted2) == original