"""JWT 签发与校验单元测试。 守的是:令牌的**有效期语义**与**版本号机制**。 这两点出错都不会在开发期暴露,但后果严重 —— 有效期算错 8 小时会表现为「刚登录就过期」或「过期了还能用」; 版本号机制失效则意味着禁用账号后旧令牌仍可访问全员薪酬。 """ import uuid from datetime import UTC, datetime, timedelta import jwt import pytest from app.core.config import get_settings from app.core.constants import JWT_ALGORITHM from app.core.exceptions import AuthError from app.core.security import create_access_token, decode_token class TestCreateAccessToken: """令牌签发。""" def test_返回令牌_jti与有效期(self) -> None: token, jti, expires_in = create_access_token(user_id=1, token_version=1, roles=["admin"]) assert isinstance(token, str) and token.count(".") == 2 # header.payload.signature assert len(jti) == 32 assert expires_in == get_settings().jwt_expire_minutes * 60 def test_载荷包含鉴定所需的全部字段(self) -> None: token, jti, _ = create_access_token(user_id=7, token_version=3, roles=["admin", "user"]) payload = decode_token(token) assert payload["sub"] == "7" # PyJWT 要求 sub 为字符串 assert payload["jti"] == jti assert payload["ver"] == 3 assert payload["roles"] == ["admin", "user"] def test_每次签发的jti都不同(self) -> None: """jti 是登出拉黑的唯一键,重复会导致误拉黑他人令牌。""" jtis = set() for _ in range(20): _, jti, _ = create_access_token(user_id=1, token_version=1, roles=[]) jtis.add(jti) assert len(jtis) == 20 def test_有效期基于UTC而非本地时间(self) -> None: """库列存本地时间、JWT 用 UTC,两者混算会产生 8 小时偏差。 这里断言 exp 与 UTC 当前时间的差值确实等于配置值 —— 若误用 datetime.now()(本地时间)生成,差值会差 8 小时。 """ token, _, expires_in = create_access_token(user_id=1, token_version=1, roles=[]) exp = decode_token(token)["exp"] drift = exp - datetime.now(UTC).timestamp() assert abs(drift - expires_in) < 5 # 允许几秒执行耗时 class TestDecodeToken: """令牌解析与校验。""" def test_正常令牌可解析(self) -> None: token, _, _ = create_access_token(user_id=1, token_version=1, roles=["admin"]) assert decode_token(token)["sub"] == "1" def test_已过期令牌抛AuthError(self) -> None: """直接构造过期令牌 —— create_access_token 的最小有效期为 1 分钟, 无法用它验证过期分支。""" settings = get_settings() past = datetime.now(UTC) - timedelta(hours=1) expired = jwt.encode( { "sub": "1", "jti": uuid.uuid4().hex, "ver": 1, "iat": past, "exp": past + timedelta(seconds=60), }, settings.app_secret_key, algorithm=JWT_ALGORITHM, ) with pytest.raises(AuthError): decode_token(expired) def test_签名被篡改时抛AuthError(self) -> None: forged = jwt.encode( { "sub": "1", "jti": uuid.uuid4().hex, "ver": 1, "exp": datetime.now(UTC) + timedelta(hours=1), }, "wrong-secret-key-that-is-long-enough-to-pass-validation", algorithm=JWT_ALGORITHM, ) with pytest.raises(AuthError): decode_token(forged) @pytest.mark.parametrize("garbage", ["", "not.a.token", "a.b", "...."]) def test_格式非法的令牌抛AuthError而非其他异常(self, garbage: str) -> None: """令牌内容不可信,任何畸形输入都必须收敛为 401 而不是 500。""" with pytest.raises(AuthError): decode_token(garbage) def test_算法被改为none时拒绝(self) -> None: """经典的 JWT 算法混淆攻击:把 alg 改成 none 伪造无签名令牌。""" unsigned = jwt.encode( {"sub": "1", "exp": datetime.now(UTC) + timedelta(hours=1)}, key="", algorithm="none", ) with pytest.raises(AuthError): decode_token(unsigned)