| """ |
| Tests for the security app β Phase 2. |
| |
| Covers MFA setup/verify, session management, rate limiting, |
| backup codes, and trusted devices. |
| """ |
| from django.test import TestCase, override_settings |
| from django.urls import reverse |
| from django.contrib.auth import get_user_model |
| import jwt |
|
|
| from apps.accounts.services import register_user |
| from apps.security.models import ( |
| BackupCode, |
| MFAFactor, |
| RateLimit, |
| SecurityEvent, |
| Session, |
| TrustedDevice, |
| ) |
| from apps.security.services import ( |
| check_rate_limit, |
| create_session, |
| disable_totp, |
| generate_backup_codes, |
| get_remaining_backup_codes_count, |
| global_logout, |
| is_mfa_enabled, |
| is_trusted_device, |
| record_attempt, |
| record_success, |
| register_trusted_device, |
| revoke_all_trusted_devices, |
| revoke_session, |
| setup_totp, |
| verify_backup_code, |
| jwt_service, |
| ) |
| from core.exceptions import RateLimitError, AuthenticationError |
| from core.services.encryption import decrypt_field, encrypt_field |
|
|
| User = get_user_model() |
|
|
|
|
| |
|
|
|
|
| class EncryptionTests(TestCase): |
| """Test core encryption service.""" |
|
|
| def test_encrypt_decrypt_roundtrip(self): |
| """Encrypting then decrypting returns original value.""" |
| original = "my_secret_totp_key_abc123" |
| encrypted = encrypt_field(original) |
| self.assertNotEqual(encrypted, original) |
| decrypted = decrypt_field(encrypted) |
| self.assertEqual(decrypted, original) |
|
|
| def test_tampered_ciphertext_fails(self): |
| """Tampered ciphertext raises ValueError.""" |
| encrypted = encrypt_field("test_secret") |
| tampered = encrypted[:-3] + "XXX" |
| with self.assertRaises(Exception): |
| decrypt_field(tampered) |
|
|
|
|
| |
|
|
|
|
| class MFASetupTests(TestCase): |
| """Test TOTP MFA setup flow.""" |
|
|
| def setUp(self): |
| self.user = register_user("mfa@example.com", "StrongPass123!", org_name="Test Org", business_type="SaaS") |
|
|
| def test_setup_totp_returns_secret(self): |
| """Setup returns secret and factor ID.""" |
| data = setup_totp(self.user) |
| self.assertIn("secret", data) |
| self.assertIn("factor_id", data) |
| self.assertEqual(data["account"], "mfa@example.com") |
|
|
| def test_mfa_not_enabled_after_setup(self): |
| """MFA is not enabled until verified.""" |
| setup_totp(self.user) |
| self.assertFalse(is_mfa_enabled(self.user)) |
|
|
| def test_disable_totp(self): |
| """Disabling removes enabled flag.""" |
| setup_totp(self.user) |
| disable_totp(self.user) |
| self.assertFalse(is_mfa_enabled(self.user)) |
|
|
|
|
| |
|
|
|
|
| class BackupCodeTests(TestCase): |
| """Test backup code generation and verification.""" |
|
|
| def setUp(self): |
| self.user = register_user("backup@example.com", "StrongPass123!", org_name="Test Org", business_type="SaaS") |
|
|
| def test_generate_codes_returns_10(self): |
| """Generates 10 backup codes.""" |
| codes = generate_backup_codes(self.user) |
| self.assertEqual(len(codes), 10) |
|
|
| def test_verify_backup_code_success(self): |
| """Valid backup code is accepted and consumed.""" |
| codes = generate_backup_codes(self.user) |
| self.assertTrue(verify_backup_code(self.user, codes[0])) |
| |
| self.assertFalse(verify_backup_code(self.user, codes[0])) |
|
|
| def test_invalid_backup_code_rejected(self): |
| """Invalid backup code is rejected.""" |
| generate_backup_codes(self.user) |
| self.assertFalse(verify_backup_code(self.user, "XXXX-YYYY")) |
|
|
| def test_remaining_count_decreases(self): |
| """Remaining count goes down after using a code.""" |
| codes = generate_backup_codes(self.user) |
| initial = get_remaining_backup_codes_count(self.user) |
| verify_backup_code(self.user, codes[0]) |
| self.assertEqual(get_remaining_backup_codes_count(self.user), initial - 1) |
|
|
| def test_regenerate_invalidates_old(self): |
| """Regenerating creates new codes and invalidates old ones.""" |
| old_codes = generate_backup_codes(self.user) |
| new_codes = generate_backup_codes(self.user) |
| self.assertFalse(verify_backup_code(self.user, old_codes[0])) |
| self.assertTrue(verify_backup_code(self.user, new_codes[0])) |
|
|
|
|
| |
|
|
|
|
| class SessionTests(TestCase): |
| """Test session management.""" |
|
|
| def setUp(self): |
| self.user = register_user("session@example.com", "StrongPass123!", org_name="Test Org", business_type="SaaS") |
|
|
| def test_create_session(self): |
| """Creating a session returns tokens.""" |
| result = create_session( |
| self.user, |
| ip_address="127.0.0.1", |
| user_agent="TestBrowser", |
| ) |
| self.assertIn("session_id", result) |
| self.assertIn("access_token", result) |
| self.assertIn("refresh_token", result) |
| |
| |
| payload = jwt_service.verify_access_token(result["access_token"]) |
| self.assertEqual(payload["sub"], str(self.user.id)) |
|
|
| def test_session_is_active(self): |
| """New session is active.""" |
| result = create_session(self.user) |
| session = Session.objects.get(pk=result["session_id"]) |
| self.assertTrue(session.is_active) |
|
|
| def test_revoke_session(self): |
| """Revoking a session marks it inactive.""" |
| result = create_session(self.user) |
| revoke_session(result["session_id"], self.user) |
| session = Session.objects.get(pk=result["session_id"]) |
| self.assertFalse(session.is_active) |
|
|
| def test_global_logout(self): |
| """Global logout revokes all sessions.""" |
| create_session(self.user) |
| create_session(self.user) |
| count = global_logout(self.user) |
| self.assertEqual(count, 2) |
| active = Session.objects.filter(user=self.user, revoked_at__isnull=True).count() |
| self.assertEqual(active, 0) |
|
|
| def test_security_event_logged_on_session_create(self): |
| """Session creation logs a security event.""" |
| create_session(self.user, ip_address="10.0.0.1") |
| event = SecurityEvent.objects.filter( |
| user=self.user, |
| event_type=SecurityEvent.EventType.LOGIN_SUCCESS, |
| ).first() |
| self.assertIsNotNone(event) |
| self.assertEqual(event.metadata["ip_address"], "10.0.0.1") |
|
|
|
|
| |
|
|
|
|
| class RateLimitTests(TestCase): |
| """Test rate limiting logic.""" |
|
|
| def test_no_block_under_threshold(self): |
| """No error when under the limit.""" |
| check_rate_limit("test:key:1") |
|
|
| def test_block_after_threshold(self): |
| """Blocked after exceeding max attempts.""" |
| key = "test:key:2" |
| for _ in range(5): |
| record_attempt(key, max_attempts=5) |
| with self.assertRaises(RateLimitError): |
| check_rate_limit(key) |
|
|
| def test_success_resets_counter(self): |
| """Successful action resets the counter.""" |
| key = "test:key:3" |
| for _ in range(3): |
| record_attempt(key, max_attempts=5) |
| record_success(key) |
| check_rate_limit(key) |
|
|
|
|
| |
|
|
|
|
| class TrustedDeviceTests(TestCase): |
| """Test trusted device management.""" |
|
|
| def setUp(self): |
| self.user = register_user("device@example.com", "StrongPass123!", org_name="Test Org", business_type="SaaS") |
|
|
| def test_register_and_check_device(self): |
| """Registering a device makes it trusted.""" |
| register_trusted_device(self.user, "device-abc-123") |
| self.assertTrue(is_trusted_device(self.user, "device-abc-123")) |
|
|
| def test_unknown_device_not_trusted(self): |
| """Unknown device is not trusted.""" |
| self.assertFalse(is_trusted_device(self.user, "unknown-device")) |
|
|
| def test_revoke_all_devices(self): |
| """Revoking all devices clears the list.""" |
| register_trusted_device(self.user, "dev-1") |
| register_trusted_device(self.user, "dev-2") |
| count = revoke_all_trusted_devices(self.user) |
| self.assertEqual(count, 2) |
| self.assertFalse(is_trusted_device(self.user, "dev-1")) |
|
|
|
|
| |
|
|
|
|
| class SecurityURLTests(TestCase): |
| """Test URL routing.""" |
|
|
| def test_dashboard_url(self): |
| url = reverse("security:dashboard") |
| self.assertEqual(url, "/security/") |
|
|
| def test_mfa_setup_url(self): |
| url = reverse("security:mfa-setup") |
| self.assertEqual(url, "/security/mfa/setup/") |
|
|
| def test_sessions_url(self): |
| url = reverse("security:sessions") |
| self.assertEqual(url, "/security/sessions/") |
|
|
|
|
| |
|
|
|
|
| class JWTServiceTests(TestCase): |
| """Test JWT service directly.""" |
|
|
| def setUp(self): |
| self.user = User.objects.create_user( |
| email="jwt@example.com", |
| password="password123" |
| ) |
|
|
| def test_create_access_token(self): |
| token = jwt_service.create_access_token(self.user) |
| self.assertIsInstance(token, str) |
| |
| payload = jwt_service.verify_access_token(token) |
| self.assertEqual(payload["sub"], str(self.user.id)) |
| self.assertIn("exp", payload) |
|
|
| def test_verify_invalid_token(self): |
| with self.assertRaises(AuthenticationError): |
| jwt_service.verify_access_token("invalid.token.here") |
|
|
| def test_verify_expired_token(self): |
| with override_settings(JWT_ACCESS_EXPIRY_MINUTES=-1): |
| token = jwt_service.create_access_token(self.user) |
| with self.assertRaises(AuthenticationError) as cm: |
| jwt_service.verify_access_token(token) |
| self.assertEqual(str(cm.exception), "Token has expired.") |
|
|
| def test_verify_tampered_token(self): |
| token = jwt_service.create_access_token(self.user) |
| tampered_token = token[:-1] + ("0" if token[-1] != "0" else "1") |
| with self.assertRaises(AuthenticationError): |
| jwt_service.verify_access_token(tampered_token) |
|
|