265 lines
9.1 KiB
Python
265 lines
9.1 KiB
Python
"""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"]
|