签入版本
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""中间件注册顺序:限流须在 DB 会话/操作日志之外(更外层),
|
||||
否则被限流的请求仍先开 DB 会话、仍写操作日志,DoS 时放大 DB 与日志负担。"""
|
||||
from core.app import create_app
|
||||
|
||||
|
||||
def _middleware_names(app):
|
||||
"""按执行顺序(列表最前 = 最外层/先执行)提取各中间件名。"""
|
||||
names = []
|
||||
for m in app.user_middleware:
|
||||
dispatch = getattr(m, 'kwargs', {}).get('dispatch')
|
||||
if dispatch is not None:
|
||||
names.append(getattr(dispatch, '__name__', str(dispatch)))
|
||||
else:
|
||||
names.append(getattr(m.cls, '__name__', str(m)))
|
||||
return names
|
||||
|
||||
|
||||
def test_rate_limit_runs_before_db_session_and_operation_log():
|
||||
names = _middleware_names(create_app())
|
||||
# 限流必须比 DB 会话 / 操作日志更外层(先执行),拒绝请求不进入 DB 与日志
|
||||
assert 'rate_limit_middleware' in names
|
||||
assert names.index('rate_limit_middleware') < names.index('db_session_middleware')
|
||||
assert names.index('rate_limit_middleware') < names.index('operation_log_middleware')
|
||||
|
||||
|
||||
def test_cors_is_outermost():
|
||||
names = _middleware_names(create_app())
|
||||
# CORS 在最外层,直接返回的响应也带 CORS 头
|
||||
assert 'CORSMiddleware' in names
|
||||
assert names.index('CORSMiddleware') == 0
|
||||
@@ -0,0 +1,120 @@
|
||||
"""middleware/operation_log.py 操作日志中间件单元测试:日志写入延迟到响应后台任务,不阻塞响应。"""
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
import middleware.operation_log as op_middleware
|
||||
|
||||
|
||||
def _make_request(method='PUT', path='/api/v1/demo', user_id=1):
|
||||
scope = {
|
||||
'type': 'http',
|
||||
'method': method,
|
||||
'path': path,
|
||||
'headers': [(b'user-agent', b'test-agent')],
|
||||
'query_string': b'',
|
||||
'route': types.SimpleNamespace(summary='测试操作'),
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state.user_id = user_id
|
||||
return request
|
||||
|
||||
|
||||
async def _run_middleware(request):
|
||||
async def call_next(request):
|
||||
return JSONResponse(content={'code': 0, 'msg': 'ok'})
|
||||
return await op_middleware.operation_log_middleware(request, call_next)
|
||||
|
||||
|
||||
def _patch_to_thread(monkeypatch, submitted):
|
||||
"""记录提交到线程池的函数并同步执行,隔离真实线程调度。"""
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
submitted.append(func)
|
||||
return func(*args, **kwargs)
|
||||
monkeypatch.setattr(asyncio, 'to_thread', fake_to_thread)
|
||||
|
||||
|
||||
def test_operation_log_deferred_to_response_background(monkeypatch):
|
||||
"""日志写入挂到 response.background,响应返回后异步落库,不阻塞响应。"""
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(op_service, 'record_operation_log',
|
||||
lambda request, title, **kw: captured.update(kw) or None)
|
||||
|
||||
resp = asyncio.run(_run_middleware(_make_request()))
|
||||
|
||||
assert resp.status_code == 200
|
||||
# 响应上挂载后台任务(而非在响应返回前同步执行日志写入)
|
||||
assert resp.background is not None
|
||||
# 后台任务执行时调用 record_operation_log 写入日志
|
||||
asyncio.run(resp.background())
|
||||
assert captured['status'] == 0
|
||||
assert captured['consume_time'] >= 0
|
||||
assert 'ok' in captured['result'] # result 采样非空
|
||||
|
||||
|
||||
def test_operation_log_records_failure_status(monkeypatch):
|
||||
"""业务失败(code=1)在后台任务中记录 error 与 status=1。"""
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(op_service, 'record_operation_log',
|
||||
lambda request, title, **kw: captured.update(kw) or None)
|
||||
|
||||
async def call_next(request):
|
||||
return JSONResponse(content={'code': 1, 'msg': '权限不足'})
|
||||
|
||||
resp = asyncio.run(op_middleware.operation_log_middleware(_make_request(), call_next))
|
||||
asyncio.run(resp.background())
|
||||
assert captured['status'] == 1
|
||||
assert captured['error'] == '权限不足'
|
||||
|
||||
|
||||
def test_operation_log_exception_path_writes_via_thread_pool(monkeypatch):
|
||||
"""请求异常无响应可挂后台任务:走线程池同步写入(低频异常路径)。"""
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
submitted = []
|
||||
_patch_to_thread(monkeypatch, submitted)
|
||||
captured = {}
|
||||
monkeypatch.setattr(op_service, 'record_operation_log',
|
||||
lambda request, title, **kw: captured.update(kw) or None)
|
||||
|
||||
async def call_next(request):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(op_middleware.operation_log_middleware(_make_request(), call_next))
|
||||
assert submitted == [op_service.record_operation_log]
|
||||
assert captured['status'] == 1
|
||||
assert captured['error'] == 'boom'
|
||||
|
||||
|
||||
def test_operation_log_skipped_for_get_request(monkeypatch):
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
submitted = []
|
||||
_patch_to_thread(monkeypatch, submitted)
|
||||
monkeypatch.setattr(op_service, 'record_operation_log', lambda *a, **k: None)
|
||||
|
||||
resp = asyncio.run(_run_middleware(_make_request(method='GET')))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert submitted == []
|
||||
|
||||
|
||||
def test_exclusion_uses_path_boundary_not_substring():
|
||||
"""排除匹配按路径段边界,避免子串误伤其他模块路由。"""
|
||||
# /api/v1/index/userInfo 是个人中心写操作,不应被子串 '/api/v1/index/user' 误排除
|
||||
assert op_middleware._should_log(_make_request(method='PUT', path='/api/v1/index/userInfo'))
|
||||
# 挂载前缀下的写操作应排除
|
||||
assert not op_middleware._should_log(_make_request(method='POST', path='/api/v1/login'))
|
||||
assert not op_middleware._should_log(_make_request(method='POST', path='/api/v1/upload/uploadFile'))
|
||||
assert not op_middleware._should_log(_make_request(method='DELETE', path='/api/v1/operationlog/delete/1'))
|
||||
assert not op_middleware._should_log(_make_request(method='DELETE', path='/api/v1/loginlog/batchDelete'))
|
||||
# 业务模块写操作应记录
|
||||
assert op_middleware._should_log(_make_request(method='PUT', path='/api/v1/user/update/1'))
|
||||
@@ -0,0 +1,105 @@
|
||||
"""middleware/rate_limit.py 请求限流中间件单元测试。"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import fakeredis.aioredis
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import middleware.rate_limit as rate_limit_module
|
||||
from middleware.rate_limit import rate_limit_middleware
|
||||
|
||||
|
||||
def _make_app():
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# 在应用运行的事件循环内创建 FakeRedis,避免跨循环绑定问题
|
||||
app.state.redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
yield
|
||||
await app.state.redis.aclose()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@app.get("/")
|
||||
async def home():
|
||||
return {"ok": True}
|
||||
|
||||
app.middleware("http")(rate_limit_middleware)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_rate_limit_disabled_passes(monkeypatch):
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", False)
|
||||
with TestClient(_make_app()) as client:
|
||||
for _ in range(5):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
async def _fake_sliding_window(redis, current_key, prev_key, window_seconds, window_index, now):
|
||||
"""fakeredis 不支持 Lua eval,用等价实现打桩滑动窗口计数/加权语义。"""
|
||||
cur = await redis.incr(current_key)
|
||||
if cur == 1:
|
||||
await redis.expire(current_key, window_seconds)
|
||||
prev = int(await redis.get(prev_key) or 0)
|
||||
elapsed = now - window_index * window_seconds
|
||||
f = elapsed / window_seconds
|
||||
f = max(0.0, min(1.0, f))
|
||||
return prev * (1 - f) + cur
|
||||
|
||||
|
||||
def test_rate_limit_exceeds_returns_business_error(monkeypatch):
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 3)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 60)
|
||||
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
|
||||
with TestClient(_make_app()) as client:
|
||||
for _ in range(3):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["code"] == 1
|
||||
assert "过于频繁" in r.json()["msg"]
|
||||
|
||||
|
||||
def test_rate_limit_window_accepts_after_reset(monkeypatch):
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 2)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 60)
|
||||
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
|
||||
with TestClient(_make_app()) as client:
|
||||
for _ in range(2):
|
||||
r = client.get("/")
|
||||
assert r.json()["ok"] is True
|
||||
r = client.get("/")
|
||||
assert r.json()["code"] == 1
|
||||
# 同一窗口内持续限流,不会因后续请求解除
|
||||
r = client.get("/")
|
||||
assert r.json()["code"] == 1
|
||||
|
||||
|
||||
def test_rate_limit_sliding_window_prevents_boundary_burst(monkeypatch):
|
||||
"""窗口边界处请求数按时间加权,不发生固定窗口的 2× 突发。"""
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 3)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 100)
|
||||
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
|
||||
|
||||
clock = {"t": 50.0}
|
||||
monkeypatch.setattr(rate_limit_module.time, "time", lambda: clock["t"])
|
||||
|
||||
with TestClient(_make_app()) as client:
|
||||
# 窗口0(t=50):2 个请求,配额充足,放行
|
||||
for _ in range(2):
|
||||
assert client.get("/").json()["ok"] is True
|
||||
# 进入窗口1(t=150,f=0.5):上一窗口 2 个请求加权计入
|
||||
clock["t"] = 150.0
|
||||
# effective: 2*0.5+1=2 放行;2*0.5+2=3 放行;2*0.5+3=4 > 3 拒绝
|
||||
for _ in range(2):
|
||||
assert client.get("/").json()["ok"] is True
|
||||
r = client.get("/")
|
||||
assert r.json()["code"] == 1
|
||||
@@ -0,0 +1,81 @@
|
||||
"""middleware/upload_size_limit.py HTTP 层上传体积预拦截中间件单元测试。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from middleware.upload_size_limit import upload_size_limit_middleware
|
||||
from core.config.app import UPLOAD_HTTP_MAX_SIZE_MB, UPLOAD_MAX_SIZE_MB
|
||||
|
||||
|
||||
def _make_request(method="POST", content_length=None):
|
||||
headers = []
|
||||
if content_length is not None:
|
||||
headers.append((b"content-length", str(content_length).encode()))
|
||||
scope = {
|
||||
"type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1",
|
||||
"method": method, "scheme": "http", "path": "/upload/uploadFile",
|
||||
"raw_path": b"/upload/uploadFile", "query_string": b"", "root_path": "",
|
||||
"headers": headers, "client": ("127.0.0.1", 1234),
|
||||
"server": ("127.0.0.1", 8031),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
def _call(method="POST", content_length=None):
|
||||
request = _make_request(method=method, content_length=content_length)
|
||||
reached = {"called": False}
|
||||
|
||||
async def call_next(req):
|
||||
reached["called"] = True
|
||||
return Response(status_code=200)
|
||||
|
||||
return request, call_next, reached
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_rejects_oversized_body():
|
||||
# 声明超过 HTTP 阈值 → 提前拦截,业务(读取请求体)不执行
|
||||
limit = UPLOAD_HTTP_MAX_SIZE_MB * 1024 * 1024
|
||||
request, call_next, reached = _call(content_length=limit + 1)
|
||||
|
||||
resp = await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert isinstance(resp, JSONResponse)
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 1
|
||||
assert "超过限制" in body["msg"]
|
||||
assert f"{UPLOAD_MAX_SIZE_MB}MB" in body["msg"]
|
||||
assert reached["called"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_passes_at_threshold():
|
||||
# 恰好等于 HTTP 阈值 → 放行(精确大小由业务层校验)
|
||||
limit = UPLOAD_HTTP_MAX_SIZE_MB * 1024 * 1024
|
||||
request, call_next, reached = _call(content_length=limit)
|
||||
|
||||
resp = await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert reached["called"] is True
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_passes_chunked_no_length():
|
||||
# 分块传输(无 Content-Length):头部无法判定,放行交由业务层流式兜底
|
||||
request, call_next, reached = _call(content_length=None)
|
||||
|
||||
await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert reached["called"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_ignores_get():
|
||||
request, call_next, reached = _call(method="GET", content_length=99999999)
|
||||
|
||||
await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert reached["called"] is True
|
||||
Reference in New Issue
Block a user