dak.c2s/backend/tests/test_security.py
CCS Admin 178d40d036 feat: JWT auth, bcrypt, MFA, dependency injection, security tests
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>
2026-02-24 07:41:35 +00:00

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