PayrollManagementSystem/backend/tests/unit/test_monitor_service.py

298 lines
13 KiB
Python
Raw Normal View History

"""使用隔离 SQLite 事务验证监控落库、恢复、权限及脱敏,不接触开发业务库。"""
import json
from datetime import timedelta
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from fastapi.testclient import TestClient
from sqlalchemy import Integer, MetaData, create_engine, func, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_current_user, get_db
from app.api.v1.system.monitor import router
from app.core.config import Settings
from app.core.exception_handlers import register_exception_handlers
from app.core.monitoring import MonitorMiddleware, Recorder, minute_of, now_local
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.schemas.monitor import MonitorWindow
from app.services import monitor_service as service
from app.services.monitor_collector import MonitorCollector, RuntimeErrorHandler
@pytest.fixture
def monitor_case(monkeypatch):
engine = create_engine(
"sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}
)
metadata = MetaData()
for model in (MonitorSnapshot, MonitorRequestMinute, MonitorEvent):
table = model.__table__.to_metadata(metadata)
table.c.id.type = Integer()
metadata.create_all(engine)
factory = sessionmaker(engine, expire_on_commit=False)
settings = Settings(_env_file=None, app_secret_key="x" * 32, monitor_enabled=False)
collector = MonitorCollector(settings, Recorder())
monkeypatch.setattr(collector, "_factory", lambda: factory)
yield collector, factory
engine.dispose()
def seed(collector, at):
collector.recorder.record("GET", "/api/items/{id}", 500, 2200, "a" * 32, at=at)
collector.recorder.record("GET", "/api/items/{id}", 400, 10, "b" * 32, at=at)
collector.samples[minute_of(at)] = [
{"memory_percent": 40, "cpu_percent": 10, "disk_percent": 25}
]
def test_历史跨采集器重启保留且当前分钟不重复统计(monitor_case):
collector, factory = monitor_case
at = minute_of(now_local()) - timedelta(minutes=2)
seed(collector, at)
collector._persist(now_local())
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 1
assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 1
assert service.overview(session, collector).requests["count"] == 2
restarted = MonitorCollector(collector.settings, Recorder())
trend = service.trends(session, restarted, MonitorWindow())
assert sum(point["request_count"] or 0 for point in trend["points"]) == 2
row = service.interfaces(session, restarted, MonitorWindow())["list"][0]
assert (row["count"], row["errors_5xx"], row["errors_4xx"]) == (2, 1, 1)
assert row["p95_ms"] == 2200
def test_持久化失败保留事件恢复后写入且不影响记录(monitor_case, monkeypatch):
collector, factory = monitor_case
at = minute_of(now_local()) - timedelta(minutes=1)
seed(collector, at)
def fail():
raise RuntimeError("synthetic-db-failure")
monkeypatch.setattr(collector, "_factory", fail)
collector._persist(now_local())
assert len(collector.recorder.events) == 1
assert collector.current()["collection_status"] == "warning"
monkeypatch.setattr(collector, "_factory", lambda: factory)
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
assert not collector.recorder.events
def test_同类健康异常合并恢复并跨重启复用首次时间(monitor_case):
collector, factory = monitor_case
at = now_local() - timedelta(minutes=3)
bad = [{"name": "MySQL", "status": "error", "message": "连接检查失败。"}]
collector._evaluate(at, {}, bad, {})
collector._persist(at)
restarted = MonitorCollector(collector.settings, Recorder())
restarted._factory = lambda: factory
restarted._evaluate(at + timedelta(minutes=1), {}, bad, {})
restarted._persist(at + timedelta(minutes=1))
with factory() as session:
row = session.scalar(select(MonitorEvent))
assert row.first_seen == at and row.occurrences == 2 and row.resolved_at is None
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
restarted._evaluate(at + timedelta(minutes=2), {}, [], {})
restarted._persist(at + timedelta(minutes=2))
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at == at + timedelta(minutes=2)
def test_资源持续五分钟才告警且未知不算恢复(monitor_case):
collector, _ = monitor_case
at = now_local() - timedelta(minutes=6)
collector._evaluate(at, {"cpu_percent": 90}, [], {})
assert not collector.conditions
collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 90}, [], {})
assert "CPU_HIGH" in collector.conditions
collector._evaluate(at + timedelta(minutes=6), {}, [], {})
assert "CPU_HIGH" in collector.conditions
collector._evaluate(at + timedelta(minutes=7), {"cpu_percent": 30}, [], {})
assert "CPU_HIGH" not in collector.conditions
def test_低请求量不报错误率且四百不触发系统故障(monitor_case):
collector, _ = monitor_case
at = now_local()
for i in range(19):
collector.recorder.record("GET", "/items", 500, 1, str(i), at=at)
collector._evaluate(at, {}, [], {})
assert "HTTP_ERROR_RATE" not in collector.conditions
collector.recorder.record("GET", "/items", 400, 1, "x", at=at)
collector._evaluate(at, {}, [], {})
assert "HTTP_ERROR_RATE" in collector.conditions
def test_过期清理仅限监控表且保留活动异常(monitor_case):
collector, factory = monitor_case
old = now_local() - timedelta(days=8)
seed(collector, old)
collector._evaluate(old, {"disk_percent": 99}, [], {})
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 0
assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 0
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
assert session.scalar(select(MonitorEvent)).code == "DISK_HIGH"
def make_api(collector, factory):
app = FastAPI()
app.state.monitor = collector
app.include_router(router, prefix="/api/v1/system")
register_exception_handlers(app)
app.add_middleware(MonitorMiddleware, recorder=collector.recorder)
def database():
with factory() as session:
yield session
app.dependency_overrides[get_db] = database
return app
def test_未登录与普通用户均不能读监控(monitor_case, monkeypatch):
collector, factory = monitor_case
app = make_api(collector, factory)
client = TestClient(app)
assert client.get("/api/v1/system/monitor/overview").status_code == 401
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1)
monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["user"])
for path in ("overview", "trends", "interfaces", "events", "events/1", "diagnostic"):
assert client.get(f"/api/v1/system/monitor/{path}").status_code == 403
def test_管理员契约查询约束与脱敏导出(monitor_case, monkeypatch):
collector, factory = monitor_case
seed(collector, now_local() - timedelta(minutes=1))
collector._persist(now_local())
app = make_api(collector, factory)
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1)
monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["admin"])
client = TestClient(app)
for path in ("overview", "trends", "interfaces", "events", "events/1"):
response = client.get(f"/api/v1/system/monitor/{path}")
assert response.status_code == 200, response.text
assert response.json()["code"] == 200
assert client.get("/api/v1/system/monitor/trends?hours=999").status_code == 400
for hours in (1, 24, 168):
for endpoint in ("trends", "interfaces", "events"):
assert client.get(f"/api/v1/system/monitor/{endpoint}?hours={hours}").status_code == 200
assert client.get("/api/v1/system/monitor/events?request_id=invalid").status_code == 400
assert client.get("/api/v1/system/monitor/events?pageSize=201").status_code == 400
result = client.get("/api/v1/system/monitor/events?request_id=" + "a" * 32).json()["data"]
assert result["total"] == 1
export = client.get("/api/v1/system/monitor/diagnostic")
assert "attachment" in export.headers["content-disposition"]
assert not any(
field in export.text
for field in ("password", "secret", "token", "traceback", "request_data", "payload_json")
)
def test_未处理异常编号匹配日志且只记录一次(monitor_case, caplog):
collector, factory = monitor_case
app = make_api(collector, factory)
@app.get("/explode")
def explode():
raise RuntimeError("synthetic-private-details")
response = TestClient(app, raise_server_exceptions=False).get("/explode")
request_id = response.headers["x-request-id"]
assert response.status_code == 500
assert "synthetic-private-details" not in response.text
assert len(collector.recorder.events) == 1
assert collector.recorder.events[0]["request_id"] == request_id
assert any(getattr(record, "request_id", None) == request_id for record in caplog.records)
assert "synthetic-private-details" not in json.dumps(
jsonable_encoder(list(collector.recorder.events))
)
def test_后台日志摘要不读取异常原文且请求日志不重复(monitor_case):
import logging
from app.core.monitoring import REQUEST_ID
collector, _ = monitor_case
handler = RuntimeErrorHandler(collector.recorder)
record = logging.LogRecord("app.jobs", logging.ERROR, "", 0, "password=synthetic", (), None)
handler.emit(record)
token = REQUEST_ID.set("a" * 32)
try:
handler.emit(record)
finally:
REQUEST_ID.reset(token)
assert len(collector.recorder.events) == 1
assert "synthetic" not in str(collector.recorder.events)
def test_过期采集不继续显示依赖正常(monitor_case):
collector, factory = monitor_case
collector.state.update(
collected_at=now_local() - timedelta(minutes=2),
dependencies=[
{"name": "MySQL", "status": "normal", "message": "正常", "checked_at": now_local()}
],
)
with factory() as session:
result = service.overview(session, collector)
assert result.status == "unknown"
assert result.dependencies[0].status == "unknown"
def test_重启首次CPU未知不恢复已有告警且健康后恢复(monitor_case):
collector, factory = monitor_case
at = now_local() - timedelta(minutes=7)
collector._evaluate(at, {"cpu_percent": 99}, [], {})
collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 99}, [], {})
collector._persist(at + timedelta(minutes=5))
restarted = MonitorCollector(collector.settings, Recorder())
restarted._factory = lambda: factory
restarted._evaluate(at + timedelta(minutes=6), {}, [], {})
restarted._persist(at + timedelta(minutes=6))
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at is None
restarted._evaluate(now_local(), {"cpu_percent": 30}, [], {})
restarted._persist(now_local())
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at is not None
def test_T加状态未知不误报恢复(monitor_case):
collector, factory = monitor_case
at = now_local() - timedelta(minutes=1)
collector._evaluate(
at,
{},
[],
{"enabled": True, "alerts": [{"code": "WORKER_OFFLINE", "message": "采集进程心跳中断"}]},
)
collector._persist(at)
collector._evaluate(now_local(), {}, [], {"status": "unknown", "alerts": []})
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at is None
def test_监控权限登记且不能授予普通角色(monitor_case):
from app.services.authorization_policy import permission_for
from app.services.authorization_service import calculate
from app.services.permission_catalog import CATALOG
_, factory = monitor_case
assert CATALOG["system:monitor:view"]["admin_only"]
assert permission_for("system.monitor", "diagnostic", "GET") == "system:monitor:view"
with factory() as session:
user = SimpleNamespace(id=1, status=1)
assert "system:monitor:view" in calculate(session, user, roles=["admin"])["permissions"]