"""JWT 服务与处理器单元测试.""" import time from datetime import datetime, timedelta, timezone import jwt import pytest from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from packages.application.auth.jwt_handler import ( JWTHandler, configure_jwt_handler, get_jwt_handler, ) from packages.application.auth.jwt_service import ( JWTConfig, JWTService, TokenType, ) # ── 测试常量 ────────────────────────────────────────────────────────────────── TEST_SECRET = "test-secret-key-for-unit-testing-only-not-for-production" STRONG_SECRET = "x" * 32 # 满足长度要求的测试密钥 # ── JWTConfig 测试 ─────────────────────────────────────────────────────────── class TestJWTConfig: """JWTConfig 配置类测试""" def test_init_with_valid_secret(self): config = JWTConfig(secret_key=STRONG_SECRET) assert config.SECRET_KEY == STRONG_SECRET assert config.ALGORITHM == "HS256" assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 15 assert config.REFRESH_TOKEN_EXPIRE_DAYS == 7 def test_init_custom_values(self): config = JWTConfig( secret_key=STRONG_SECRET, algorithm="HS384", access_token_expire_minutes=60, refresh_token_expire_days=14, ) assert config.SECRET_KEY == STRONG_SECRET assert config.ALGORITHM == "HS384" assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 60 assert config.REFRESH_TOKEN_EXPIRE_DAYS == 14 def test_empty_secret_raises(self): with pytest.raises(ValueError, match="secret_key must be provided"): JWTConfig(secret_key="") def test_whitespace_only_secret_raises(self): with pytest.raises(ValueError, match="secret_key must be provided"): JWTConfig(secret_key=" ") def test_none_secret_raises(self): with pytest.raises(ValueError, match="secret_key must be provided"): JWTConfig(secret_key=None) @pytest.mark.parametrize( "insecure_secret", [ "your-secret-key-change-in-production", "your-secret-key", "secret", "changeme", "password", "SECRET", "Your-Secret-Key", ], ) def test_insecure_default_secret_raises(self, insecure_secret): with pytest.raises(ValueError, match="insecure"): JWTConfig(secret_key=insecure_secret) def test_zero_expire_minutes_allowed(self): config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=0) assert config.ACCESS_TOKEN_EXPIRE_MINUTES == 0 def test_negative_expire_days_allowed(self): # 配置类不校验合理性,由业务层判断 config = JWTConfig(secret_key=STRONG_SECRET, refresh_token_expire_days=-1) assert config.REFRESH_TOKEN_EXPIRE_DAYS == -1 # ── JWTService 初始化测试 ──────────────────────────────────────────────────── class TestJWTServiceInit: """JWTService 初始化测试""" def test_init_with_config(self): config = JWTConfig(secret_key=STRONG_SECRET) service = JWTService(config) assert service.config is config def test_init_none_config_raises(self): with pytest.raises(ValueError, match="JWTService requires a JWTConfig"): JWTService(None) # ── TokenType 测试 ─────────────────────────────────────────────────────────── class TestTokenType: """TokenType 常量测试""" def test_access_value(self): assert TokenType.ACCESS == "access" def test_refresh_value(self): assert TokenType.REFRESH == "refresh" def test_access_and_refresh_different(self): assert TokenType.ACCESS != TokenType.REFRESH # ── JWTService create_access_token 测试 ───────────────────────────────────── class TestCreateAccessToken: """创建 access_token 测试""" @pytest.fixture def service(self): return JWTService(JWTConfig(secret_key=STRONG_SECRET)) def test_creates_valid_jwt_string(self, service): token = service.create_access_token(user_id="user-123") assert isinstance(token, str) assert len(token) > 0 def test_token_contains_user_id_as_sub(self, service): token = service.create_access_token(user_id="user-123") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["sub"] == "user-123" def test_token_type_is_access(self, service): token = service.create_access_token(user_id="user-123") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["type"] == TokenType.ACCESS def test_default_role_is_empty_string(self, service): token = service.create_access_token(user_id="user-123") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["role"] == "" def test_custom_role(self, service): token = service.create_access_token(user_id="user-123", role="admin") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["role"] == "admin" def test_has_iat_and_exp(self, service): token = service.create_access_token(user_id="user-123") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert "iat" in payload assert "exp" in payload assert payload["exp"] > payload["iat"] def test_expire_matches_config(self, service): token = service.create_access_token(user_id="user-123") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) iat = datetime.fromtimestamp(payload["iat"], tz=timezone.utc) exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc) delta = exp - iat assert delta.total_seconds() == 15 * 60 # 15分钟 def test_custom_expire_time(self): config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=30) service = JWTService(config) token = service.create_access_token(user_id="user-123") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) delta = payload["exp"] - payload["iat"] assert delta == 30 * 60 def test_additional_claims(self, service): extra = {"custom_field": "value", "another": 42} token = service.create_access_token(user_id="user-123", additional_claims=extra) payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["custom_field"] == "value" assert payload["another"] == 42 def test_additional_claims_can_override_standard(self, service): # additional_claims 可以覆盖标准字段(由调用者负责) token = service.create_access_token( user_id="user-123", additional_claims={"sub": "overridden"}, ) payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["sub"] == "overridden" def test_additional_claims_none_is_same_as_empty(self, service): token = service.create_access_token(user_id="user-123", additional_claims=None) payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["sub"] == "user-123" def test_uses_correct_algorithm(self): config = JWTConfig(secret_key=STRONG_SECRET, algorithm="HS384") service = JWTService(config) token = service.create_access_token(user_id="u1") # 用 HS256 解码应该失败 with pytest.raises(InvalidTokenError): jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) # 用 HS384 解码应该成功 payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS384"]) assert payload["sub"] == "u1" # ── JWTService create_refresh_token 测试 ──────────────────────────────────── class TestCreateRefreshToken: """创建 refresh_token 测试""" @pytest.fixture def service(self): return JWTService(JWTConfig(secret_key=STRONG_SECRET)) def test_creates_valid_string(self, service): token = service.create_refresh_token(user_id="u1", session_id="s1") assert isinstance(token, str) assert len(token) > 0 def test_contains_user_id_and_session_id(self, service): token = service.create_refresh_token(user_id="u1", session_id="sess-abc") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["sub"] == "u1" assert payload["session_id"] == "sess-abc" def test_token_type_is_refresh(self, service): token = service.create_refresh_token(user_id="u1", session_id="s1") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert payload["type"] == TokenType.REFRESH def test_has_iat_and_exp(self, service): token = service.create_refresh_token(user_id="u1", session_id="s1") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) assert "iat" in payload assert "exp" in payload assert payload["exp"] > payload["iat"] def test_expire_matches_config_days(self, service): token = service.create_refresh_token(user_id="u1", session_id="s1") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) delta = payload["exp"] - payload["iat"] assert delta == 7 * 24 * 60 * 60 # 7天 def test_custom_refresh_expire_days(self): config = JWTConfig(secret_key=STRONG_SECRET, refresh_token_expire_days=30) service = JWTService(config) token = service.create_refresh_token(user_id="u1", session_id="s1") payload = jwt.decode(token, STRONG_SECRET, algorithms=["HS256"]) delta = payload["exp"] - payload["iat"] assert delta == 30 * 24 * 60 * 60 # ── JWTService verify_token 测试 ──────────────────────────────────────────── class TestVerifyToken: """通用 Token 验证测试""" @pytest.fixture def service(self): return JWTService(JWTConfig(secret_key=STRONG_SECRET)) def test_verify_valid_access_token(self, service): token = service.create_access_token(user_id="u1") payload = service.verify_token(token) assert payload["sub"] == "u1" assert payload["type"] == TokenType.ACCESS def test_verify_valid_refresh_token(self, service): token = service.create_refresh_token(user_id="u1", session_id="s1") payload = service.verify_token(token) assert payload["sub"] == "u1" assert payload["session_id"] == "s1" def test_verify_expired_token_raises(self, service): config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=0) svc = JWTService(config) token = svc.create_access_token(user_id="u1") # 0 分钟过期,立即过期 time.sleep(0.1) # 稍微等一下确保过期 with pytest.raises(ExpiredSignatureError): svc.verify_token(token) def test_verify_wrong_secret_raises(self, service): token = service.create_access_token(user_id="u1") other_service = JWTService(JWTConfig(secret_key="different-secret-1234567890")) with pytest.raises(InvalidTokenError): other_service.verify_token(token) def test_verify_tampered_token_raises(self, service): token = service.create_access_token(user_id="u1") # 篡改 token 中间部分 parts = token.split(".") assert len(parts) == 3 tampered = parts[0] + "." + parts[1][:-1] + "A." + parts[2] with pytest.raises(InvalidTokenError): service.verify_token(tampered) def test_verify_empty_string_raises(self, service): with pytest.raises(InvalidTokenError): service.verify_token("") def test_verify_garbage_string_raises(self, service): with pytest.raises(InvalidTokenError): service.verify_token("not.a.valid.jwt.token") def test_verify_returns_dict(self, service): token = service.create_access_token(user_id="u1", role="admin") payload = service.verify_token(token) assert isinstance(payload, dict) assert "sub" in payload assert "role" in payload # ── JWTService verify_access_token 测试 ───────────────────────────────────── class TestVerifyAccessToken: """Access Token 专属验证测试""" @pytest.fixture def service(self): return JWTService(JWTConfig(secret_key=STRONG_SECRET)) def test_valid_access_token_passes(self, service): token = service.create_access_token(user_id="u1", role="admin") payload = service.verify_access_token(token) assert payload["sub"] == "u1" assert payload["role"] == "admin" def test_refresh_token_fails_type_check(self, service): token = service.create_refresh_token(user_id="u1", session_id="s1") with pytest.raises(ValueError, match="Token type must be 'access'"): service.verify_access_token(token) def test_token_without_type_field_raises(self, service): # 手动构造一个没有 type 字段的 token payload_data = {"sub": "u1", "iat": 1000, "exp": 9999999999} token = jwt.encode(payload_data, STRONG_SECRET, algorithm="HS256") with pytest.raises(ValueError, match="Token type must be 'access'"): service.verify_access_token(token) def test_expired_access_token_raises_expired_error(self, service): config = JWTConfig(secret_key=STRONG_SECRET, access_token_expire_minutes=0) svc = JWTService(config) token = svc.create_access_token(user_id="u1") time.sleep(0.1) with pytest.raises(ExpiredSignatureError): svc.verify_access_token(token) # ── JWTService verify_refresh_token 测试 ──────────────────────────────────── class TestVerifyRefreshToken: """Refresh Token 专属验证测试""" @pytest.fixture def service(self): return JWTService(JWTConfig(secret_key=STRONG_SECRET)) def test_valid_refresh_token_passes(self, service): token = service.create_refresh_token(user_id="u1", session_id="s1") payload = service.verify_refresh_token(token) assert payload["sub"] == "u1" assert payload["session_id"] == "s1" def test_access_token_fails_type_check(self, service): token = service.create_access_token(user_id="u1") with pytest.raises(ValueError, match="Token type must be 'refresh'"): service.verify_refresh_token(token) def test_token_without_type_field_raises(self, service): payload_data = {"sub": "u1", "session_id": "s1", "iat": 1000, "exp": 9999999999} token = jwt.encode(payload_data, STRONG_SECRET, algorithm="HS256") with pytest.raises(ValueError, match="Token type must be 'refresh'"): service.verify_refresh_token(token) def test_expired_refresh_token_raises(self): config = JWTConfig(secret_key=STRONG_SECRET, refresh_token_expire_days=0) service = JWTService(config) token = service.create_refresh_token(user_id="u1", session_id="s1") # 0天过期,应该立即使exp <= iat with pytest.raises(ExpiredSignatureError): service.verify_refresh_token(token) # ── JWTHandler 委托层测试 ──────────────────────────────────────────────────── class TestJWTHandler: """JWTHandler 委托层测试""" def test_init_creates_handler(self): handler = JWTHandler(secret_key=STRONG_SECRET) assert handler is not None def test_create_and_verify_access_token(self): handler = JWTHandler(secret_key=STRONG_SECRET) token = handler.create_access_token(user_id="u1", role="user") payload = handler.verify_access_token(token) assert payload["sub"] == "u1" assert payload["role"] == "user" def test_verify_token_generic(self): handler = JWTHandler(secret_key=STRONG_SECRET) token = handler.create_access_token(user_id="u1") payload = handler.verify_token(token) assert payload["sub"] == "u1" def test_custom_algorithm(self): handler = JWTHandler(secret_key=STRONG_SECRET, algorithm="HS384") token = handler.create_access_token(user_id="u1") payload = handler.verify_access_token(token) assert payload["sub"] == "u1" def test_custom_expire_minutes(self): handler = JWTHandler(secret_key=STRONG_SECRET, access_token_expire_minutes=45) token = handler.create_access_token(user_id="u1") payload = handler.verify_access_token(token) delta = payload["exp"] - payload["iat"] assert delta == 45 * 60 def test_additional_claims_passthrough(self): handler = JWTHandler(secret_key=STRONG_SECRET) extra = {"org_id": "org-1", "plan": "pro"} token = handler.create_access_token("u1", additional_claims={"org_id": "org-1"}) payload = handler.verify_access_token( token := handler.create_access_token("u1", additional_claims={"org_id": "org-1"}) ) # 这里直接测试更简洁 payload = handler.verify_access_token(handler.create_access_token("u1", additional_claims={"x": 1})) assert payload["x"] == 1 # ── 全局 JWT handler 测试 ─────────────────────────────────────────────────── class TestGlobalJWTHandler: """全局 JWT Handler 配置与获取测试""" def test_configure_creates_handler(self): handler = configure_jwt_handler(secret_key=STRONG_SECRET) assert isinstance(handler, JWTHandler) def test_get_after_configure_works(self): configure_jwt_handler(secret_key=STRONG_SECRET) handler = get_jwt_handler() assert isinstance(handler, JWTHandler) token = handler.create_access_token(user_id="u1") payload = handler.verify_access_token(token) assert payload["sub"] == "u1" def test_get_before_configure_raises(self): # 重置全局状态(通过设置 None 模拟未配置) import packages.application.auth.jwt_handler as mod mod._default_handler = None with pytest.raises(RuntimeError, match="JWT handler not configured"): get_jwt_handler() def test_configure_returns_same_as_get(self): h1 = configure_jwt_handler(secret_key=STRONG_SECRET) h2 = get_jwt_handler() assert h1 is h2 def test_reconfigure_replaces_handler(self): h1 = configure_jwt_handler(secret_key=STRONG_SECRET) h2 = configure_jwt_handler(secret_key=STRONG_SECRET + "_new") assert h1 is not h2 assert get_jwt_handler() is h2