2026-10-13 20:55:28 +08:00
|
|
|
"""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 发件人】 <bot@demo.com>"
|
|
|
|
|
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】 <env-bot@x.com>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 10:17:52 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 20:55:28 +08:00
|
|
|
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
|