Files
model-platform/backend/tests/test_audit_logging.py
T
tao.chen 97825ca86d feat(audit): per-request loguru audit middleware with daily rotation
按需为每个 HTTP 接口写一条合规记录到
data/logs/audit/audit-YYYY-MM-DD.log,字段:时间 / 用户 /
METHOD path / 状态码。

设计:
* 复用全局 loguru logger,文件 sink 由 audit._DailyFileSink
  自管:缓存当天文件句柄、跨日重建。不走 loguru 的
  rotation=00:00(产物是 audit.log.YYYY-MM-DD_HH-MM-SS,
  不符合按天单文件的命名要求)。
* AuditMiddleware 只做 CPU 验签拿 user_id:cookie access_token
  优先,Authorization Bearer 兜底,无/坏 JWT 一律记 '-'。
  绝不查 DB(RequestContext 在路由解析后才注入)。
* 审计失败不拖死请求:所有异常捕获。
* 与 main.py 现有 access_log 严格分离:access_log 走 stderr
  诊断(method/path/status/耗时),audit 走独立文件合规
  (时间/用户/接口),并存。
* 启动时按 settings.audit_log_retention_days 清理过期文件
  (设 0 关闭)。
* 新增 settings.audit_log_dir(默认 data/logs/audit,相对 cwd)
  与 settings.audit_log_retention_days(默认 30)两个配置项;
  .env.example 同步。

新增 9 个 case:文件创建、行字段、未登录 '-'、坏 JWT、ULID
path、retention 清理/关闭、Bearer 头、幂等。
2026-08-21 13:22:00 +08:00

