"""Tests for security module.""" import pytest from app.infra.security import ( hash_password, verify_password, create_access_token, create_refresh_token, decode_access_token, decode_refresh_token, get_subject, ) class TestPasswordHashing: """Test password hashing and verification.""" def test_hash_password_creates_hash(self): """Test that hash_password creates a valid Argon2 hash.""" password = "test_password_123" hashed = hash_password(password) # Argon2 hashes start with $argon2 assert hashed.startswith("$argon2") # Argon2 hashes are longer than bcrypt (typically 90+ characters) assert len(hashed) > 80 def test_verify_password_correct(self): """Test that verify_password returns True for correct password.""" password = "my_secure_password" hashed = hash_password(password) assert verify_password(password, hashed) is True def test_verify_password_incorrect(self): """Test that verify_password returns False for incorrect password.""" password = "my_secure_password" hashed = hash_password(password) assert verify_password("wrong_password", hashed) is False def test_short_password(self): """Test that short passwords work correctly.""" password = "abc" hashed = hash_password(password) assert verify_password(password, hashed) is True assert verify_password("xyz", hashed) is False def test_long_password_under_72_bytes(self): """Test passwords under 72 bytes.""" # 50 character password (50 bytes in ASCII) password = "a" * 50 hashed = hash_password(password) assert verify_password(password, hashed) is True assert verify_password("a" * 49, hashed) is False def test_long_password_over_72_bytes(self): """Test that passwords over 72 bytes work (this is the critical test).""" # 100 character password (100 bytes in ASCII) password = "a" * 100 hashed = hash_password(password) # Should work without ValueError assert verify_password(password, hashed) is True # Different password should fail assert verify_password("a" * 99, hashed) is False assert verify_password("a" * 101, hashed) is False def test_very_long_password(self): """Test extremely long passwords (200+ bytes).""" password = "x" * 200 hashed = hash_password(password) assert verify_password(password, hashed) is True assert verify_password("x" * 199, hashed) is False def test_unicode_password(self): """Test passwords with unicode characters.""" password = "пароль_с_юникодом_🔒" hashed = hash_password(password) assert verify_password(password, hashed) is True assert verify_password("пароль_с_юникодом_🔓", hashed) is False def test_long_unicode_password(self): """Test long unicode password (each Cyrillic char is 2 bytes in UTF-8).""" # 50 Cyrillic characters = 100 bytes in UTF-8 password = "п" * 50 hashed = hash_password(password) assert verify_password(password, hashed) is True assert verify_password("п" * 49, hashed) is False def test_same_password_different_hashes(self): """Test that same password produces different hashes (salt).""" password = "same_password" hash1 = hash_password(password) hash2 = hash_password(password) # Hashes should be different (Argon2 uses random salt) assert hash1 != hash2 # But both should verify correctly assert verify_password(password, hash1) is True assert verify_password(password, hash2) is True def test_empty_password_raises_error(self): """Test that empty password raises ValueError.""" with pytest.raises(ValueError, match="password must be a non-empty string"): hash_password("") def test_none_password_raises_error(self): """Test that None password raises ValueError.""" with pytest.raises(ValueError, match="password must be a non-empty string"): hash_password(None) def test_verify_empty_password_returns_false(self): """Test that verifying with empty password/hash returns False.""" hashed = hash_password("validpassword") assert verify_password("", hashed) is False assert verify_password("validpassword", "") is False def test_password_with_special_chars(self): """Test password with special characters.""" password = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`" hashed = hash_password(password) assert verify_password(password, hashed) is True def test_password_with_spaces(self): """Test password with spaces.""" password = "password with spaces" hashed = hash_password(password) assert verify_password(password, hashed) is True assert verify_password("passwordwithspaces", hashed) is False def test_realistic_long_password(self): """Test realistic long password scenario.""" # Simulate a password manager generated password password = "Xy8#mK9$pL2@nQ7!wE6%rT5^yU4&iO3*aS2(dF1)" hashed = hash_password(password) assert verify_password(password, hashed) is True # One character different should fail wrong = "Xy8#mK9$pL2@nQ7!wE6%rT5^yU4&iO3*aS2(dF2)" assert verify_password(wrong, hashed) is False class TestJWTTokens: """Test JWT token creation and validation.""" def test_create_access_token(self): """Test access token creation.""" user_id = "user123" token = create_access_token(subject=user_id) assert token is not None assert isinstance(token, str) assert len(token) > 50 def test_create_refresh_token(self): """Test refresh token creation.""" user_id = "user456" token = create_refresh_token(subject=user_id) assert token is not None assert isinstance(token, str) assert len(token) > 50 def test_decode_access_token(self): """Test decoding valid access token.""" user_id = "user789" token = create_access_token(subject=user_id) payload = decode_access_token(token) assert payload is not None assert payload["sub"] == user_id assert payload["typ"] == "access" assert "exp" in payload assert "iat" in payload assert "jti" in payload def test_decode_refresh_token(self): """Test decoding valid refresh token.""" user_id = "user999" token = create_refresh_token(subject=user_id) payload = decode_refresh_token(token) assert payload is not None assert payload["sub"] == user_id assert payload["typ"] == "refresh" assert "exp" in payload assert "iat" in payload assert "jti" in payload def test_decode_access_token_rejects_refresh(self): """Test that decode_access_token rejects refresh tokens.""" user_id = "user111" refresh_token = create_refresh_token(subject=user_id) payload = decode_access_token(refresh_token) assert payload is None def test_decode_refresh_token_rejects_access(self): """Test that decode_refresh_token rejects access tokens.""" user_id = "user222" access_token = create_access_token(subject=user_id) payload = decode_refresh_token(access_token) assert payload is None def test_decode_invalid_token(self): """Test decoding invalid token.""" payload = decode_access_token("invalid.token.here") assert payload is None def test_decode_empty_token(self): """Test decoding empty token.""" payload = decode_access_token("") assert payload is None def test_get_subject(self): """Test extracting subject from payload.""" user_id = "user333" token = create_access_token(subject=user_id) payload = decode_access_token(token) subject = get_subject(payload) assert subject == user_id def test_get_subject_from_empty_payload(self): """Test extracting subject from empty payload.""" subject = get_subject({}) assert subject is None def test_create_access_token_with_extra_claims(self): """Test creating access token with extra claims.""" user_id = "user444" extra = {"role": "admin", "email": "test@example.com"} token = create_access_token(subject=user_id, extra=extra) payload = decode_access_token(token) assert payload is not None assert payload["sub"] == user_id assert payload.get("role") == "admin" assert payload.get("email") == "test@example.com" def test_unique_jti_per_token(self): """Test that each token has unique jti.""" user_id = "user555" token1 = create_access_token(subject=user_id) token2 = create_access_token(subject=user_id) payload1 = decode_access_token(token1) payload2 = decode_access_token(token2) assert payload1["jti"] != payload2["jti"]