feat: 测试端点限流(每租户5次/15分钟)

- email/test 与 push/test 复用 LoginLimiter 滑动窗模式: (kind, tenant_slug)
  键每租户 15 分钟 5 次真实出站,超限 429——guard 在端点最前(鉴权后),
  被限流的租户连 400 预检也不触发;record 只落在通过全部预检真正出站
  的那次(校验失败/未配置不消耗预算,两端口预算按 kind 互不挤占)
- conftest autouse 复位限流器(跨测试隔离);新增三测: 第 6 次 429+
  不同租户独立+email/push 互不挤占、校验失败不耗预算
- 回归: 后端 630 passed(627+3)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 10:17:52 +08:00
parent d734ed6d2a
commit fff4f27e98
4 changed files with 171 additions and 2 deletions
+79 -2
View File
@@ -42,6 +42,8 @@ email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的
import copy import copy
import json import json
import socket import socket
import time
from collections import defaultdict
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@@ -78,6 +80,75 @@ settings_router = APIRouter(
prefix="/api/platform/settings", tags=["platform-settings"]) prefix="/api/platform/settings", tags=["platform-settings"])
# ---- 测试端点限流(Phase 4 Task 5B) -------------------------------------------------
TEST_WINDOW_SECONDS = 15 * 60
TEST_MAX_CALLS = 5
class SlidingWindowCounter:
"""(kind, tenant_slug) 键滑动窗计数: 窗内第 5 次发起后拒绝后续,窗过
恢复。LoginLimiter 同款风格/单进程内存态,但语义是"计数一切发起的
调用"——email/push 测试端点每次受理都可能触发出站,不分成败(登录
限流只计失败,这里没有"成功即清零"的正路)。计数只落在通过全部
预检(参数/租户/配置可用)真正出站的那次——校验失败的调用不消耗
预算,但超限后连预检一并拒绝(allow 守卫在端点最前)。"""
def __init__(
self,
window_seconds: float = TEST_WINDOW_SECONDS,
max_calls: int = TEST_MAX_CALLS) -> None:
self.window_seconds = window_seconds
self.max_calls = max_calls
self._calls: dict[tuple[str, str], list[float]] = defaultdict(list)
def allow(self, key: tuple[str, str]) -> bool:
"""窗内计数是否仍低于上限(不记录——记录走 record)。"""
self._prune(key, time.time())
return len(self._calls.get(key, ())) < self.max_calls
def record(self, key: tuple[str, str]) -> None:
now = time.time()
self._prune(key, now)
self._calls[key].append(now)
def _prune(self, key: tuple[str, str], now: float) -> None:
calls = self._calls.get(key)
if calls is None:
return
kept = [t for t in calls if t > now - self.window_seconds]
if kept:
self._calls[key] = kept
else:
self._calls.pop(key, None)
# 模块级单例: email/push 两测试端点共用(键含 kind,两预算互不挤占)
_test_limiter = SlidingWindowCounter()
def _reset_test_limiter_for_test() -> None:
"""单例复位(测试隔离),conftest autouse 前后各跑一次(同 auth 纪律)。"""
_test_limiter._calls.clear()
_test_limiter.window_seconds = TEST_WINDOW_SECONDS
_test_limiter.max_calls = TEST_MAX_CALLS
def _test_rate_guard(kind: str, slug: str) -> None:
"""测试端点的统一限流守卫: 超限 → 429(先于一切业务逻辑——被限流的
租户连 400 预检也不触发,更不许出站)。"""
if not _test_limiter.allow((kind, slug)):
raise HTTPException(
status_code=429,
detail=f"测试端点调用过于频繁(每租户 15 分钟 {TEST_MAX_CALLS} 次)"
"——请稍后再试")
def _record_test_call(kind: str, slug: str) -> None:
"""通过全部预检、即将出站的那次调用计数。"""
_test_limiter.record((kind, slug))
# ---- 助手 ----------------------------------------------------------------------- # ---- 助手 -----------------------------------------------------------------------
@@ -513,7 +584,9 @@ async def test_email(slug: str, request: Request,
优先/空槽回退 env SMTP_*)真发一封到 body.to——主题/正文契约见 优先/空槽回退 env SMTP_*)真发一封到 body.to——主题/正文契约见
email_service.send_test_email。SMTP 未配置(回退后 host/user 仍空) email_service.send_test_email。SMTP 未配置(回退后 host/user 仍空)
→ 400;发送失败不 5xx,{sent: false, error} 如实返回。不落库不 bump → 400;发送失败不 5xx,{sent: false, error} 如实返回。不落库不 bump
版本;密钥绝不进响应。""" 版本;密钥绝不进响应。
限流(Phase 4 Task 5B): 每租户 15 分钟 5 次真实出站,超限 429。"""
_test_rate_guard("email", slug)
body = await _json_body(request) body = await _json_body(request)
unknown = set(body) - _EMAIL_TEST_KEYS unknown = set(body) - _EMAIL_TEST_KEYS
if unknown: if unknown:
@@ -536,6 +609,7 @@ async def test_email(slug: str, request: Request,
status_code=400, status_code=400,
detail="SMTP 未配置(服务器/账号在租户配置与环境回退后均为空)" detail="SMTP 未配置(服务器/账号在租户配置与环境回退后均为空)"
"——请先在「配置中心 → 凭据」保存邮件 SMTP") "——请先在「配置中心 → 凭据」保存邮件 SMTP")
_record_test_call("email", slug)
sent, error = await send_test_email(to.strip(), tenant.name, snapshot) sent, error = await send_test_email(to.strip(), tenant.name, snapshot)
return {"slug": slug, "sent": sent, "error": error} return {"slug": slug, "sent": sent, "error": error}
@@ -590,7 +664,9 @@ async def test_push(slug: str, request: Request,
"""推送连通性探测: 只验 URL 可达 + 鉴权通过,不发真数据(空 messages """推送连通性探测: 只验 URL 可达 + 鉴权通过,不发真数据(空 messages
批量,见 _probe_push)。URL 回退后仍空 → 400;其余如实返回——不可达 批量,见 _probe_push)。URL 回退后仍空 → 400;其余如实返回——不可达
(含 dns/timeout/connect 原因分类)/可达但鉴权失败(401/403)/正常(2xx); (含 dns/timeout/connect 原因分类)/可达但鉴权失败(401/403)/正常(2xx);
其余状态码可达但鉴权不定(auth_ok=null)。不落库不 bump 版本。""" 其余状态码可达但鉴权不定(auth_ok=null)。不落库不 bump 版本。
限流(Phase 4 Task 5B): 每租户 15 分钟 5 次探测,超限 429。"""
_test_rate_guard("push", slug)
engine = request.app.state.platform_engine engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session: async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug) tenant = await _get_tenant_or_404(session, slug)
@@ -601,6 +677,7 @@ async def test_push(slug: str, request: Request,
status_code=400, status_code=400,
detail="推送 URL 未配置(租户配置与环境回退后均为空)" detail="推送 URL 未配置(租户配置与环境回退后均为空)"
"——请先在「配置中心 → 凭据」保存推送 URL") "——请先在「配置中心 → 凭据」保存推送 URL")
_record_test_call("push", slug)
return {"slug": slug, **await _probe_push(cfg)} return {"slug": slug, **await _probe_push(cfg)}
+11
View File
@@ -97,6 +97,17 @@ def _reset_platform_settings_snapshot():
_reset_for_test() _reset_for_test()
@pytest.fixture(autouse=True)
def _reset_test_endpoint_limiter():
"""Phase 4 Task 5B: email/push 测试端点限流器前后复位——同测试内
连发会被窗限流(这是被测行为),跨测试不隔离则前一脚打满的预算顶掉
后脚的断言;注入的窗长/上限一并还原默认。"""
from app.platform.routes.config_routes import _reset_test_limiter_for_test
_reset_test_limiter_for_test()
yield
_reset_test_limiter_for_test()
# ---- Phase 2 Task 10: FakeAI 公共确定性桩(清理单 #1) -------------------------- # ---- Phase 2 Task 10: FakeAI 公共确定性桩(清理单 #1) --------------------------
# 收敛自四份拷贝(test_lead_email_trigger / test_cc_integration / # 收敛自四份拷贝(test_lead_email_trigger / test_cc_integration /
# test_channel_hygiene / test_lead_push_wiring,接口取超集: 前两份全量, # test_channel_hygiene / test_lead_push_wiring,接口取超集: 前两份全量,
+50
View File
@@ -157,6 +157,56 @@ def test_email_test_unknown_tenant_404(super_client, monkeypatch):
assert r.status_code == 404 assert r.status_code == 404
def test_email_test_rate_limited_after_5(super_client, monkeypatch):
"""限流(Phase 4 Task 5B): 每租户 15 分钟 5 次真实出站——前 5 次 200,
第 6 次 429;不同租户预算独立;400 校验失败/未配置不消耗预算。"""
_create(super_client, slug="demo")
_create(super_client, slug="other", profile="auto")
assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200
sends = []
async def counting_send(msg, **kwargs):
sends.append(msg)
return msg
monkeypatch.setattr(email_service.aiosmtplib, "send", counting_send)
for i in range(5):
r = _post_test(super_client, "demo", {"to": "checker@example.com"})
assert r.status_code == 200, f"第 {i + 1} 次不应限流: {r.text}"
# 第 6 次 → 429,且不再出站(真实发送仍 5 次)
r = _post_test(super_client, "demo", {"to": "checker@example.com"})
assert r.status_code == 429
assert "频繁" in r.json()["detail"]
assert len(sends) == 5
# 429 优先于参数校验: 超限后坏 body 也 429(不许预检出站侧通道)
r = _post_test(super_client, "demo", {"to": "not-an-email"})
assert r.status_code == 429
# 不同租户独立预算: other(未配置 SMTP)不受 demo 拉满影响
_clear_smtp_env(monkeypatch)
r = _post_test(super_client, "other", {"to": "a@b.com"})
assert r.status_code == 400 # 未配置业务分支照常,非 429
def test_email_test_validation_failures_do_not_consume_quota(
super_client, monkeypatch):
"""校验失败(to 非法/未配置 400)不计数——只有真实出站消耗预算
(管理操作误点不该被坏请求打满)。"""
_clear_smtp_env(monkeypatch)
_create(super_client, slug="demo")
for _ in range(7):
assert _post_test(
super_client, "demo", {"to": "bad"}).status_code == 400
assert _post_test(
super_client, "demo", {"to": "a@b.com"}).status_code == 400
# 15 分钟预算原封: 配好 SMTP 后第 1 次仍放行
assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200
_capture_smtp(monkeypatch)
assert _post_test(
super_client, "demo", {"to": "a@b.com"}).status_code == 200
def test_email_test_auth_matrix(super_client, monkeypatch): def test_email_test_auth_matrix(super_client, monkeypatch):
"""require_tenant(slug): 本租户管理员放行(400 是业务分支,鉴权先于 """require_tenant(slug): 本租户管理员放行(400 是业务分支,鉴权先于
业务);跨租户 403;未认证 401。""" 业务);跨租户 403;未认证 401。"""
+31
View File
@@ -225,6 +225,37 @@ def test_push_test_unknown_tenant_404(super_client, monkeypatch):
assert r.status_code == 404 assert r.status_code == 404
def test_push_test_rate_limited_after_5(super_client, monkeypatch):
"""限流(Phase 4 Task 5B): 每租户 15 分钟 5 次探测——前 5 次 200,
第 6 次 429;email 与 push 预算互不挤占;不同租户独立。"""
_create(super_client, slug="demo")
_create(super_client, slug="other", profile="auto")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
calls = _install_fake_client(monkeypatch, _ok)
for i in range(5):
r = _post_test(super_client, "demo")
assert r.status_code == 200, f"第 {i + 1} 次不应限流: {r.text}"
r = _post_test(super_client, "demo")
assert r.status_code == 429
assert "频繁" in r.json()["detail"]
assert len(_post_calls(calls)) == 5 # 第 6 次不再出站
# email 预算独立(键含 kind): 同租户 email/test 不受 push 拉满影响
# (不配置 SMTP → 400 业务分支,证明 guard 放行)
monkeypatch.setattr(settings, "SMTP_HOST", "")
monkeypatch.setattr(settings, "SMTP_USER", "")
r = super_client.post(f"/api/platform/tenants/demo/email/test",
headers=_auth(super_client), json={"to": "a@b.com"})
assert r.status_code == 400
# 不同租户独立预算
assert _put_push_group(super_client, "other", {
**PUSH_GROUP, "prefix": "other-p"}).status_code == 200
r = _post_test(super_client, "other")
assert r.status_code == 200
def test_push_test_auth_matrix(super_client, monkeypatch): def test_push_test_auth_matrix(super_client, monkeypatch):
"""require_tenant(slug): 本租户管理员放行;跨租户 403;未认证 401。""" """require_tenant(slug): 本租户管理员放行;跨租户 403;未认证 401。"""
_create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1") _create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1")