199 lines
7.7 KiB
Python
199 lines
7.7 KiB
Python
|
|
"""日志查询接口集成测试(需要 MySQL 与 Redis)。
|
|||
|
|
|
|||
|
|
守的是:两类日志的筛选、分页、权限,以及**写入与查询的口径一致** ——
|
|||
|
|
写操作产生的审计记录必须能被查询接口按同样口径检索到(否则"有写入
|
|||
|
|
没查询"或"查询条件对不上",日志功能形同虚设)。
|
|||
|
|
|
|||
|
|
登录日志造数直接 ORM 插入,不经过登录接口:绕开验证码依赖,且能精确
|
|||
|
|
控制 success/fail_reason/ip 组合。
|
|||
|
|
|
|||
|
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import uuid
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy import delete
|
|||
|
|
|
|||
|
|
from app.core.db import get_session_factory
|
|||
|
|
from app.models import LoginLog
|
|||
|
|
|
|||
|
|
pytestmark = pytest.mark.integration
|
|||
|
|
|
|||
|
|
_OPERATION_LOGS = "/api/v1/system/operation-logs"
|
|||
|
|
_LOGIN_LOGS = "/api/v1/system/login-logs"
|
|||
|
|
_USERS = "/api/v1/system/users"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_audit(client: TestClient, admin: dict, action: str = "删除") -> None:
|
|||
|
|
"""触发一条审计记录(失败操作最省事、不留业务数据)。"""
|
|||
|
|
resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"])
|
|||
|
|
assert resp.status_code == 404
|
|||
|
|
assert action == "删除"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestOperationLogQuery:
|
|||
|
|
"""操作日志查询。"""
|
|||
|
|
|
|||
|
|
def test_写操作后可按模块与操作筛选(self, client: TestClient, admin: dict, make_user) -> None:
|
|||
|
|
target = make_user()
|
|||
|
|
resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
|
|||
|
|
assert resp.status_code == 200
|
|||
|
|
|
|||
|
|
resp = client.get(
|
|||
|
|
_OPERATION_LOGS,
|
|||
|
|
params={"username": admin["username"], "module": "用户管理", "action": "删除"},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
data = resp.json()["data"]
|
|||
|
|
assert data["total"] >= 1
|
|||
|
|
row = data["list"][0]
|
|||
|
|
assert row["success"] == 1
|
|||
|
|
assert target["username"] in row["summary"]
|
|||
|
|
assert row["module"] == "用户管理"
|
|||
|
|
|
|||
|
|
def test_按结果筛选出失败记录(self, client: TestClient, admin: dict) -> None:
|
|||
|
|
_make_audit(client, admin)
|
|||
|
|
resp = client.get(
|
|||
|
|
_OPERATION_LOGS,
|
|||
|
|
params={"username": admin["username"], "success": 0},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
data = resp.json()["data"]
|
|||
|
|
assert data["total"] >= 1
|
|||
|
|
assert data["list"][0]["error_message"]
|
|||
|
|
|
|||
|
|
def test_时间范围筛选(self, client: TestClient, admin: dict) -> None:
|
|||
|
|
_make_audit(client, admin)
|
|||
|
|
now = datetime.now()
|
|||
|
|
fmt = "%Y-%m-%d %H:%M:%S"
|
|||
|
|
|
|||
|
|
resp = client.get(
|
|||
|
|
_OPERATION_LOGS,
|
|||
|
|
params={
|
|||
|
|
"username": admin["username"],
|
|||
|
|
"start_time": (now - timedelta(minutes=5)).strftime(fmt),
|
|||
|
|
"end_time": (now + timedelta(minutes=5)).strftime(fmt),
|
|||
|
|
},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
assert resp.json()["data"]["total"] >= 1
|
|||
|
|
|
|||
|
|
# 已过去的时间窗(排除测试刚产生的记录)
|
|||
|
|
resp = client.get(
|
|||
|
|
_OPERATION_LOGS,
|
|||
|
|
params={
|
|||
|
|
"username": admin["username"],
|
|||
|
|
"start_time": "2020-01-01 00:00:00",
|
|||
|
|
"end_time": "2020-01-02 00:00:00",
|
|||
|
|
},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
assert resp.json()["data"]["total"] == 0
|
|||
|
|
|
|||
|
|
def test_起始晚于结束返回400(self, client: TestClient, admin: dict) -> None:
|
|||
|
|
resp = client.get(
|
|||
|
|
_OPERATION_LOGS,
|
|||
|
|
params={"start_time": "2026-10-02 00:00:00", "end_time": "2026-10-01 00:00:00"},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
assert resp.status_code == 400
|
|||
|
|
|
|||
|
|
def test_列表响应不含请求参数字段(self, client: TestClient, admin: dict) -> None:
|
|||
|
|
"""列表为性能排除 request_data(TEXT 大字段),防止翻页负担随日志表增长。"""
|
|||
|
|
resp = client.get(_OPERATION_LOGS, headers=admin["headers"])
|
|||
|
|
rows = resp.json()["data"]["list"]
|
|||
|
|
if rows:
|
|||
|
|
assert "request_data" not in rows[0]
|
|||
|
|
|
|||
|
|
def test_详情端点返回已脱敏的请求参数(self, client: TestClient, admin: dict, make_user) -> None:
|
|||
|
|
"""列表 → 详情 的两段式:详情含 request_data 且已脱敏。"""
|
|||
|
|
secret = "S3cret@2026!"
|
|||
|
|
target = make_user()
|
|||
|
|
resp = client.put(
|
|||
|
|
f"{_USERS}/{target['id']}/password",
|
|||
|
|
json={"new_password": secret},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
assert resp.status_code == 200
|
|||
|
|
|
|||
|
|
resp = client.get(
|
|||
|
|
_OPERATION_LOGS,
|
|||
|
|
params={"username": admin["username"], "action": "重置密码"},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
row = resp.json()["data"]["list"][0]
|
|||
|
|
assert "request_data" not in row
|
|||
|
|
|
|||
|
|
detail = client.get(f"{_OPERATION_LOGS}/{row['id']}", headers=admin["headers"])
|
|||
|
|
assert detail.status_code == 200
|
|||
|
|
request_data = detail.json()["data"]["request_data"] or ""
|
|||
|
|
assert secret not in request_data
|
|||
|
|
assert "***" in request_data
|
|||
|
|
|
|||
|
|
def test_详情端点不存在返回404(self, client: TestClient, admin: dict) -> None:
|
|||
|
|
resp = client.get(f"{_OPERATION_LOGS}/999999999", headers=admin["headers"])
|
|||
|
|
assert resp.status_code == 404
|
|||
|
|
|
|||
|
|
def test_详情端点普通用户被拒(self, client: TestClient, normal: dict) -> None:
|
|||
|
|
resp = client.get(f"{_OPERATION_LOGS}/1", headers=normal["headers"])
|
|||
|
|
assert resp.status_code == 403
|
|||
|
|
|
|||
|
|
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
|
|||
|
|
assert client.get(_OPERATION_LOGS, headers=normal["headers"]).status_code == 403
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLoginLogQuery:
|
|||
|
|
"""登录日志查询。"""
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def seeded_logs(self):
|
|||
|
|
"""直插两条登录日志(一成功一失败),用后清理。"""
|
|||
|
|
username = f"t_login_{uuid.uuid4().hex[:6]}"
|
|||
|
|
factory = get_session_factory()
|
|||
|
|
with factory() as session:
|
|||
|
|
session.add(
|
|||
|
|
LoginLog(
|
|||
|
|
username=username,
|
|||
|
|
ip="10.1.2.3",
|
|||
|
|
user_agent="pytest",
|
|||
|
|
success=0,
|
|||
|
|
fail_reason="用户名或密码错误",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
session.add(LoginLog(username=username, ip="10.1.2.4", user_agent="pytest", success=1))
|
|||
|
|
session.commit()
|
|||
|
|
yield username
|
|||
|
|
with factory() as session:
|
|||
|
|
session.execute(delete(LoginLog).where(LoginLog.username == username))
|
|||
|
|
session.commit()
|
|||
|
|
|
|||
|
|
def test_按用户名查询并返回两端结果(
|
|||
|
|
self, client: TestClient, admin: dict, seeded_logs: str
|
|||
|
|
) -> None:
|
|||
|
|
resp = client.get(_LOGIN_LOGS, params={"username": seeded_logs}, headers=admin["headers"])
|
|||
|
|
data = resp.json()["data"]
|
|||
|
|
assert data["total"] == 2
|
|||
|
|
assert {row["success"] for row in data["list"]} == {0, 1}
|
|||
|
|
|
|||
|
|
def test_按IP精确筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
|
|||
|
|
resp = client.get(_LOGIN_LOGS, params={"ip": "10.1.2.3"}, headers=admin["headers"])
|
|||
|
|
data = resp.json()["data"]
|
|||
|
|
assert data["total"] == 1
|
|||
|
|
assert data["list"][0]["fail_reason"] == "用户名或密码错误"
|
|||
|
|
|
|||
|
|
def test_按结果筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
|
|||
|
|
resp = client.get(
|
|||
|
|
_LOGIN_LOGS,
|
|||
|
|
params={"username": seeded_logs, "success": 0},
|
|||
|
|
headers=admin["headers"],
|
|||
|
|
)
|
|||
|
|
data = resp.json()["data"]
|
|||
|
|
assert data["total"] == 1
|
|||
|
|
assert data["list"][0]["success"] == 0
|
|||
|
|
|
|||
|
|
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
|
|||
|
|
assert client.get(_LOGIN_LOGS, headers=normal["headers"]).status_code == 403
|