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 头、幂等。
This commit is contained in:
tao.chen
2026-08-21 13:22:00 +08:00
parent 2b1f6b5303
commit 97825ca86d
5 changed files with 425 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
"""审计日志中间件测试。
覆盖:
* 每天一个 ``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()