"""审计日志中间件测试。 覆盖: * 每天一个 ``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()