PayrollManagementSystem/backend/tests/unit/test_jwt.py
liuwenxin 273ebf4657 chore: 初始化项目结构与登录鉴权模块
后端 FastAPI 六层结构(api/services/repositories/models/schemas/core),
统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存
db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、
登出黑名单(TTL=令牌剩余有效期)、登录日志。

前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。

工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。

测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-09 18:46:36 +08:00

113 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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