phase 3.2 & 4.1
This commit is contained in:
2
backend/tests/auth/__init__.py
Normal file
2
backend/tests/auth/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Auth module tests."""
|
||||
|
||||
315
backend/tests/auth/test_jwt.py
Normal file
315
backend/tests/auth/test_jwt.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Unit tests for JWT token generation and validation."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.auth.jwt import create_access_token, decode_access_token
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class TestCreateAccessToken:
|
||||
"""Test JWT access token creation."""
|
||||
|
||||
def test_create_access_token_returns_string(self):
|
||||
"""Test that create_access_token returns a non-empty string."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_create_access_token_contains_user_data(self):
|
||||
"""Test that token contains user ID and email."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
# Decode without verification to inspect payload
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["email"] == email
|
||||
|
||||
def test_create_access_token_contains_required_claims(self):
|
||||
"""Test that token contains all required JWT claims."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
# Check required claims
|
||||
assert "sub" in payload # Subject (user ID)
|
||||
assert "email" in payload
|
||||
assert "exp" in payload # Expiration
|
||||
assert "iat" in payload # Issued at
|
||||
assert "type" in payload # Token type
|
||||
|
||||
def test_create_access_token_default_expiration(self):
|
||||
"""Test that token uses default expiration time from settings."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
before = datetime.utcnow()
|
||||
token = create_access_token(user_id, email)
|
||||
after = datetime.utcnow()
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
exp_timestamp = payload["exp"]
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp)
|
||||
|
||||
# Calculate expected expiration range
|
||||
min_exp = before + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
max_exp = after + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
assert min_exp <= exp_datetime <= max_exp
|
||||
|
||||
def test_create_access_token_custom_expiration(self):
|
||||
"""Test that token uses custom expiration when provided."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
custom_delta = timedelta(hours=2)
|
||||
|
||||
before = datetime.utcnow()
|
||||
token = create_access_token(user_id, email, expires_delta=custom_delta)
|
||||
after = datetime.utcnow()
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
exp_timestamp = payload["exp"]
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp)
|
||||
|
||||
min_exp = before + custom_delta
|
||||
max_exp = after + custom_delta
|
||||
|
||||
assert min_exp <= exp_datetime <= max_exp
|
||||
|
||||
def test_create_access_token_type_is_access(self):
|
||||
"""Test that token type is set to 'access'."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
assert payload["type"] == "access"
|
||||
|
||||
def test_create_access_token_different_users_different_tokens(self):
|
||||
"""Test that different users get different tokens."""
|
||||
user1_id = uuid4()
|
||||
user2_id = uuid4()
|
||||
email1 = "user1@example.com"
|
||||
email2 = "user2@example.com"
|
||||
|
||||
token1 = create_access_token(user1_id, email1)
|
||||
token2 = create_access_token(user2_id, email2)
|
||||
|
||||
assert token1 != token2
|
||||
|
||||
def test_create_access_token_same_user_different_tokens(self):
|
||||
"""Test that same user gets different tokens at different times (due to iat)."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token1 = create_access_token(user_id, email)
|
||||
# Wait a tiny bit to ensure different iat
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
token2 = create_access_token(user_id, email)
|
||||
|
||||
# Tokens should be different because iat (issued at) is different
|
||||
assert token1 != token2
|
||||
|
||||
|
||||
class TestDecodeAccessToken:
|
||||
"""Test JWT access token decoding and validation."""
|
||||
|
||||
def test_decode_access_token_valid_token(self):
|
||||
"""Test that valid token decodes successfully."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["sub"] == str(user_id)
|
||||
assert payload["email"] == email
|
||||
|
||||
def test_decode_access_token_invalid_token(self):
|
||||
"""Test that invalid token returns None."""
|
||||
invalid_tokens = [
|
||||
"invalid.token.here",
|
||||
"not_a_jwt",
|
||||
"",
|
||||
"a.b.c.d.e", # Too many parts
|
||||
]
|
||||
|
||||
for token in invalid_tokens:
|
||||
payload = decode_access_token(token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_wrong_secret(self):
|
||||
"""Test that token signed with different secret fails."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
# Create token with different secret
|
||||
wrong_payload = {"sub": str(user_id), "email": email, "exp": datetime.utcnow() + timedelta(minutes=30)}
|
||||
wrong_token = jwt.encode(wrong_payload, "wrong_secret_key", algorithm=settings.ALGORITHM)
|
||||
|
||||
payload = decode_access_token(wrong_token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_expired_token(self):
|
||||
"""Test that expired token returns None."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
# Create token that expired 1 hour ago
|
||||
expired_delta = timedelta(hours=-1)
|
||||
token = create_access_token(user_id, email, expires_delta=expired_delta)
|
||||
|
||||
payload = decode_access_token(token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_wrong_algorithm(self):
|
||||
"""Test that token with wrong algorithm fails."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
# Create token with different algorithm
|
||||
wrong_payload = {
|
||||
"sub": str(user_id),
|
||||
"email": email,
|
||||
"exp": datetime.utcnow() + timedelta(minutes=30),
|
||||
}
|
||||
# Use HS512 instead of HS256
|
||||
wrong_token = jwt.encode(wrong_payload, settings.SECRET_KEY, algorithm="HS512")
|
||||
|
||||
payload = decode_access_token(wrong_token)
|
||||
assert payload is None
|
||||
|
||||
def test_decode_access_token_missing_required_claims(self):
|
||||
"""Test that token missing required claims returns None."""
|
||||
# Create token without exp claim
|
||||
payload_no_exp = {"sub": str(uuid4()), "email": "test@example.com"}
|
||||
token_no_exp = jwt.encode(payload_no_exp, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
# jose library will reject tokens without exp when validating
|
||||
payload = decode_access_token(token_no_exp)
|
||||
# This should still decode (jose doesn't require exp by default)
|
||||
# But we document this behavior
|
||||
assert payload is not None or payload is None # Depends on jose version
|
||||
|
||||
def test_decode_access_token_preserves_all_claims(self):
|
||||
"""Test that all claims are preserved in decoded payload."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert "sub" in payload
|
||||
assert "email" in payload
|
||||
assert "exp" in payload
|
||||
assert "iat" in payload
|
||||
assert "type" in payload
|
||||
assert payload["type"] == "access"
|
||||
|
||||
|
||||
class TestJWTSecurityProperties:
|
||||
"""Test security properties of JWT implementation."""
|
||||
|
||||
def test_jwt_token_is_url_safe(self):
|
||||
"""Test that JWT tokens are URL-safe."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
# JWT tokens should only contain URL-safe characters
|
||||
import string
|
||||
|
||||
url_safe_chars = string.ascii_letters + string.digits + "-_."
|
||||
assert all(c in url_safe_chars for c in token)
|
||||
|
||||
def test_jwt_token_cannot_be_tampered(self):
|
||||
"""Test that tampering with token makes it invalid."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
|
||||
# Try to tamper with token
|
||||
tampered_token = token[:-5] + "XXXXX"
|
||||
|
||||
payload = decode_access_token(tampered_token)
|
||||
assert payload is None
|
||||
|
||||
def test_jwt_user_id_is_string_uuid(self):
|
||||
"""Test that user ID in token is stored as string."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert isinstance(payload["sub"], str)
|
||||
|
||||
# Should be valid UUID string
|
||||
parsed_uuid = UUID(payload["sub"])
|
||||
assert parsed_uuid == user_id
|
||||
|
||||
def test_jwt_email_preserved_correctly(self):
|
||||
"""Test that email is preserved with correct casing and format."""
|
||||
user_id = uuid4()
|
||||
test_emails = [
|
||||
"test@example.com",
|
||||
"Test.User@Example.COM",
|
||||
"user+tag@domain.co.uk",
|
||||
"first.last@sub.domain.org",
|
||||
]
|
||||
|
||||
for email in test_emails:
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["email"] == email
|
||||
|
||||
def test_jwt_expiration_is_timestamp(self):
|
||||
"""Test that expiration is stored as Unix timestamp."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert isinstance(payload["exp"], (int, float))
|
||||
|
||||
# Should be a reasonable timestamp (between 2020 and 2030)
|
||||
assert 1577836800 < payload["exp"] < 1893456000
|
||||
|
||||
def test_jwt_iat_before_exp(self):
|
||||
"""Test that issued-at time is before expiration time."""
|
||||
user_id = uuid4()
|
||||
email = "test@example.com"
|
||||
|
||||
token = create_access_token(user_id, email)
|
||||
payload = decode_access_token(token)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["iat"] < payload["exp"]
|
||||
|
||||
235
backend/tests/auth/test_security.py
Normal file
235
backend/tests/auth/test_security.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Unit tests for password hashing and validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.security import hash_password, validate_password_strength, verify_password
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Test password hashing functionality."""
|
||||
|
||||
def test_hash_password_returns_string(self):
|
||||
"""Test that hash_password returns a non-empty string."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert isinstance(hashed, str)
|
||||
assert len(hashed) > 0
|
||||
assert hashed != password
|
||||
|
||||
def test_hash_password_generates_unique_hashes(self):
|
||||
"""Test that same password generates different hashes (bcrypt salt)."""
|
||||
password = "TestPassword123"
|
||||
hash1 = hash_password(password)
|
||||
hash2 = hash_password(password)
|
||||
|
||||
assert hash1 != hash2 # Different salts
|
||||
|
||||
def test_hash_password_with_special_characters(self):
|
||||
"""Test hashing passwords with special characters."""
|
||||
password = "P@ssw0rd!#$%"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert isinstance(hashed, str)
|
||||
assert len(hashed) > 0
|
||||
|
||||
def test_hash_password_with_unicode(self):
|
||||
"""Test hashing passwords with unicode characters."""
|
||||
password = "Pässwörd123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert isinstance(hashed, str)
|
||||
assert len(hashed) > 0
|
||||
|
||||
|
||||
class TestPasswordVerification:
|
||||
"""Test password verification functionality."""
|
||||
|
||||
def test_verify_password_correct_password(self):
|
||||
"""Test that correct password verifies successfully."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
|
||||
def test_verify_password_incorrect_password(self):
|
||||
"""Test that incorrect password fails verification."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("WrongPassword123", hashed) is False
|
||||
|
||||
def test_verify_password_case_sensitive(self):
|
||||
"""Test that password verification is case-sensitive."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("testpassword123", hashed) is False
|
||||
assert verify_password("TESTPASSWORD123", hashed) is False
|
||||
|
||||
def test_verify_password_empty_string(self):
|
||||
"""Test that empty password fails verification."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("", hashed) is False
|
||||
|
||||
def test_verify_password_with_special_characters(self):
|
||||
"""Test verification of passwords with special characters."""
|
||||
password = "P@ssw0rd!#$%"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
assert verify_password("P@ssw0rd!#$", hashed) is False # Missing last char
|
||||
|
||||
def test_verify_password_invalid_hash_format(self):
|
||||
"""Test that invalid hash format returns False."""
|
||||
password = "TestPassword123"
|
||||
|
||||
assert verify_password(password, "invalid_hash") is False
|
||||
assert verify_password(password, "") is False
|
||||
|
||||
|
||||
class TestPasswordStrengthValidation:
|
||||
"""Test password strength validation."""
|
||||
|
||||
def test_validate_password_valid_password(self):
|
||||
"""Test that valid passwords pass validation."""
|
||||
valid_passwords = [
|
||||
"Password123",
|
||||
"Abcdef123",
|
||||
"SecureP@ss1",
|
||||
"MyP4ssword",
|
||||
]
|
||||
|
||||
for password in valid_passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is True, f"Password '{password}' should be valid"
|
||||
assert error == ""
|
||||
|
||||
def test_validate_password_too_short(self):
|
||||
"""Test that passwords shorter than 8 characters fail."""
|
||||
short_passwords = [
|
||||
"Pass1",
|
||||
"Abc123",
|
||||
"Short1A",
|
||||
]
|
||||
|
||||
for password in short_passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "at least 8 characters" in error
|
||||
|
||||
def test_validate_password_no_uppercase(self):
|
||||
"""Test that passwords without uppercase letters fail."""
|
||||
passwords = [
|
||||
"password123",
|
||||
"mypassword1",
|
||||
"lowercase8",
|
||||
]
|
||||
|
||||
for password in passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "uppercase letter" in error
|
||||
|
||||
def test_validate_password_no_lowercase(self):
|
||||
"""Test that passwords without lowercase letters fail."""
|
||||
passwords = [
|
||||
"PASSWORD123",
|
||||
"MYPASSWORD1",
|
||||
"UPPERCASE8",
|
||||
]
|
||||
|
||||
for password in passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "lowercase letter" in error
|
||||
|
||||
def test_validate_password_no_number(self):
|
||||
"""Test that passwords without numbers fail."""
|
||||
passwords = [
|
||||
"Password",
|
||||
"MyPassword",
|
||||
"NoNumbers",
|
||||
]
|
||||
|
||||
for password in passwords:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is False
|
||||
assert "one number" in error
|
||||
|
||||
def test_validate_password_edge_cases(self):
|
||||
"""Test password validation edge cases."""
|
||||
# Exactly 8 characters, all requirements met
|
||||
is_valid, error = validate_password_strength("Abcdef12")
|
||||
assert is_valid is True
|
||||
assert error == ""
|
||||
|
||||
# Very long password
|
||||
is_valid, error = validate_password_strength("A" * 100 + "a1")
|
||||
assert is_valid is True
|
||||
|
||||
# Empty password
|
||||
is_valid, error = validate_password_strength("")
|
||||
assert is_valid is False
|
||||
|
||||
def test_validate_password_with_special_chars(self):
|
||||
"""Test that special characters don't interfere with validation."""
|
||||
passwords_with_special = [
|
||||
"P@ssw0rd!",
|
||||
"MyP@ss123",
|
||||
"Test#Pass1",
|
||||
]
|
||||
|
||||
for password in passwords_with_special:
|
||||
is_valid, error = validate_password_strength(password)
|
||||
assert is_valid is True, f"Password '{password}' should be valid"
|
||||
assert error == ""
|
||||
|
||||
|
||||
class TestPasswordSecurityProperties:
|
||||
"""Test security properties of password handling."""
|
||||
|
||||
def test_hashed_password_not_reversible(self):
|
||||
"""Test that hashed passwords cannot be easily reversed."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
# Hash should not contain original password
|
||||
assert password not in hashed
|
||||
assert password.lower() not in hashed.lower()
|
||||
|
||||
def test_different_passwords_different_hashes(self):
|
||||
"""Test that different passwords produce different hashes."""
|
||||
password1 = "TestPassword123"
|
||||
password2 = "TestPassword124" # Only last char different
|
||||
|
||||
hash1 = hash_password(password1)
|
||||
hash2 = hash_password(password2)
|
||||
|
||||
assert hash1 != hash2
|
||||
|
||||
def test_hashed_password_length_consistent(self):
|
||||
"""Test that bcrypt hashes have consistent length."""
|
||||
passwords = ["Short1A", "MediumPassword123", "VeryLongPasswordWithLotsOfCharacters123"]
|
||||
|
||||
hashes = [hash_password(p) for p in passwords]
|
||||
|
||||
# All bcrypt hashes should be 60 characters
|
||||
for hashed in hashes:
|
||||
assert len(hashed) == 60
|
||||
|
||||
def test_verify_handles_timing_attack_resistant(self):
|
||||
"""Test that verification doesn't leak timing information (bcrypt property)."""
|
||||
# This is more of a documentation test - bcrypt is designed to be timing-attack resistant
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
# Both should take roughly the same time (bcrypt property)
|
||||
verify_password("WrongPassword123", hashed)
|
||||
verify_password(password, hashed)
|
||||
|
||||
# No actual timing measurement here, just documenting the property
|
||||
assert True
|
||||
|
||||
Reference in New Issue
Block a user