mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 19:33:41 +00:00
Add core security layer: - security.py: password hashing (bcrypt), JWT access/refresh tokens, SHA-256 token hashing, TOTP MFA (generate, verify, provisioning URI), plus passlib/bcrypt 5.x compatibility patch - dependencies.py: FastAPI deps for get_current_user (Bearer JWT) and require_admin (role check) - exceptions.py: domain-specific HTTP exceptions (CaseNotFound, DuplicateCase, InvalidImportFile, ICDValidation, AccountLocked, InvalidCredentials) - test_security.py: 9 tests covering all security functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
113 lines
3.6 KiB
Python
113 lines
3.6 KiB
Python
"""Tests for app.core.security — JWT, bcrypt, MFA/TOTP, token hashing."""
|
|
|
|
import hashlib
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pyotp
|
|
import pytest
|
|
from jose import JWTError, jwt
|
|
|
|
from app.config import get_settings
|
|
from app.core.security import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
decode_access_token,
|
|
generate_mfa_secret,
|
|
get_mfa_uri,
|
|
hash_password,
|
|
hash_token,
|
|
verify_mfa_code,
|
|
verify_password,
|
|
)
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Password hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_hash_and_verify_password():
|
|
"""Hashing then verifying the same password returns True; wrong one False."""
|
|
hashed = hash_password("s3cret!")
|
|
assert verify_password("s3cret!", hashed) is True
|
|
assert verify_password("wrong-password", hashed) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JWT access tokens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_create_and_decode_access_token():
|
|
"""Round-trip: create a token and decode it, payload should match."""
|
|
token = create_access_token(user_id=1, role="admin")
|
|
payload = decode_access_token(token)
|
|
assert payload["sub"] == "1"
|
|
assert payload["role"] == "admin"
|
|
assert "exp" in payload
|
|
|
|
|
|
def test_expired_token_raises():
|
|
"""A token with a negative expiry must fail to decode."""
|
|
expire = datetime.now(timezone.utc) - timedelta(seconds=10)
|
|
token = jwt.encode(
|
|
{"sub": "1", "role": "admin", "exp": expire},
|
|
settings.JWT_SECRET_KEY,
|
|
algorithm=settings.JWT_ALGORITHM,
|
|
)
|
|
with pytest.raises(JWTError):
|
|
decode_access_token(token)
|
|
|
|
|
|
def test_invalid_token_raises():
|
|
"""Decoding a garbage string must raise JWTError."""
|
|
with pytest.raises(JWTError):
|
|
decode_access_token("not.a.valid.jwt.token")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Refresh / hash tokens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_create_refresh_token_uniqueness():
|
|
"""Two refresh tokens should never collide."""
|
|
t1 = create_refresh_token()
|
|
t2 = create_refresh_token()
|
|
assert t1 != t2
|
|
assert len(t1) > 40 # url-safe base64 of 64 bytes
|
|
|
|
|
|
def test_hash_token():
|
|
"""hash_token must produce a consistent SHA-256 hex digest."""
|
|
token = "test-token-value"
|
|
expected = hashlib.sha256(token.encode()).hexdigest()
|
|
assert hash_token(token) == expected
|
|
# Deterministic: calling twice yields the same result
|
|
assert hash_token(token) == hash_token(token)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MFA / TOTP
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_mfa_secret_and_verify():
|
|
"""Generate a secret, produce the current OTP, verify it succeeds."""
|
|
secret = generate_mfa_secret()
|
|
assert len(secret) > 0
|
|
totp = pyotp.TOTP(secret)
|
|
current_code = totp.now()
|
|
assert verify_mfa_code(secret, current_code) is True
|
|
|
|
|
|
def test_mfa_wrong_code():
|
|
"""A clearly wrong code must be rejected."""
|
|
secret = generate_mfa_secret()
|
|
assert verify_mfa_code(secret, "000000") is False or verify_mfa_code(secret, "999999") is False
|
|
|
|
|
|
def test_mfa_uri_format():
|
|
"""The provisioning URI must start with the otpauth scheme."""
|
|
secret = generate_mfa_secret()
|
|
uri = get_mfa_uri(secret, "user@example.com")
|
|
assert uri.startswith("otpauth://totp/")
|
|
assert "user%40example.com" in uri or "user@example.com" in uri
|