Files
openrouter-watcher/tests/unit/services/test_jwt.py
Luca Sacchi Ricciardi 781e564ea0 feat(security): T14 implement JWT utilities
- Add create_access_token with custom/default expiration
- Add decode_access_token with signature verification
- Add verify_token returning TokenData dataclass
- Support HS256 algorithm with config.SECRET_KEY
- Payload includes exp, iat, sub claims
- 19 comprehensive tests with 100% coverage
- Handle expired tokens, invalid signatures, missing claims
2026-04-07 12:10:04 +02:00

316 lines
11 KiB
Python

"""Tests for JWT utilities - T14.
Tests for JWT token creation, decoding, and verification.
"""
from datetime import datetime, timedelta, timezone
import pytest
from jose import JWTError, jwt
pytestmark = [pytest.mark.unit, pytest.mark.security]
class TestJWTCreateAccessToken:
"""Test suite for create_access_token function."""
def test_create_access_token_returns_string(self, jwt_secret):
"""Test that create_access_token returns a string token."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token
data = {"sub": "user123"}
# Act
token = create_access_token(data, secret_key=jwt_secret)
# Assert
assert isinstance(token, str)
assert len(token) > 0
def test_create_access_token_contains_payload(self, jwt_secret):
"""Test that token contains the original payload data."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123", "email": "test@example.com"}
# Act
token = create_access_token(data, secret_key=jwt_secret)
decoded = decode_access_token(token, secret_key=jwt_secret)
# Assert
assert decoded["sub"] == "user123"
assert decoded["email"] == "test@example.com"
def test_create_access_token_includes_exp(self, jwt_secret):
"""Test that token includes expiration claim."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
# Act
token = create_access_token(data, secret_key=jwt_secret)
decoded = decode_access_token(token, secret_key=jwt_secret)
# Assert
assert "exp" in decoded
assert isinstance(decoded["exp"], int)
def test_create_access_token_includes_iat(self, jwt_secret):
"""Test that token includes issued-at claim."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
# Act
token = create_access_token(data, secret_key=jwt_secret)
decoded = decode_access_token(token, secret_key=jwt_secret)
# Assert
assert "iat" in decoded
assert isinstance(decoded["iat"], int)
def test_create_access_token_with_custom_expiration(self, jwt_secret):
"""Test token creation with custom expiration delta."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
expires_delta = timedelta(hours=1)
# Act
token = create_access_token(data, expires_delta=expires_delta, secret_key=jwt_secret)
decoded = decode_access_token(token, secret_key=jwt_secret)
# Assert
exp_timestamp = decoded["exp"]
iat_timestamp = decoded["iat"]
exp_duration = exp_timestamp - iat_timestamp
assert exp_duration == 3600 # 1 hour in seconds
def test_create_access_token_default_expiration(self, jwt_secret):
"""Test token creation with default expiration (24 hours)."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
# Act
token = create_access_token(data, secret_key=jwt_secret)
decoded = decode_access_token(token, secret_key=jwt_secret)
# Assert
exp_timestamp = decoded["exp"]
iat_timestamp = decoded["iat"]
exp_duration = exp_timestamp - iat_timestamp
assert exp_duration == 86400 # 24 hours in seconds
class TestJWTDecodeAccessToken:
"""Test suite for decode_access_token function."""
def test_decode_valid_token_returns_payload(self, jwt_secret):
"""Test decoding a valid token returns the payload."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123", "role": "admin"}
token = create_access_token(data, secret_key=jwt_secret)
# Act
decoded = decode_access_token(token, secret_key=jwt_secret)
# Assert
assert decoded["sub"] == "user123"
assert decoded["role"] == "admin"
def test_decode_expired_token_raises_error(self, jwt_secret):
"""Test decoding an expired token raises JWTError."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
# Create token that expired 1 hour ago
expires_delta = timedelta(hours=-1)
token = create_access_token(data, expires_delta=expires_delta, secret_key=jwt_secret)
# Act & Assert
with pytest.raises(JWTError):
decode_access_token(token, secret_key=jwt_secret)
def test_decode_invalid_signature_raises_error(self, jwt_secret):
"""Test decoding token with wrong secret raises JWTError."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
token = create_access_token(data, secret_key=jwt_secret)
# Act & Assert
with pytest.raises(JWTError):
decode_access_token(token, secret_key="wrong-secret-key-32-chars-long!")
def test_decode_malformed_token_raises_error(self, jwt_secret):
"""Test decoding malformed token raises JWTError."""
# Arrange
from src.openrouter_monitor.services.jwt import decode_access_token
# Act & Assert
with pytest.raises(JWTError):
decode_access_token("invalid-token", secret_key=jwt_secret)
class TestJWTVerifyToken:
"""Test suite for verify_token function."""
def test_verify_valid_token_returns_token_data(self, jwt_secret):
"""Test verifying valid token returns TokenData."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, verify_token
data = {"sub": "user123"}
token = create_access_token(data, secret_key=jwt_secret)
# Act
token_data = verify_token(token, secret_key=jwt_secret)
# Assert
assert token_data.user_id == "user123"
assert token_data.exp is not None
def test_verify_token_without_sub_raises_error(self, jwt_secret):
"""Test verifying token without sub claim raises error."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, verify_token
data = {"email": "test@example.com"} # No sub
token = create_access_token(data, secret_key=jwt_secret)
# Act & Assert
with pytest.raises(JWTError):
verify_token(token, secret_key=jwt_secret)
def test_verify_expired_token_raises_error(self, jwt_secret):
"""Test verifying expired token raises JWTError."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, verify_token
data = {"sub": "user123"}
expires_delta = timedelta(hours=-1)
token = create_access_token(data, expires_delta=expires_delta, secret_key=jwt_secret)
# Act & Assert
with pytest.raises(JWTError):
verify_token(token, secret_key=jwt_secret)
class TestJWTAlgorithm:
"""Test suite for JWT algorithm configuration."""
def test_token_uses_hs256_algorithm(self, jwt_secret):
"""Test that token uses HS256 algorithm."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token
data = {"sub": "user123"}
# Act
token = create_access_token(data, secret_key=jwt_secret)
# Decode without verification to check header
header = jwt.get_unverified_header(token)
# Assert
assert header["alg"] == "HS256"
class TestJWTWithConfig:
"""Test suite for JWT functions using config settings."""
def test_create_access_token_uses_config_secret(self):
"""Test that create_access_token uses SECRET_KEY from config when not provided."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
# Act - Don't pass secret_key, should use config
token = create_access_token(data)
decoded = decode_access_token(token)
# Assert
assert decoded["sub"] == "user123"
def test_decode_access_token_uses_config_secret(self):
"""Test that decode_access_token uses SECRET_KEY from config when not provided."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, decode_access_token
data = {"sub": "user123"}
token = create_access_token(data) # Uses config
# Act - Don't pass secret_key, should use config
decoded = decode_access_token(token)
# Assert
assert decoded["sub"] == "user123"
def test_verify_token_uses_config_secret(self):
"""Test that verify_token uses SECRET_KEY from config when not provided."""
# Arrange
from src.openrouter_monitor.services.jwt import create_access_token, verify_token
data = {"sub": "user123"}
token = create_access_token(data) # Uses config
# Act - Don't pass secret_key, should use config
token_data = verify_token(token)
# Assert
assert token_data.user_id == "user123"
class TestJWTEdgeCases:
"""Test suite for JWT edge cases."""
def test_verify_token_without_exp_raises_error(self, jwt_secret):
"""Test verifying token without exp claim raises error."""
# Arrange - Create token manually without exp
from jose import jwt as jose_jwt
payload = {"sub": "user123", "iat": datetime.now(timezone.utc).timestamp()}
token = jose_jwt.encode(payload, jwt_secret, algorithm="HS256")
# Act & Assert
from src.openrouter_monitor.services.jwt import verify_token
with pytest.raises(Exception): # JWTError or similar
verify_token(token, secret_key=jwt_secret)
def test_verify_token_without_iat_raises_error(self, jwt_secret):
"""Test verifying token without iat claim raises error."""
# Arrange - Create token manually without iat
from jose import jwt as jose_jwt
from datetime import timedelta
now = datetime.now(timezone.utc)
payload = {"sub": "user123", "exp": (now + timedelta(hours=1)).timestamp()}
token = jose_jwt.encode(payload, jwt_secret, algorithm="HS256")
# Act & Assert
from src.openrouter_monitor.services.jwt import verify_token
with pytest.raises(Exception): # JWTError or similar
verify_token(token, secret_key=jwt_secret)
# Fixtures
@pytest.fixture
def jwt_secret():
"""Provide a test JWT secret key."""
return "test-jwt-secret-key-32-chars-long!"