feat: 配置中心-凭据(邮件推送AI表单/掩码交互/SMTP测试/推送探测端点)

- 后端 POST /tenants/{slug}/email/test:resolve_email_settings 当前配置
  真发测试邮件(主题 [all-kefu] SMTP 测试邮件+租户名+UTC+8 时间);
  回退后 host/user 仍空 → 400;成败如实返回 {sent, error},密钥不进响应
- 后端 POST /tenants/{slug}/push/test:连通性探测——与 _do_post 同款
  连接形态 POST 空 messages 批量(红线:绝不携带线索数据);不可达
  reason 分类 dns/timeout/connect(Windows 裸 ConnectError 文案兜底),
  auth_ok 三态(2xx 真 / 401·403 假 / 其余 null),单次无重试
- email_service 抽 _smtp_send 共用连接逻辑(_send 契约不变)+
  send_test_email(成败+错误串如实返回,不走吞异常 bool)
- 前端 CredsPane:邮件/推送/AI 三组凭据表单(数值/邮箱前端预检 +
  后端 400 双层);密钥哨兵交互(__SET__ 占位符/不动保存保持库值/
  清除按钮显式清空可撤销);SMTP 测试与推送探测三态内联展示
- 测试:pytest 新增 18(email/test 7 + push/test 11),全量 595 通过;
  vitest 新增 20(model 哨兵往返/预检边界 + API 调用形态),107 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 20:55:28 +08:00
