- 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量 固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data 按键名的结构化脱敏 + UTF-8 字节边界截断 - 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、 编码创建后不可改、停用角色不可再分配 - 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数); 时间参数统一转本地 naive(修混用时区 500 与偏移丢弃) - 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400 - 测试 301 例全绿(单元 223 / 集成 78) Co-Authored-By: Claude Code <noreply@anthropic.com>
93 lines
3.8 KiB
Python
93 lines
3.8 KiB
Python
"""日志查询模型单元测试。
|
||
|
||
守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去
|
||
的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了";
|
||
带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较
|
||
抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。
|
||
"""
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
from app.schemas.log import LoginLogQuery, OperationLogQuery
|
||
|
||
|
||
class TestTimeRange:
|
||
"""start_time ≤ end_time 校验(两类日志共用基类)。"""
|
||
|
||
def test_合法范围通过(self) -> None:
|
||
query = OperationLogQuery(
|
||
start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0)
|
||
)
|
||
assert query.start_time is not None
|
||
|
||
def test_起始晚于结束被拒(self) -> None:
|
||
with pytest.raises(ValidationError):
|
||
OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
|
||
|
||
def test_登录日志同样校验(self) -> None:
|
||
with pytest.raises(ValidationError):
|
||
LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
|
||
|
||
def test_边界相等通过(self) -> None:
|
||
moment = datetime(2026, 10, 1, 12, 0, 0)
|
||
assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment
|
||
|
||
def test_仅传一端不校验(self) -> None:
|
||
assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None
|
||
assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None
|
||
|
||
|
||
class TestQueryFields:
|
||
"""筛选字段边界。"""
|
||
|
||
def test_成功标记只能为0或1(self) -> None:
|
||
assert OperationLogQuery(success=0).success == 0
|
||
with pytest.raises(ValidationError):
|
||
OperationLogQuery(success=2)
|
||
|
||
def test_分页别名与偏移(self) -> None:
|
||
query = OperationLogQuery(pageNum=2, pageSize=50)
|
||
assert query.offset == 50
|
||
|
||
def test_操作日志筛选字段(self) -> None:
|
||
query = OperationLogQuery(module="用户管理", action="删除", username="adm")
|
||
assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm")
|
||
|
||
def test_登录日志按IP精确筛选(self) -> None:
|
||
assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1"
|
||
|
||
|
||
class TestTimezoneNormalization:
|
||
"""时区归一。
|
||
|
||
回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传
|
||
曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。
|
||
"""
|
||
|
||
def test_utc后缀被转换为本地时间(self) -> None:
|
||
"""Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。"""
|
||
query = OperationLogQuery(start_time="2026-10-01T00:00:00Z")
|
||
assert query.start_time == datetime(2026, 10, 1, 8, 0, 0)
|
||
|
||
def test_混合aware与naive不再抛比较错误(self) -> None:
|
||
query = OperationLogQuery(
|
||
start_time=datetime(2026, 10, 1, 0, 0, 0),
|
||
end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC),
|
||
)
|
||
assert query.end_time is not None
|
||
assert query.end_time.tzinfo is None # 已摘除
|
||
|
||
def test_归一后范围校验仍按本地口径生效(self) -> None:
|
||
with pytest.raises(ValidationError):
|
||
OperationLogQuery(
|
||
start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00
|
||
end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始
|
||
)
|
||
|
||
def test_naive时间视为本地原样保留(self) -> None:
|
||
moment = datetime(2026, 10, 1, 12, 0, 0)
|
||
assert OperationLogQuery(start_time=moment).start_time == moment
|