PayrollManagementSystem/backend/tests/unit/test_jwt.py

113 lines
4.4 KiB
Python
Raw Normal View History

"""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)