"""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