Files
ljwx-children/tests/unit/test_auth_utils.py
T
brunoandClaude Sonnet 4.5 9b7a0f6ec2 test: add comprehensive risk assessments API test suite (Issue #37)
Created complete test coverage for risk assessment endpoints:
- 25 test cases covering all 3 endpoints
- POST /risk-assessments (11 tests)
- GET /risk-assessments (5 tests)
- GET /risk-assessments/{id} (5 tests)
- Edge cases and error handling (4 tests)

Test features:
✅ Access control (guardian, psychologist, administrator)
✅ Ollama integration (mocked with AsyncMock)
✅ Fallback mechanisms
✅ Pagination and filtering
✅ Error handling and edge cases
✅ Data validation

Dependencies and fixes:
- Updated httpx (0.26.0 → 0.27.2) for pytest-httpx compatibility
- Fixed JWT imports (import jwt → from jose import jwt)
- Fixed Pydantic v2 field_validator syntax
- Fixed model import conflicts (removed duplicate User model)
- Updated backend/models/__init__.py to import left_behind_children models
- Added missing auth exports (blacklist_token, is_token_blacklisted, can_access_risk_assessment)

Known issue (⚠️ BLOCKING):
- SQLite UUID type incompatibility preventing tests from running
- Error: SQLiteTypeCompiler can't render UUID type
- Need to implement UUID-to-String adapter for SQLite in tests
- All 25 tests fail at setup phase

Files created/modified:
- tests/unit/test_api_risk_assessments.py (NEW - 700+ lines)
- requirements.txt (updated httpx, commented safety)
- backend/auth/__init__.py (added exports)
- backend/auth/utils.py (fixed JWT import)
- backend/auth/dependencies.py (fixed JWT import)
- backend/api/data.py (fixed Pydantic validator)
- backend/models/__init__.py (switched to left_behind_children models)

Next steps:
1. Fix UUID/SQLite compatibility (use String(36) or UUID adapter)
2. Run tests successfully
3. Achieve >95% coverage target

Progress: Issue #37 - 70% complete (tests written, need execution)

Related: #38, #39, #40, #41, #42

🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-01-01 14:23:55 +08:00

212 lines
7.1 KiB
Python

"""
Unit tests for backend.auth.utils module.
Tests:
- Password hashing and verification
- JWT token creation and verification
- Token blacklist functions
"""
import pytest
from datetime import datetime, timedelta
import jwt as pyjwt
from backend.auth.utils import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
verify_token,
SECRET_KEY,
ALGORITHM,
)
class TestPasswordHashing:
"""Test password hashing functions."""
def test_hash_password_returns_string(self):
"""hash_password should return a string."""
password = "SecurePassword123!"
hashed = hash_password(password)
assert isinstance(hashed, str)
assert len(hashed) > 0
def test_hash_password_different_each_time(self):
"""hash_password should return different hashes for same password (salt)."""
password = "SecurePassword123!"
hash1 = hash_password(password)
hash2 = hash_password(password)
assert hash1 != hash2 # Different salts
def test_verify_password_success(self):
"""verify_password should return True for correct password."""
password = "SecurePassword123!"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_verify_password_failure(self):
"""verify_password should return False for incorrect password."""
password = "SecurePassword123!"
hashed = hash_password(password)
wrong_password = "WrongPassword456!"
assert verify_password(wrong_password, hashed) is False
def test_verify_password_empty_password(self):
"""verify_password should handle empty passwords."""
hashed = hash_password("test")
assert verify_password("", hashed) is False
class TestJWTTokens:
"""Test JWT token creation and verification."""
def test_create_access_token_returns_string(self):
"""create_access_token should return a string."""
data = {"sub": "user-123", "role": "child"}
token = create_access_token(data)
assert isinstance(token, str)
assert len(token) > 0
def test_create_access_token_contains_payload(self):
"""create_access_token should encode payload data."""
data = {"sub": "user-123", "role": "psychologist"}
token = create_access_token(data)
# Decode without verification to check payload
payload = pyjwt.decode(token, options={"verify_signature": False})
assert payload["sub"] == "user-123"
assert payload["role"] == "psychologist"
assert "exp" in payload # Expiration
assert "iat" in payload # Issued at
assert "jti" in payload # JWT ID
def test_create_access_token_default_expiration(self):
"""create_access_token should use default 7-day expiration."""
data = {"sub": "user-123"}
token = create_access_token(data)
payload = pyjwt.decode(token, options={"verify_signature": False})
exp = datetime.fromtimestamp(payload["exp"])
iat = datetime.fromtimestamp(payload["iat"])
# Should be approximately 7 days (allow 1 second tolerance)
delta = exp - iat
assert 6.99 < delta.days < 7.01
def test_create_access_token_custom_expiration(self):
"""create_access_token should accept custom expiration."""
data = {"sub": "user-123"}
custom_expiry = timedelta(hours=1)
token = create_access_token(data, expires_delta=custom_expiry)
payload = pyjwt.decode(token, options={"verify_signature": False})
exp = datetime.fromtimestamp(payload["exp"])
iat = datetime.fromtimestamp(payload["iat"])
# Should be approximately 1 hour
delta = (exp - iat).total_seconds()
assert 3595 < delta < 3605 # Allow 5 second tolerance
def test_create_refresh_token_contains_token_type(self):
"""create_refresh_token should mark token_type as 'refresh'."""
data = {"sub": "user-123"}
token = create_refresh_token(data)
payload = pyjwt.decode(token, options={"verify_signature": False})
assert payload["token_type"] == "refresh"
assert payload["sub"] == "user-123"
def test_create_refresh_token_30_day_expiration(self):
"""create_refresh_token should use 30-day expiration."""
data = {"sub": "user-123"}
token = create_refresh_token(data)
payload = pyjwt.decode(token, options={"verify_signature": False})
exp = datetime.fromtimestamp(payload["exp"])
iat = datetime.fromtimestamp(payload["iat"])
# Should be approximately 30 days
delta = exp - iat
assert 29.99 < delta.days < 30.01
def test_verify_token_success(self):
"""verify_token should decode valid token."""
data = {"sub": "user-123", "role": "guardian"}
token = create_access_token(data)
payload = verify_token(token)
assert payload["sub"] == "user-123"
assert payload["role"] == "guardian"
def test_verify_token_invalid_signature(self):
"""verify_token should raise InvalidTokenError for tampered token."""
data = {"sub": "user-123"}
token = create_access_token(data)
# Tamper with token
tampered_token = token[:-5] + "XXXXX"
with pytest.raises(pyjwt.InvalidTokenError):
verify_token(tampered_token)
def test_verify_token_expired(self):
"""verify_token should raise ExpiredSignatureError for expired token."""
data = {"sub": "user-123"}
# Create token that expired 1 hour ago
expired_delta = timedelta(hours=-1)
token = create_access_token(data, expires_delta=expired_delta)
with pytest.raises(pyjwt.ExpiredSignatureError):
verify_token(token)
def test_verify_token_invalid_format(self):
"""verify_token should raise InvalidTokenError for malformed token."""
invalid_token = "not.a.valid.jwt.token"
with pytest.raises(pyjwt.InvalidTokenError):
verify_token(invalid_token)
class TestTokenBlacklist:
"""Test token blacklist functions."""
@pytest.mark.asyncio
async def test_is_token_blacklisted_placeholder(self):
"""is_token_blacklisted should return False (placeholder implementation)."""
from backend.auth.utils import is_token_blacklisted
result = await is_token_blacklisted("some-jti")
assert result is False # Placeholder always returns False
@pytest.mark.asyncio
async def test_blacklist_token_placeholder(self):
"""blacklist_token should not raise errors (placeholder implementation)."""
from backend.auth.utils import blacklist_token
# Should not raise any errors
await blacklist_token("some-jti", 3600)
class TestSecurityConstants:
"""Test security configuration constants."""
def test_secret_key_configured(self):
"""SECRET_KEY should be configured."""
assert SECRET_KEY is not None
assert len(SECRET_KEY) > 0
def test_algorithm_is_hs256(self):
"""ALGORITHM should be HS256."""
assert ALGORITHM == "HS256"