parent 39d72c53aa
commit bf923d9cf7
11 changed files with 2024 additions and 19 deletions
+177
View File
@@ -0,0 +1,177 @@
"""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
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
+243
View File
@@ -0,0 +1,243 @@
"""POST /api/platform/tenants/{slug}/push/test(Phase 3 Task 7c)——推送连通性探测。
端点红线: **不发任何线索数据**——探测 body = {"messages": []}(空批量,
与 lead_push_service._do_post 同端点同头;UniService 是批量结构,空列表
对方案应为无操作)。测试断言钉死这一形态。
响应契约: {reachable, auth_ok(可空), status, reason, error}——
- 传输层异常 → reachable=false,reason 分类 dns/timeout/connect;
- HTTP 有响应 → reachable=true + status;2xx → auth_ok=true,
401/403 → false,其余(4xx/5xx)→ null(到达但状态异常,鉴权不定);
- 单次探测不重试(与 _run_push 的重试矩阵无关)。
测试纪律: monkeypatch config_routes.httpx.AsyncClient(假客户端),绝不真连。
"""
import socket
import httpx
from app.engine.config import settings
from app.platform.routes import config_routes
from tests.test_tenant_admin_api import _auth, _bearer, _create, _tenant_admin_token
CONFIG = "/api/platform/tenants/{slug}/config"
PUSH_TEST = "/api/platform/tenants/{slug}/push/test"
PUSH_GROUP = {
"enabled": True,
"url": "https://push.example/open/v1/inquiry/push",
"api_key": "k-t1",
"prefix": "demo-p",
"timeout": 5.5,
"max_attempts": 3,
"retry_backoff_base": 1,
}
def _install_fake_client(monkeypatch, handler):
"""config_routes.httpx.AsyncClient 桩: handler(call) → httpx.Response,
抛异常照抛。返回调用记录(init 构造参数 + 每次 post 的 url/json/headers)。"""
calls = []
class FakeClient:
def __init__(self, **kwargs):
calls.append({"init": kwargs})
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, url, json=None, headers=None):
call = {"url": url, "json": json, "headers": dict(headers or {})}
calls.append(call)
return handler(call)
monkeypatch.setattr(config_routes.httpx, "AsyncClient", FakeClient)
return calls
def _ok(call):
return httpx.Response(200, json={"success": True, "data": {
"accepted": 0, "duplicated": 0, "failed": []}})
def _put_push_group(client, slug, payload):
return client.put(f"{CONFIG.format(slug=slug)}/push",
headers=_auth(client), json=payload)
def _post_test(client, slug, headers=None):
return client.post(PUSH_TEST.format(slug=slug), headers=headers or _auth(client))
def _post_calls(calls):
return [c for c in calls if "url" in c]
def test_push_test_probe_shape_and_ok(super_client, monkeypatch):
"""正常路径: 200 → reachable+auth_ok;请求形态逐项钉死——同 _do_post 的
端点/X-Api-Key 头/超时/follow_redirects,body 恒为空 messages(红线)。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
calls = _install_fake_client(monkeypatch, _ok)
r = _post_test(super_client, "demo")
assert r.status_code == 200, r.text
assert r.json() == {"slug": "demo", "reachable": True, "auth_ok": True,
"status": 200, "reason": None, "error": None}
assert calls[0]["init"] == {"timeout": 5.5, "follow_redirects": True}
post = _post_calls(calls)[0]
assert post["url"] == "https://push.example/open/v1/inquiry/push"
assert post["json"] == {"messages": []} # 红线: 空批量,零线索数据
assert post["headers"] == {"X-Api-Key": "k-t1"} # 与 _do_post 同头
def test_push_test_auth_rejected(super_client, monkeypatch):
"""可达但鉴权失败: 401/403 → auth_ok=false(前端三态展示的第二态)。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
for status in (401, 403):
_install_fake_client(
monkeypatch, lambda call, s=status: httpx.Response(s))
r = _post_test(super_client, "demo")
assert r.status_code == 200
body = r.json()
assert body["reachable"] is True and body["status"] == status
assert body["auth_ok"] is False
def test_push_test_5xx_reachable_auth_unknown_single_shot(super_client, monkeypatch):
"""5xx: 可达但状态异常(auth_ok=null);探测单次发起,不重试。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
calls = _install_fake_client(
monkeypatch, lambda call: httpx.Response(502))
r = _post_test(super_client, "demo")
assert r.status_code == 200
body = r.json()
assert body["reachable"] is True and body["status"] == 502
assert body["auth_ok"] is None
assert len(_post_calls(calls)) == 1 # 无重试矩阵
def test_push_test_unreachable_dns(super_client, monkeypatch):
"""DNS 失败(异常 cause 链含 gaierror)→ reachable=false + reason=dns。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def dns_fail(call):
raise httpx.ConnectError("Failed to resolve.") from socket.gaierror(
-2, "Name or service not known")
_install_fake_client(monkeypatch, dns_fail)
r = _post_test(super_client, "demo")
assert r.status_code == 200
body = r.json()
assert body["reachable"] is False and body["auth_ok"] is None
assert body["status"] is None
assert body["reason"] == "dns" and body["error"]
def test_push_test_unreachable_connect(super_client, monkeypatch):
"""连接拒绝/不可达(无 gaierror cause 的 ConnectError)→ reason=connect。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def refused(call):
raise httpx.ConnectError("Connection refused")
_install_fake_client(monkeypatch, refused)
r = _post_test(super_client, "demo")
body = r.json()
assert body["reachable"] is False
assert body["reason"] == "connect"
def test_push_test_unreachable_dns_windows_text(super_client, monkeypatch):
"""Windows 实测形态(冒烟 Task 7c): getaddrinfo 失败被 httpx/anyio 包成
裸 ConnectError("[Errno 11001] getaddrinfo failed"),cause 链不带
gaierror——文案匹配兜底,仍须归 dns 而非 connect。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def win_dns_fail(call):
raise httpx.ConnectError("[Errno 11001] getaddrinfo failed")
_install_fake_client(monkeypatch, win_dns_fail)
r = _post_test(super_client, "demo")
body = r.json()
assert body["reachable"] is False
assert body["reason"] == "dns"
assert "getaddrinfo" in body["error"]
def test_push_test_unreachable_timeout(super_client, monkeypatch):
"""连接超时(TimeoutException 族)→ reason=timeout。"""
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200
def timed_out(call):
raise httpx.ConnectTimeout("timed out")
_install_fake_client(monkeypatch, timed_out)
r = _post_test(super_client, "demo")
body = r.json()
assert body["reachable"] is False
assert body["reason"] == "timeout"
def test_push_test_url_unconfigured_400(super_client, monkeypatch):
"""URL 回退后仍空(租户 url 空槽 + env PUSH_API_URL 清空)→ 400。"""
monkeypatch.setattr(settings, "PUSH_API_URL", "")
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", {"prefix": "demo-p"}).status_code == 200
r = _post_test(super_client, "demo")
assert r.status_code == 400
detail = r.json()["detail"]
assert "URL" in detail and "未配置" in detail
def test_push_test_env_fallback(super_client, monkeypatch):
"""租户 push 组空槽 → env PUSH_* 回退(探测用的就是部署级平台默认)。"""
monkeypatch.setattr(settings, "PUSH_API_URL", "https://env-push.example/v1")
monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key")
monkeypatch.setattr(settings, "PUSH_TIMEOUT_SECONDS", 8.0)
_create(super_client, slug="demo")
assert _put_push_group(super_client, "demo", {"prefix": "demo-p"}).status_code == 200
calls = _install_fake_client(monkeypatch, _ok)
r = _post_test(super_client, "demo")
assert r.status_code == 200
assert r.json()["reachable"] is True
assert calls[0]["init"]["timeout"] == 8.0
post = _post_calls(calls)[0]
assert post["url"] == "https://env-push.example/v1"
assert post["headers"] == {"X-Api-Key": "env-key"}
def test_push_test_unknown_tenant_404(super_client, monkeypatch):
_install_fake_client(monkeypatch, _ok)
r = _post_test(super_client, "ghost")
assert r.status_code == 404
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")
_create(super_client, slug="au2", profile="auto",
admin="au2-admin", password="au2-pass-1")
assert _put_push_group(super_client, "jc2", PUSH_GROUP).status_code == 200
token = _tenant_admin_token(super_client, "jc2-admin", "jc2-pass-1")
_install_fake_client(monkeypatch, _ok)
# 本租户管理员放行
assert _post_test(super_client, "jc2", headers=_bearer(token)).status_code == 200
# 别的租户 → 403
assert _post_test(super_client, "au2", headers=_bearer(token)).status_code == 403
# 未认证 → 401
r = super_client.post(PUSH_TEST.format(slug="jc2"))
assert r.status_code == 401