# #region TestSecurityOrthogonal [C:3] [TYPE Module] [SEMANTICS test, security, orthogonal, edge-cases] # @BRIEF Orthogonal security tests for critical auth/encryption modules. # Tests edge cases and invariants that existing tests miss: # - bcrypt 72-char limit, unicode passwords # - API key format validation, edge characters # - Fernet encryption: invalid ciphertext, empty data, corruption # - Encryption key lifecycle: missing .env, unwritable dir # - Log injection: CRLF in username, long messages # - JWT: expired, malformed, missing claims # @RELATION BINDS_TO -> [AuthSecurityModule] # @RELATION BINDS_TO -> [APIKeyUtilities] # @RELATION BINDS_TO -> [EncryptionCore] # @RELATION BINDS_TO -> [EncryptionKeyModule] # @RELATION BINDS_TO -> [AuthLoggerModule] # @TEST_CONTRACT: PasswordSecurity -> edge-case password handling # @TEST_CONTRACT: ApiKeyFormat -> key structure invariants # @TEST_CONTRACT: EncryptionRobustness -> error handling on invalid data # @TEST_CONTRACT: KeyLifecycle -> encryption key resolution paths # @TEST_CONTRACT: LogInjectionProtection -> CRLF/control chars in log input # @TEST_INVARIANT: bcrypt_truncation -> bcrypt silently truncates at 72 bytes # @TEST_INVARIANT: api_key_format -> ssk_ prefix + hash uniqueness # @TEST_INVARIANT: fernet_symmetric -> encrypt/decrypt is reversible # @TEST_INVARIANT: log_no_injection -> CRLF in input doesn't forge log entries import os from pathlib import Path import pytest import tempfile from unittest.mock import patch # ────────────────────────────────────────────── # AUTH: Password security edge cases # ────────────────────────────────────────────── class TestPasswordSecurity: """Orthogonal tests for password hashing — edge cases bcrypt doesn't handle well.""" # #region test_password_bcrypt_72_byte_limit [C:2] [TYPE Function] # @BRIEF bcrypt truncates passwords longer than 72 bytes — the first 72 bytes # determine the hash. A password of 73 bytes where byte 73 differs should # produce the SAME hash. # @TEST_EDGE: password_over_72_bytes -> bcrypt silently truncates def test_password_bcrypt_72_byte_limit(self): """bcrypt truncates at 72 bytes: passwords differing only after byte 72 match.""" from src.core.auth.security import get_password_hash, verify_password # 80-byte passwords differing only at position 73+ pwd1 = "A" * 72 + "B" * 8 pwd2 = "A" * 72 + "C" * 8 h1 = get_password_hash(pwd1) assert verify_password(pwd2, h1) # bcrypt sees same first 72 bytes # #endregion test_password_bcrypt_72_byte_limit # #region test_password_unicode_normalization [C:2] [TYPE Function] # @BRIEF Unicode composed vs decomposed forms — bcrypt operates on bytes, # so "é" (U+00E9) != "e" + combining accent (U+0065 U+0301). # @TEST_EDGE: unicode_decomposition -> different byte sequences fail def test_password_unicode_normalization(self): """Unicode normalization: NFC != NFD produces different hashes.""" import unicodedata from src.core.auth.security import get_password_hash, verify_password nfc = unicodedata.normalize("NFC", "café") # single codepoint é nfd = unicodedata.normalize("NFD", "café") # e + combining accent assert nfc != nfd # different byte sequences h = get_password_hash(nfc) assert verify_password(nfd, h) is False # should NOT match # #endregion test_password_unicode_normalization # #region test_password_empty_and_whitespace [C:2] [TYPE Function] # @BRIEF Empty password, space-only password, null hash. def test_password_empty_and_whitespace(self): from src.core.auth.security import get_password_hash, verify_password # Empty password — should hash and verify h = get_password_hash("") assert verify_password("", h) # Space-only password h = get_password_hash(" ") assert verify_password(" ", h) # Verify against None — should return False, not crash assert not verify_password("password", None) assert not verify_password("password", "") # #endregion test_password_empty_and_whitespace # ────────────────────────────────────────────── # AUTH: API key format invariants # ────────────────────────────────────────────── class TestApiKeyFormat: """Orthogonal tests for API key generation — format invariants and edge cases.""" # #region test_api_key_format_invariants [C:2] [TYPE Function] # @BRIEF Every generated key must start with ssk_, prefix is exactly 11 chars, # key_hash is 64 hex chars, raw_key contains only URL-safe base64. def test_api_key_format_invariants(self): from src.core.auth.api_key import generate_api_key for _ in range(100): raw, prefix, key_hash = generate_api_key() assert raw.startswith("ssk_"), f"Key must start with ssk_: {raw[:10]}" assert len(prefix) == 11, f"Prefix must be 11 chars: {prefix}" assert prefix == raw[:11], f"Prefix mismatch: {prefix} != {raw[:11]}" assert len(key_hash) == 64, f"Hash must be 64 hex chars: {key_hash}" int(key_hash, 16) # must be valid hex — raises ValueError if not # Raw key is URL-safe base64 after ssk_ prefix body = raw[4:] # after "ssk_" import string allowed = set(string.ascii_letters + string.digits + "-_") assert all(c in allowed for c in body), f"Invalid chars in key body: {body[:10]}" # #endregion test_api_key_format_invariants # #region test_api_key_hash_uniqueness [C:2] [TYPE Function] # @BRIEF 1000 generated keys must all have unique raw keys and unique hashes. def test_api_key_hash_uniqueness(self): from src.core.auth.api_key import generate_api_key raws = set() hashes = set() prefixes = set() for _ in range(1000): raw, prefix, key_hash = generate_api_key() raws.add(raw) hashes.add(key_hash) prefixes.add(prefix) assert len(raws) == 1000 # all raws unique assert len(hashes) == 1000 # all hashes unique assert len(prefixes) <= 1000 # prefixes may collide (only 7 chars) # #endregion test_api_key_hash_uniqueness # #region test_hash_api_key_consistency [C:2] [TYPE Function] # @BRIEF hash_api_key is deterministic — same input = same output. def test_hash_api_key_consistency(self): from src.core.auth.api_key import hash_api_key key = "ssk_test-key-for-consistency-check-123" h1 = hash_api_key(key) h2 = hash_api_key(key) assert h1 == h2 assert len(h1) == 64 # #endregion test_hash_api_key_consistency # ────────────────────────────────────────────── # ENCRYPTION: Fernet robustness # ────────────────────────────────────────────── class TestEncryptionRobustness: """Orthogonal tests for EncryptionManager — error handling on invalid data.""" @pytest.fixture(autouse=True) def _setup_key(self): """Ensure ENCRYPTION_KEY is set.""" if not os.getenv("ENCRYPTION_KEY"): from cryptography.fernet import Fernet os.environ["ENCRYPTION_KEY"] = Fernet.generate_key().decode() # #region test_encrypt_decrypt_empty [C:2] [TYPE Function] # @BRIEF Empty string should encrypt and decrypt back to empty string. def test_encrypt_decrypt_empty(self): from src.core.encryption import EncryptionManager mgr = EncryptionManager() encrypted = mgr.encrypt("") decrypted = mgr.decrypt(encrypted) assert decrypted == "" # #endregion test_encrypt_decrypt_empty # #region test_decrypt_invalid_data [C:2] [TYPE Function] # @BRIEF Decrypting garbage, wrong key, truncated data must raise. def test_decrypt_invalid_data(self): from src.core.encryption import EncryptionManager mgr = EncryptionManager() # Garbage data with pytest.raises(Exception): mgr.decrypt("not-encrypted-data") # Empty string with pytest.raises(Exception): mgr.decrypt("") # Truncated valid token valid = mgr.encrypt("test") with pytest.raises(Exception): mgr.decrypt(valid[:-10]) # #endregion test_decrypt_invalid_data # #region test_encrypt_decrypt_large_data [C:2] [TYPE Function] # @BRIEF Large strings (1MB) should encrypt/decrypt without error. def test_encrypt_decrypt_large_data(self): from src.core.encryption import EncryptionManager mgr = EncryptionManager() large = "x" * (1024 * 1024) # 1MB encrypted = mgr.encrypt(large) decrypted = mgr.decrypt(encrypted) assert decrypted == large # #endregion test_encrypt_decrypt_large_data # ────────────────────────────────────────────── # ENCRYPTION KEY: lifecycle paths # ────────────────────────────────────────────── class TestEncryptionKeyLifecycle: """Orthogonal tests for ensure_encryption_key — resolution strategy.""" # #region test_ensure_encryption_key_from_env [C:2] [TYPE Function] # @BRIEF When ENCRYPTION_KEY is set in environment, return it without side effects. def test_ensure_encryption_key_from_env(self): from cryptography.fernet import Fernet from src.core.encryption_key import ensure_encryption_key expected = Fernet.generate_key().decode() with patch.dict(os.environ, {"ENCRYPTION_KEY": expected}, clear=False): # Ensure no .env file is accessed — use a non-existent path result = ensure_encryption_key(Path("/nonexistent/.env")) assert result == expected # #endregion test_ensure_encryption_key_from_env # #region test_ensure_encryption_key_missing_raises [C:2] [TYPE Function] # @BRIEF When neither env nor .env has a key, crash-early with RuntimeError. # @TEST_EDGE: missing_encryption_key -> RuntimeError with clear message def test_ensure_encryption_key_missing_raises(self): from src.core.encryption_key import ensure_encryption_key with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: f.write("# empty .env\n") env_path = Path(f.name) try: old = os.environ.pop("ENCRYPTION_KEY", None) try: with pytest.raises(RuntimeError, match="ENCRYPTION_KEY"): ensure_encryption_key(env_path) finally: if old is not None: os.environ["ENCRYPTION_KEY"] = old finally: env_path.unlink(missing_ok=True) # #endregion test_ensure_encryption_key_missing_raises # #region test_ensure_encryption_key_loads_from_file [C:2] [TYPE Function] # @BRIEF When env is empty but .env file has the key, load it. def test_ensure_encryption_key_loads_from_file(self): from cryptography.fernet import Fernet from src.core.encryption_key import ensure_encryption_key key = Fernet.generate_key().decode() old = os.environ.pop("ENCRYPTION_KEY", None) try: with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: f.write(f"ENCRYPTION_KEY={key}\n") env_path = Path(f.name) try: result = ensure_encryption_key(env_path) assert result == key assert os.environ["ENCRYPTION_KEY"] == key finally: env_path.unlink(missing_ok=True) finally: if old is not None: os.environ["ENCRYPTION_KEY"] = old # #endregion test_ensure_encryption_key_loads_from_file # ────────────────────────────────────────────── # AUTH LOGGER: log injection protection # ────────────────────────────────────────────── class TestLogInjectionProtection: """Orthogonal tests for auth logger — verify CRLF/control chars don't forge logs.""" # #region test_log_security_event_injection [C:2] [TYPE Function] # @BRIEF CRLF in username or event_type must not create forged log entries. # Our fix uses %s-formatting which prevents CRLF injection. def test_log_security_event_injection(self): from src.core.auth.logger import log_security_event # Username with embedded newline — this would forge a log entry with plain f-strings malicious = "admin\n[AUDIT][FAKE] User: attacker" # Should not raise, should not create a fake entry log_security_event("LOGIN_SUCCESS", malicious, {"source": "test"}) # (We verify by checking that no exception occurs — actual log inspection # would require a log handler fixture) # #endregion test_log_security_event_injection # #region test_log_security_event_long_input [C:2] [TYPE Function] # @BRIEF Very long username/event_type must not crash the logger. def test_log_security_event_long_input(self): from src.core.auth.logger import log_security_event long_username = "u" * 10000 log_security_event("TEST_EVENT", long_username, {"detail": "x" * 10000}) # #endregion test_log_security_event_long_input # #region test_log_security_event_special_chars [C:2] [TYPE Function] # @BRIEF Unicode, control chars, emoji in username must not break logging. def test_log_security_event_special_chars(self): from src.core.auth.logger import log_security_event log_security_event("LOGIN_SUCCESS", "user@domain.com\u0000null_bytes") log_security_event("LOGIN_SUCCESS", "user\u0001\u0002\u001fcontrol_chars") log_security_event("LOGIN_SUCCESS", "user🔥emoji") log_security_event("LOGIN_SUCCESS", "пользователь_кириллица") # #endregion test_log_security_event_special_chars # ────────────────────────────────────────────── # DEPENDENCIES: JWT edge cases # ────────────────────────────────────────────── class TestJwtEdgeCases: """Orthogonal tests for JWT token handling — edge cases in decode/validation.""" # #region test_decode_expired_token [C:2] [TYPE Function] # @BRIEF Token with past expiration must raise JWTError. def test_decode_expired_token(self): # Create an already-expired token import time from jose import JWTError, jwt from src.core.auth.config import auth_config payload = {"sub": "testuser", "exp": int(time.time()) - 3600} # 1 hour ago token = jwt.encode(payload, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM) from src.core.auth.jwt import decode_token with pytest.raises(JWTError): decode_token(token) # #endregion test_decode_expired_token # #region test_decode_malformed_token [C:2] [TYPE Function] # @BRIEF Malformed JWT (wrong format, wrong key) must raise JWTError. def test_decode_malformed_token(self): from jose import JWTError from src.core.auth.jwt import decode_token with pytest.raises(JWTError): decode_token("not.a.token") with pytest.raises(JWTError): decode_token("") # Token with wrong algorithm from jose import jwt token = jwt.encode({"sub": "test"}, "wrong-key", algorithm="HS256") with pytest.raises(JWTError): decode_token(token) # #endregion test_decode_malformed_token # #region test_decode_token_missing_sub [C:2] [TYPE Function] # @BRIEF Token without 'sub' claim should decode but downstream should handle. def test_decode_token_missing_sub(self): from src.core.auth.jwt import create_access_token, decode_token token = create_access_token(data={"role": "admin"}) # no 'sub' payload = decode_token(token) assert "sub" not in payload assert payload.get("role") == "admin" # #endregion test_decode_token_missing_sub # ────────────────────────────────────────────── # RATE LIMITER # ────────────────────────────────────────────── class TestRateLimiter: """Orthogonal tests for in-memory rate limiter.""" # #region test_rate_limiter_allows_under_limit [C:2] [TYPE Function] # @BRIEF Under limit: not banned, attempts recorded. def test_rate_limiter_allows_under_limit(self): from src.core.rate_limiter import RateLimiter rl = RateLimiter() assert not rl.is_banned("1.2.3.4") for _ in range(5): rl.record_attempt("1.2.3.4") assert not rl.is_banned("1.2.3.4") # #endregion test_rate_limiter_allows_under_limit # #region test_rate_limiter_bans_over_limit [C:2] [TYPE Function] # @BRIEF Over limit: IP is banned, success clears history. def test_rate_limiter_bans_over_limit(self): from src.core.rate_limiter import MAX_ATTEMPTS, RateLimiter rl = RateLimiter() ip = "5.6.7.8" for _ in range(MAX_ATTEMPTS + 1): rl.record_attempt(ip) assert rl.is_banned(ip) # After ban, success should clear (but may still be banned) rl.record_success(ip) # #endregion test_rate_limiter_bans_over_limit # #region test_rate_limiter_success_clears_history [C:2] [TYPE Function] # @BRIEF Successful auth clears attempt history for that IP. def test_rate_limiter_success_clears_history(self): from src.core.rate_limiter import RateLimiter rl = RateLimiter() rl.record_attempt("9.9.9.9") rl.record_attempt("9.9.9.9") rl.record_success("9.9.9.9") # After success, the attempts dict is cleared — we can make more without ban for _ in range(5): rl.record_attempt("9.9.9.9") assert not rl.is_banned("9.9.9.9") # #endregion test_rate_limiter_success_clears_history # #region test_rate_limiter_different_ips_independent [C:2] [TYPE Function] # @BRIEF Different IPs are tracked independently. def test_rate_limiter_different_ips_independent(self): from src.core.rate_limiter import MAX_ATTEMPTS, RateLimiter rl = RateLimiter() # One IP gets banned for _ in range(MAX_ATTEMPTS + 1): rl.record_attempt("bad-ip") assert rl.is_banned("bad-ip") # Other IP is not affected assert not rl.is_banned("good-ip") # #endregion test_rate_limiter_different_ips_independent # ────────────────────────────────────────────── # PASSWORD POLICY # ────────────────────────────────────────────── class TestPasswordPolicy: """Orthogonal tests for password strength validation.""" # #region test_password_too_short [C:2] [TYPE Function] def test_password_too_short(self): from pydantic import ValidationError from src.schemas.auth import UserCreate with pytest.raises(ValidationError): UserCreate(username="test", password="Ab1") # only 3 chars # #endregion test_password_too_short # #region test_password_no_uppercase [C:2] [TYPE Function] def test_password_no_uppercase(self): from pydantic import ValidationError from src.schemas.auth import UserCreate with pytest.raises(ValidationError): UserCreate(username="test", password="abcdefgh1") # no uppercase # #endregion test_password_no_uppercase # #region test_password_no_digit [C:2] [TYPE Function] def test_password_no_digit(self): from pydantic import ValidationError from src.schemas.auth import UserCreate with pytest.raises(ValidationError): UserCreate(username="test", password="Abcdefgh") # no digit # #endregion test_password_no_digit # #region test_password_valid [C:2] [TYPE Function] def test_password_valid(self): from src.schemas.auth import UserCreate u = UserCreate(username="test", password="ValidPass1") assert u.password == "ValidPass1" # #endregion test_password_valid # ────────────────────────────────────────────── # LOG MASKING # ────────────────────────────────────────────── class TestLogMasking: """Orthogonal tests for sensitive field masking in log_security_event.""" # #region test_mask_sensitive_fields [C:2] [TYPE Function] def test_mask_sensitive_fields(self): from src.core.auth.logger import _mask_details masked = _mask_details({ "username": "john", "password": "supersecret", "token": "eyJhbGciOiJIUzI1NiJ9.xxx", "api_key": "ssk_abc123", "source": "LOCAL", }) assert masked["username"] == "john" assert masked["password"] == "***" assert masked["token"] == "***" assert masked["api_key"] == "***" assert masked["source"] == "LOCAL" # #endregion test_mask_sensitive_fields # #region test_mask_nested_details [C:2] [TYPE Function] def test_mask_nested_details(self): from src.core.auth.logger import _mask_details masked = _mask_details({ "user": {"username": "john", "password": "secret"}, }) assert masked["user"]["username"] == "john" assert masked["user"]["password"] == "***" # #endregion test_mask_nested_details # #endregion TestSecurityOrthogonal