204 lines
6.7 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.
"""审计日志中间件测试。
覆盖:
* 每天一个 ``audit-YYYY-MM-DD.log`` 文件且写入至少一行;
* 日志行包含 user_id / method / path / status
* 未登录(无 cookie 无 header)与坏 JWT 时 user_id 记 ``-``
* 带路径参数的请求原样记录实际 path(不替换为 ``{script_id}``);
* 启动时按 mtime 清理超过保留天数的旧 ``audit-*.log``
* ``configure_audit_logging`` 幂等。
测试只注册空路由,不触达 MySQL / 任何真实业务逻辑;``AuditMiddleware``
挂在独立的临时 FastAPI app 上,用 ``TestClient`` 发请求。
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from pathlib import Path
import pytest
from common.auth.jwt import issue_jwt
from fastapi import FastAPI
from fastapi.testclient import TestClient
from loguru import logger
from backend import audit
@pytest.fixture(autouse=True)
def _reset_audit_logging():
"""每个用例之间重置审计模块的幂等标志并卸掉上次挂上的审计 sink。
loguru 的全局 logger 会跨用例留存 sink,若不清理,后面的用例会往已
删除的临时目录继续写,且 ``_CONFIGURED`` 会让后续 configure 变空操作。
"""
audit._CONFIGURED = False
yield
if audit._HANDLER_ID is not None:
logger.remove(audit._HANDLER_ID)
audit._HANDLER_ID = None
audit._CONFIGURED = False
def _build_client(log_dir: str) -> TestClient:
"""配置审计日志并返回挂上 AuditMiddleware 的测试 app 客户端。
只注册空路由,不碰数据库与业务逻辑;中间件与路由的注册顺序与
``main.py`` 保持一致(路由先注册,再挂中间件)。
"""
audit.configure_audit_logging(log_dir, retention_days=30)
app = FastAPI()
@app.get("/x")
def x() -> dict:
return {"ok": True}
@app.get("/api/v1/scripts/{script_id}")
def script(script_id: str) -> dict:
return {"id": script_id}
app.add_middleware(audit.AuditMiddleware)
return TestClient(app)
def _local_today() -> str:
return datetime.now(UTC).astimezone().strftime("%Y-%m-%d")
def _today_file(log_dir: Path) -> Path:
return log_dir / f"audit-{_local_today()}.log"
def _audit_lines(log_dir: Path) -> list[str]:
"""等 enqueue 队列落盘后返回今天审计文件的全部非空行。"""
logger.complete()
path = _today_file(log_dir)
if not path.exists():
return []
return [line for line in path.read_text(encoding="utf-8").splitlines() if line]
def test_audit_log_file_is_created_with_date_suffix(tmp_path: Path) -> None:
client = _build_client(str(tmp_path))
client.get("/x")
lines = _audit_lines(tmp_path)
assert _today_file(tmp_path).exists()
assert len(lines) >= 1
def test_audit_log_line_contains_user_method_path_status(tmp_path: Path) -> None:
client = _build_client(str(tmp_path))
user_id = "01USER12345678901234567890"
token = issue_jwt(user_id)
client.cookies.set("access_token", token)
response = client.get("/x")
assert response.status_code == 200
lines = _audit_lines(tmp_path)
assert lines
assert f"| {user_id} | GET /x -> 200" in lines[0]
def test_audit_log_user_id_dash_when_no_jwt(tmp_path: Path) -> None:
client = _build_client(str(tmp_path))
response = client.get("/x")
assert response.status_code == 200
lines = _audit_lines(tmp_path)
assert lines
assert "| - | GET /x -> 200" in lines[0]
def test_audit_log_invalid_jwt_does_not_raise_or_skip(tmp_path: Path) -> None:
client = _build_client(str(tmp_path))
client.cookies.set("access_token", "not.a.jwt")
response = client.get("/x")
# 坏 JWT 不应拖垮请求:响应依旧正常,审计行照样写,user_id 记 "-"。
assert response.status_code == 200
lines = _audit_lines(tmp_path)
assert lines
assert "| - | GET /x -> 200" in lines[0]
def test_audit_log_includes_ulid_path_params(tmp_path: Path) -> None:
client = _build_client(str(tmp_path))
ulid = "01ABCDEFGHIJKLMNOPQRSTUVWXYZ"
response = client.get(f"/api/v1/scripts/{ulid}")
assert response.status_code == 200
lines = _audit_lines(tmp_path)
assert lines
# 记录的是实际请求路径,而不是路由模板里的 {script_id}。
assert f"GET /api/v1/scripts/{ulid} -> 200" in lines[0]
assert "{script_id}" not in lines[0]
def test_audit_log_bearer_header_resolves_user(tmp_path: Path) -> None:
"""Authorization: Bearer 头同样能解析出 user_id。"""
client = _build_client(str(tmp_path))
user_id = "01BEARER000000000000000001"
token = issue_jwt(user_id)
response = client.get("/x", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
lines = _audit_lines(tmp_path)
assert lines
assert f"| {user_id} | GET /x -> 200" in lines[0]
def test_retention_cleanup_removes_old_files(tmp_path: Path) -> None:
old = tmp_path / "audit-2024-01-01.log"
recent = tmp_path / "audit-2024-06-01.log"
old.write_text("old\n", encoding="utf-8")
recent.write_text("recent\n", encoding="utf-8")
old_mtime = datetime.now(UTC).timestamp() - 60 * 86_400 # 60 天前
recent_mtime = datetime.now(UTC).timestamp() - 5 * 86_400 # 5 天前
os.utime(old, (old_mtime, old_mtime))
os.utime(recent, (recent_mtime, recent_mtime))
audit.configure_audit_logging(str(tmp_path), retention_days=30)
assert not old.exists(), "超过保留期的旧审计文件应被启动清理删除"
assert recent.exists(), "保留期内(5 天前)的文件应保留"
def test_retention_cleanup_disabled_when_zero(tmp_path: Path) -> None:
"""AUDIT_LOG_RETENTION_DAYS=0 关闭清理:任何旧文件都不删除。"""
old = tmp_path / "audit-2024-01-01.log"
old.write_text("old\n", encoding="utf-8")
old_mtime = datetime.now(UTC).timestamp() - 400 * 86_400 # 400 天前
os.utime(old, (old_mtime, old_mtime))
audit.configure_audit_logging(str(tmp_path), retention_days=0)
assert old.exists(), "retention_days=0 时应跳过清理,保留所有旧文件"
def test_configure_audit_logging_is_idempotent(tmp_path: Path) -> None:
"""多次调用只有首次生效:日志只写到第一次给的目录。"""
first_dir = tmp_path / "first"
second_dir = tmp_path / "second"
audit.configure_audit_logging(str(first_dir), retention_days=30)
audit.configure_audit_logging(str(second_dir), retention_days=30)
app = FastAPI()
@app.get("/x")
def x() -> dict:
return {"ok": True}
app.add_middleware(audit.AuditMiddleware)
client = TestClient(app)
client.get("/x")
logger.complete()
assert (first_dir / f"audit-{_local_today()}.log").exists()
assert not (second_dir / f"audit-{_local_today()}.log").exists()