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
+11
View File
@@ -97,6 +97,17 @@ def _reset_platform_settings_snapshot():
_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) --------------------------
# 收敛自四份拷贝(test_lead_email_trigger / test_cc_integration /
# 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
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):
"""require_tenant(slug): 本租户管理员放行(400 是业务分支,鉴权先于
业务);跨租户 403;未认证 401。"""
+31
View File
@@ -225,6 +225,37 @@ def test_push_test_unknown_tenant_404(super_client, monkeypatch):
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):
"""require_tenant(slug): 本租户管理员放行;跨租户 403;未认证 401。"""
_create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1")