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]>
212 lines
7.1 KiB
Python
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"
|