"""POST /api/platform/tenants/{slug}/email/test(Phase 3 Task 7c)——SMTP 测试邮件。 端点契约(计划 Task 7c): - 用当前生效配置(resolve_email_settings:租户 email 组优先,空槽回退 env SMTP_*)**真发一封**:主题 "[all-kefu] SMTP 测试邮件",正文含租户名与 时间,From = sender_name/user,To = body.to; - SMTP 未配置(回退后 host/user 仍空)→ 400; - 发送成败如实返回 {sent, error}(失败不 5xx——凭据对不对让收件箱与 错误串说话);密钥绝不进响应。 测试纪律: monkeypatch email_service.aiosmtplib.send 捕获报文与连接参数, 绝不真发(同 test_email_rendering 的 _capture_send 姿势,但打在 aiosmtplib.send 一层——端点经 send_test_email 走完整组包路径)。 """ from datetime import datetime, timedelta, timezone from app.engine.config import settings from app.engine.services import email_service from tests.test_tenant_admin_api import _auth, _bearer, _create, _tenant_admin_token CONFIG = "/api/platform/tenants/{slug}/config" EMAIL_TEST = "/api/platform/tenants/{slug}/email/test" def _capture_smtp(monkeypatch): """aiosmtplib.send 桩: 捕获报文与连接参数,返回 True 语义(发送成功)。""" captured = {} async def fake_send(msg, **kwargs): captured["msg"] = msg captured["kwargs"] = kwargs return msg monkeypatch.setattr(email_service.aiosmtplib, "send", fake_send) return captured def _failing_smtp(monkeypatch, message: str): async def failing_send(msg, **kwargs): raise RuntimeError(message) monkeypatch.setattr(email_service.aiosmtplib, "send", failing_send) def _clear_smtp_env(monkeypatch): """env SMTP_* 清空(隔离本机 .env 联调值——同 test_push_settings_defaults 的隔离教训);"回退后仍空"的 400 分支才可控。""" for key in ("SMTP_HOST", "SMTP_USER", "SMTP_PASS", "NOTIFY_EMAIL"): monkeypatch.setattr(settings, key, "") monkeypatch.setattr(settings, "SMTP_PORT", 465) def _put_email_group(client, slug, payload): return client.put(f"{CONFIG.format(slug=slug)}/email", headers=_auth(client), json=payload) def _post_test(client, slug, body, headers=None): return client.post(EMAIL_TEST.format(slug=slug), headers=headers or _auth(client), json=body) SMTP_GROUP = { "smtp": {"host": "t.smtp.example.com", "port": 587, "user": "bot@demo.com", "password": "s3cret-pw"}, "sender_name": "Demo 发件人", "notify_emails": ["notify@demo.com"], } def test_email_test_sends_with_tenant_config(super_client, monkeypatch): """已配置 SMTP: 真发路径走通——主题/收件人/发件人/正文(租户名+时间) 与连接参数(租户四字段)逐项钉死;密钥不进响应。""" _create(super_client, slug="demo", name="Demo 建材") assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200 captured = _capture_smtp(monkeypatch) r = _post_test(super_client, "demo", {"to": "checker@example.com"}) assert r.status_code == 200, r.text assert r.json() == {"slug": "demo", "sent": True, "error": None} msg = captured["msg"] assert str(msg["Subject"]) == "[all-kefu] SMTP 测试邮件" assert str(msg["To"]) == "checker@example.com" assert str(msg["From"]) == "【Demo 发件人】 " body = msg.get_payload(0).get_payload(decode=True).decode("utf-8") assert "Demo 建材" in body # 正文含租户名 cst_today = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d") assert cst_today in body and "UTC+8" in body # 正文含时间(与推送同款 CST 口径) # 连接参数 = 租户配置(与 email_service._send 同一连接逻辑) assert captured["kwargs"] == { "hostname": "t.smtp.example.com", "port": 587, "username": "bot@demo.com", "password": "s3cret-pw", "use_tls": True} assert "s3cret-pw" not in r.text # 密钥不进响应 def test_email_test_env_fallback(super_client, monkeypatch): """租户 smtp 未配置 → 回退 env SMTP_*(部署级平台默认同样可发)。""" _create(super_client, slug="demo") # jiancai 档案: email 组无 smtp 键 for key, value in (("SMTP_HOST", "env.smtp.example.com"), ("SMTP_PORT", 465), ("SMTP_USER", "env-bot@x.com"), ("SMTP_PASS", "env-pass")): monkeypatch.setattr(settings, key, value) captured = _capture_smtp(monkeypatch) r = _post_test(super_client, "demo", {"to": "checker@example.com"}) assert r.status_code == 200, r.text assert r.json()["sent"] is True assert captured["kwargs"]["hostname"] == "env.smtp.example.com" assert captured["kwargs"]["password"] == "env-pass" # sender_name 无租户覆盖 → 平台默认;user 走 env assert str(captured["msg"]["From"]) == "【HESUPIPE】 " def test_email_test_smtp_unconfigured_400(super_client, monkeypatch): """SMTP 未配置(租户组与 env 回退后仍空)→ 400 提示(不试探连接)。""" _clear_smtp_env(monkeypatch) _create(super_client, slug="demo") r = _post_test(super_client, "demo", {"to": "checker@example.com"}) assert r.status_code == 400 detail = r.json()["detail"] assert "SMTP" in detail and "未配置" in detail def test_email_test_send_failure_reports_error(super_client, monkeypatch): """发送失败不 5xx: {sent: false, error 带原始错误串}如实返回。""" _create(super_client, slug="demo") assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200 _failing_smtp(monkeypatch, "smtp auth rejected") r = _post_test(super_client, "demo", {"to": "checker@example.com"}) assert r.status_code == 200, r.text body = r.json() assert body["sent"] is False assert "smtp auth rejected" in body["error"] def test_email_test_validates_to(super_client, monkeypatch): """to 必填且须为含 @ 的字符串;未知字段 400(错误契约带字段路径)。""" _clear_smtp_env(monkeypatch) _create(super_client, slug="demo") for bad in (None, 123, "", "no-at-sign", ["a@b.com"], {"to": "a@b.com"}): r = _post_test(super_client, "demo", {"to": bad}) assert r.status_code == 400, f"{bad!r}: {r.text}" assert "to" in r.json()["detail"] # 缺 to / 未知字段 assert _post_test(super_client, "demo", {}).status_code == 400 r = _post_test(super_client, "demo", {"to": "a@b.com", "extra": 1}) assert r.status_code == 400 assert "extra" in r.json()["detail"] def test_email_test_unknown_tenant_404(super_client, monkeypatch): _clear_smtp_env(monkeypatch) r = _post_test(super_client, "ghost", {"to": "a@b.com"}) 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。""" _clear_smtp_env(monkeypatch) _create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1") _create(super_client, slug="au2", profile="auto", admin="au2-admin", password="au2-pass-1") token = _tenant_admin_token(super_client, "jc2-admin", "jc2-pass-1") # 本租户管理员: 鉴权过,落在"未配置"业务分支 400(而非 403) r = _post_test(super_client, "jc2", {"to": "a@b.com"}, headers=_bearer(token)) assert r.status_code == 400 # 别的租户 → 403 r = _post_test(super_client, "au2", {"to": "a@b.com"}, headers=_bearer(token)) assert r.status_code == 403 # 未认证 → 401 r = super_client.post(EMAIL_TEST.format(slug="jc2"), json={"to": "a@b.com"}) assert r.status_code == 401