df38101bd9
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 4m6s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 15m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
363 lines
14 KiB
Python
Executable File
363 lines
14 KiB
Python
Executable File
"""
|
|
JWT Service 单元测试
|
|
"""
|
|
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
import jwt
|
|
import pytest
|
|
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
|
|
|
from packages.application.auth.jwt_service import (
|
|
JWTConfig,
|
|
JWTService,
|
|
TokenType,
|
|
)
|
|
|
|
|
|
class TestJWTConfig:
|
|
"""JWT 配置测试"""
|
|
|
|
def test_config_init_success(self):
|
|
"""测试正常初始化"""
|
|
config = JWTConfig(secret_key="a-very-strong-secret-key-for-testing")
|
|
assert config.SECRET_KEY == "a-very-strong-secret-key-for-testing"
|
|
assert config.ALGORITHM == "HS256"
|
|
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 15
|
|
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 7
|
|
|
|
def test_config_custom_values(self):
|
|
"""测试自定义配置值"""
|
|
config = JWTConfig(
|
|
secret_key="test-secret",
|
|
algorithm="HS512",
|
|
access_token_expire_minutes=30,
|
|
refresh_token_expire_days=14,
|
|
)
|
|
assert config.ALGORITHM == "HS512"
|
|
assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 30
|
|
assert config.REFRESH_TOKEN_EXPIRE_DAYS == 14
|
|
|
|
def test_config_empty_secret_raises(self):
|
|
"""测试空密钥报错"""
|
|
with pytest.raises(ValueError, match="secret_key must be provided"):
|
|
JWTConfig(secret_key="")
|
|
|
|
def test_config_whitespace_secret_raises(self):
|
|
"""测试全空格密钥报错"""
|
|
with pytest.raises(ValueError, match="secret_key must be provided"):
|
|
JWTConfig(secret_key=" ")
|
|
|
|
def test_config_insecure_default_secret_raises(self):
|
|
"""测试不安全的默认密钥报错"""
|
|
insecure_keys = [
|
|
"your-secret-key-change-in-production",
|
|
"your-secret-key",
|
|
"secret",
|
|
"changeme",
|
|
"password",
|
|
"YOUR-SECRET-KEY",
|
|
"Secret",
|
|
]
|
|
for key in insecure_keys:
|
|
with pytest.raises(ValueError, match="insecure"):
|
|
JWTConfig(secret_key=key)
|
|
|
|
|
|
class TestJWTService:
|
|
"""JWT 服务测试"""
|
|
|
|
@pytest.fixture
|
|
def config(self):
|
|
return JWTConfig(
|
|
secret_key="test-secret-key-for-jwt-unit-tests-12345",
|
|
algorithm="HS256",
|
|
access_token_expire_minutes=30,
|
|
refresh_token_expire_days=7,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def service(self, config):
|
|
return JWTService(config=config)
|
|
|
|
def test_service_init_without_config_raises(self):
|
|
"""测试无 config 初始化报错"""
|
|
with pytest.raises(ValueError, match="requires a JWTConfig"):
|
|
JWTService(config=None)
|
|
|
|
# --- create_access_token ---
|
|
|
|
def test_create_access_token_success(self, service):
|
|
"""测试创建 access token 成功"""
|
|
token = service.create_access_token(user_id="user-123")
|
|
assert isinstance(token, str)
|
|
assert len(token) > 0
|
|
|
|
def test_create_access_token_contains_user_id(self, service, config):
|
|
"""测试 access token 包含正确的 user_id"""
|
|
token = service.create_access_token(user_id="user-456")
|
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
assert payload["sub"] == "user-456"
|
|
|
|
def test_create_access_token_has_correct_type(self, service, config):
|
|
"""测试 access token 类型正确"""
|
|
token = service.create_access_token(user_id="user-123")
|
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
assert payload["type"] == TokenType.ACCESS
|
|
|
|
def test_create_access_token_contains_role(self, service, config):
|
|
"""测试 access token 包含角色"""
|
|
token = service.create_access_token(user_id="user-123", role="admin")
|
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
assert payload["role"] == "admin"
|
|
|
|
def test_create_access_token_additional_claims(self, service, config):
|
|
"""测试 access token 包含额外声明"""
|
|
token = service.create_access_token(
|
|
user_id="user-123",
|
|
additional_claims={"custom_field": "custom_value", "sid": "session-abc"},
|
|
)
|
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
assert payload["custom_field"] == "custom_value"
|
|
assert payload["sid"] == "session-abc"
|
|
|
|
def test_create_access_token_has_iat_and_exp(self, service, config):
|
|
"""测试 access token 包含 iat 和 exp"""
|
|
before = datetime.utcnow() - timedelta(seconds=1)
|
|
token = service.create_access_token(user_id="user-123")
|
|
after = datetime.utcnow() + timedelta(seconds=1)
|
|
|
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
assert "iat" in payload
|
|
assert "exp" in payload
|
|
|
|
iat = datetime.utcfromtimestamp(payload["iat"])
|
|
exp = datetime.utcfromtimestamp(payload["exp"])
|
|
|
|
assert before <= iat <= after
|
|
assert exp > iat
|
|
# 过期时间约等于配置的分钟数
|
|
expected_expiry = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
actual_expiry = exp - iat
|
|
assert abs((actual_expiry - expected_expiry).total_seconds()) < 5
|
|
|
|
# --- create_refresh_token ---
|
|
|
|
def test_create_refresh_token_success(self, service):
|
|
"""测试创建 refresh token 成功"""
|
|
token = service.create_refresh_token(user_id="user-123", session_id="sess-abc")
|
|
assert isinstance(token, str)
|
|
assert len(token) > 0
|
|
|
|
def test_create_refresh_token_contains_correct_data(self, service, config):
|
|
"""测试 refresh token 包含正确数据"""
|
|
token = service.create_refresh_token(user_id="user-789", session_id="sess-xyz")
|
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
assert payload["sub"] == "user-789"
|
|
assert payload["session_id"] == "sess-xyz"
|
|
assert payload["type"] == TokenType.REFRESH
|
|
|
|
def test_create_refresh_token_expiry(self, service, config):
|
|
"""测试 refresh token 过期时间正确"""
|
|
token = service.create_refresh_token(user_id="user-123", session_id="sess-abc")
|
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
|
|
iat = datetime.utcfromtimestamp(payload["iat"])
|
|
exp = datetime.utcfromtimestamp(payload["exp"])
|
|
expected_expiry = timedelta(days=config.REFRESH_TOKEN_EXPIRE_DAYS)
|
|
actual_expiry = exp - iat
|
|
assert abs((actual_expiry - expected_expiry).total_seconds()) < 5
|
|
|
|
# --- verify_token ---
|
|
|
|
def test_verify_valid_token(self, service):
|
|
"""测试验证有效 token"""
|
|
token = service.create_access_token(user_id="user-123")
|
|
payload = service.verify_token(token)
|
|
assert payload["sub"] == "user-123"
|
|
|
|
def test_verify_expired_token_raises(self, service, config):
|
|
"""测试验证过期 token 报错"""
|
|
# 创建一个已经过期的 token
|
|
payload = {
|
|
"sub": "user-123",
|
|
"type": TokenType.ACCESS,
|
|
"iat": datetime.utcnow() - timedelta(hours=1),
|
|
"exp": datetime.utcnow() - timedelta(minutes=30),
|
|
}
|
|
expired_token = jwt.encode(payload, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
|
|
|
with pytest.raises(ExpiredSignatureError, match="expired"):
|
|
service.verify_token(expired_token)
|
|
|
|
def test_verify_invalid_token_raises(self, service):
|
|
"""测试验证无效 token 报错"""
|
|
with pytest.raises(InvalidTokenError):
|
|
service.verify_token("this-is-not-a-valid-jwt-token")
|
|
|
|
def test_verify_token_with_wrong_secret_raises(self, service, config):
|
|
"""测试用错误密钥签发的 token 验证失败"""
|
|
wrong_config = JWTConfig(secret_key="different-secret-key")
|
|
wrong_service = JWTService(config=wrong_config)
|
|
token = wrong_service.create_access_token(user_id="user-123")
|
|
|
|
with pytest.raises(InvalidTokenError):
|
|
service.verify_token(token)
|
|
|
|
# --- verify_access_token ---
|
|
|
|
def test_verify_access_token_success(self, service):
|
|
"""测试验证有效的 access token"""
|
|
token = service.create_access_token(user_id="user-123", role="user")
|
|
payload = service.verify_access_token(token)
|
|
assert payload["sub"] == "user-123"
|
|
assert payload["type"] == TokenType.ACCESS
|
|
|
|
def test_verify_access_token_with_refresh_token_raises(self, service):
|
|
"""测试用 refresh token 调用 verify_access_token 报错"""
|
|
refresh_token = service.create_refresh_token(user_id="user-123", session_id="sess-abc")
|
|
|
|
with pytest.raises(ValueError, match="Token type must be 'access'"):
|
|
service.verify_access_token(refresh_token)
|
|
|
|
# --- verify_refresh_token ---
|
|
|
|
def test_verify_refresh_token_success(self, service):
|
|
"""测试验证有效的 refresh token"""
|
|
token = service.create_refresh_token(user_id="user-123", session_id="sess-abc")
|
|
payload = service.verify_refresh_token(token)
|
|
assert payload["sub"] == "user-123"
|
|
assert payload["session_id"] == "sess-abc"
|
|
assert payload["type"] == TokenType.REFRESH
|
|
|
|
def test_verify_refresh_token_with_access_token_raises(self, service):
|
|
"""测试用 access token 调用 verify_refresh_token 报错"""
|
|
access_token = service.create_access_token(user_id="user-123")
|
|
|
|
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
|
|
service.verify_refresh_token(access_token)
|
|
|
|
def test_access_and_refresh_tokens_are_different(self, service):
|
|
"""测试 access token 和 refresh token 不相同"""
|
|
access = service.create_access_token(user_id="user-123")
|
|
refresh = service.create_refresh_token(user_id="user-123", session_id="sess-abc")
|
|
assert access != refresh
|
|
|
|
|
|
class TestJWTHandler:
|
|
"""JWT Handler 委托层测试"""
|
|
|
|
def test_create_access_token(self):
|
|
"""测试创建 access token"""
|
|
from packages.application.auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret-key")
|
|
token = handler.create_access_token(user_id="user-123", role="admin")
|
|
assert token is not None
|
|
assert len(token) > 20
|
|
|
|
# 验证token内容
|
|
payload = jwt.decode(token, "test-secret-key", algorithms=["HS256"])
|
|
assert payload["sub"] == "user-123"
|
|
assert payload["role"] == "admin"
|
|
assert payload["type"] == "access"
|
|
|
|
def test_create_access_token_with_additional_claims(self):
|
|
"""测试带额外声明创建 token"""
|
|
from packages.application.auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret-key")
|
|
token = handler.create_access_token(
|
|
user_id="user-456",
|
|
additional_claims={"custom_field": "custom_value"},
|
|
)
|
|
|
|
payload = jwt.decode(token, "test-secret-key", algorithms=["HS256"])
|
|
assert payload["sub"] == "user-456"
|
|
assert payload["custom_field"] == "custom_value"
|
|
|
|
def test_verify_access_token(self):
|
|
"""测试验证 access token"""
|
|
from packages.application.auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret-key")
|
|
token = handler.create_access_token(user_id="user-123", role="user")
|
|
payload = handler.verify_access_token(token)
|
|
|
|
assert payload["sub"] == "user-123"
|
|
assert payload["role"] == "user"
|
|
assert payload["type"] == "access"
|
|
|
|
def test_verify_access_token_expired(self):
|
|
"""测试验证过期的 access token"""
|
|
from packages.application.auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret-key", access_token_expire_minutes=0)
|
|
token = handler.create_access_token(user_id="user-123")
|
|
|
|
time.sleep(1) # 确保过期
|
|
|
|
with pytest.raises(ExpiredSignatureError):
|
|
handler.verify_access_token(token)
|
|
|
|
def test_verify_token(self):
|
|
"""测试验证任意类型 token"""
|
|
from packages.application.auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret-key")
|
|
token = handler.create_access_token(user_id="user-123")
|
|
payload = handler.verify_token(token)
|
|
|
|
assert payload["sub"] == "user-123"
|
|
|
|
def test_verify_invalid_token(self):
|
|
"""测试验证无效 token"""
|
|
from packages.application.auth.jwt_handler import JWTHandler
|
|
|
|
handler = JWTHandler(secret_key="test-secret-key")
|
|
with pytest.raises(InvalidTokenError):
|
|
handler.verify_token("invalid.token.here")
|
|
|
|
def test_configure_and_get_default_handler(self):
|
|
"""测试配置和获取全局默认 handler"""
|
|
from packages.application.auth import jwt_handler as handler_module
|
|
from packages.application.auth.jwt_handler import (
|
|
configure_jwt_handler,
|
|
get_jwt_handler,
|
|
)
|
|
|
|
# 重置全局状态
|
|
handler_module._default_handler = None
|
|
|
|
# 配置
|
|
handler = configure_jwt_handler(
|
|
secret_key="global-secret",
|
|
algorithm="HS256",
|
|
access_token_expire_minutes=60,
|
|
)
|
|
assert handler is not None
|
|
|
|
# 获取
|
|
same_handler = get_jwt_handler()
|
|
assert same_handler is handler
|
|
|
|
# 验证能正常工作
|
|
token = same_handler.create_access_token(user_id="global-user")
|
|
payload = jwt.decode(token, "global-secret", algorithms=["HS256"])
|
|
assert payload["sub"] == "global-user"
|
|
|
|
# 重置全局状态,避免影响其他测试
|
|
handler_module._default_handler = None
|
|
|
|
def test_get_jwt_handler_not_configured(self):
|
|
"""测试未配置时获取 handler 抛出异常"""
|
|
from packages.application.auth import jwt_handler as handler_module
|
|
from packages.application.auth.jwt_handler import get_jwt_handler
|
|
|
|
# 确保未配置
|
|
handler_module._default_handler = None
|
|
|
|
with pytest.raises(RuntimeError, match="JWT handler not configured"):
|
|
get_jwt_handler()
|