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
This commit is contained in:
315
tests/unit/services/test_jwt.py
Normal file
315
tests/unit/services/test_jwt.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""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!"
|
||||
Reference in New Issue
Block a user