Saas-Base / apps /security /tests.py
rsnarsna
fifth commit
bccb680
Raw
History Blame Contribute Delete
11.4 kB
"""
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()
# ─── Encryption Tests ────────────────────────────────────────────────────────
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)
# ─── MFA Tests ───────────────────────────────────────────────────────────────
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))
# ─── Backup Codes Tests ─────────────────────────────────────────────────────
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]))
# Same code should not work again
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]))
# ─── Session Tests ───────────────────────────────────────────────────────────
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)
# Verify access_token is a valid JWT
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")
# ─── Rate Limit Tests ───────────────────────────────────────────────────────
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") # Should not raise
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) # Should not raise
# ─── Trusted Device Tests ───────────────────────────────────────────────────
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"))
# ─── URL Tests ───────────────────────────────────────────────────────────────
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/")
# ─── JWT Tests ───────────────────────────────────────────────────────────────
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)