Merge pull request #176 from christianlouis/copilot/add-auth-encryption-tests
test: add comprehensive auth and encryption test coverage
This commit is contained in:
+194
-4
@@ -1,9 +1,11 @@
|
||||
"""Tests for app/auth.py module."""
|
||||
|
||||
import hashlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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:
|
||||
@@ -57,14 +66,113 @@ 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
|
||||
|
||||
@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 +192,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"
|
||||
|
||||
+114
-2
@@ -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()
|
||||
@@ -229,3 +230,114 @@ 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 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 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 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 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 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 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
|
||||
|
||||
Reference in New Issue
Block a user