From bf923d9cf77239f54a8587f089a90e19e472ecb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 20:55:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E7=BD=AE=E4=B8=AD=E5=BF=83-?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=EF=BC=88=E9=82=AE=E4=BB=B6=E6=8E=A8=E9=80=81?= =?UTF-8?q?AI=E8=A1=A8=E5=8D=95/=E6=8E=A9=E7=A0=81=E4=BA=A4=E4=BA=92/SMTP?= =?UTF-8?q?=E6=B5=8B=E8=AF=95/=E6=8E=A8=E9=80=81=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 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 --- admin/src/api/__tests__/config.test.ts | 71 +- admin/src/api/config.ts | 73 +- admin/src/types/import/components.d.ts | 1 + admin/src/views/config/center/CredsPane.vue | 743 ++++++++++++++++++ .../config/center/__tests__/model.test.ts | 273 ++++++- admin/src/views/config/center/index.vue | 38 +- admin/src/views/config/center/model.ts | 260 +++++- app/engine/services/email_service.py | 50 +- app/platform/routes/config_routes.py | 114 +++ tests/test_email_test_endpoint.py | 177 +++++ tests/test_push_test_endpoint.py | 243 ++++++ 11 files changed, 2024 insertions(+), 19 deletions(-) create mode 100644 admin/src/views/config/center/CredsPane.vue create mode 100644 tests/test_email_test_endpoint.py create mode 100644 tests/test_push_test_endpoint.py diff --git a/admin/src/api/__tests__/config.test.ts b/admin/src/api/__tests__/config.test.ts index 4795102..f267988 100644 --- a/admin/src/api/__tests__/config.test.ts +++ b/admin/src/api/__tests__/config.test.ts @@ -1,5 +1,6 @@ /** - * 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石) + * 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石; + * Task 7c——凭据测试端点) * * 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与 * upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http @@ -9,6 +10,9 @@ * 包装),备注走 comment 查询参数,无备注时不带 params * - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演, * 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数 + * - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已 + * 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索 + * 数据)——两者 showErrorMessage: false 内联展示 * - 头像上传:multipart file 字段(FormData) * - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项 */ @@ -24,6 +28,8 @@ import { fetchTenantConfig, normalizeBrandGroup, putConfigGroup, + testEmail, + testPush, testWall, uploadBrandAvatar } from '@/api/config' @@ -194,6 +200,69 @@ describe('试金石 API(testWall · Phase 3 Task 7b)', () => { }) }) +describe('凭据测试 API(testEmail / testPush · Phase 3 Task 7c)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('testEmail:POST email/test,body 只带 {to}(收件人)', async () => { + const response = { slug: 'jiancai', sent: true, error: null } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await testEmail('jiancai', { to: 'you@example.com' }, { + showErrorMessage: false + }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/email/test', + { to: 'you@example.com' }, + { showErrorMessage: false } // 400「SMTP 未配置」等内联展示,不走全局提示 + ) + }) + + it('testEmail:发送失败也走成功响应通道(sent=false + error 串,非异常)', async () => { + const response = { slug: 'jiancai', sent: false, error: 'SMTPRecipientsRefused(...)' } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await testEmail('jiancai', { to: 'you@example.com' }) + + expect(res.sent).toBe(false) + expect(res.error).toContain('SMTP') + }) + + it('testPush:POST push/test,无请求体(探测读已保存配置)', async () => { + const response = { + slug: 'jiancai', + reachable: false, + auth_ok: null, + status: null, + reason: 'dns', + error: 'Failed to resolve.' + } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await testPush('jiancai', { showErrorMessage: false }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + const [url, body, config] = vi.mocked(http.post).mock.calls[0] + expect(url).toBe('/api/platform/tenants/jiancai/push/test') + expect(body).toBeUndefined() // 端点不读 body——探测参数全来自已保存配置 + expect(config).toEqual({ showErrorMessage: false }) + }) + + it('CONFIG_ENDPOINTS.emailTest / pushTest:端点路径与后端 config_routes 一致', () => { + expect(CONFIG_ENDPOINTS.emailTest('jiancai')).toBe( + '/api/platform/tenants/jiancai/email/test' + ) + expect(CONFIG_ENDPOINTS.pushTest('jiancai')).toBe( + '/api/platform/tenants/jiancai/push/test' + ) + }) +}) + describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => { it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => { const out = normalizeBrandGroup({ title: 'HESUPIPE Support' }) diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index fcdd87e..19920bf 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -25,6 +25,14 @@ * 400 带字段路径;has_contact / source 可选(默认 false / web); * - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→ * 零号码墙(replaced)→ 再索要墙(仅 has_contact,blocked 无替换)。 + * + * ## Task 7c 凭据测试契约(config_routes.test_email / test_push) + * + * - POST email/test:body {to}——后端用**已保存**的当前邮件配置真发一封 + * 测试邮件;SMTP 未配置 → 400;成败如实返回(sent=false 时带 error); + * - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索 + * 数据**(后端发空 messages 批量);不可达时 reason 分类 + * dns/timeout/connect,auth_ok 三态(true/false/null=不定)。 */ import { http, type RequestOptions } from './client' @@ -132,6 +140,32 @@ export interface AvatarUploadResponse { version: number } +/** SMTP 测试邮件请求(POST .../email/test;用**已保存**配置真发一封到 to) */ +export interface EmailTestRequest { + to: string +} + +/** SMTP 测试邮件响应(发送失败不 5xx——sent=false 时 error 带原因) */ +export interface EmailTestResponse { + slug: string + sent: boolean + error: string | null +} + +/** 推送探测不可达原因分类(前端据此给修复提示文案) */ +export type PushProbeReason = 'dns' | 'timeout' | 'connect' + +/** 推送连通性探测响应(POST .../push/test;空批量预检,零线索数据) */ +export interface PushTestResponse { + slug: string + reachable: boolean + /** true=2xx / false=401·403 / null=其他状态(到达但鉴权不定)或不可达 */ + auth_ok: boolean | null + status: number | null + reason: PushProbeReason | null + error: string | null +} + /** 配置中心端点 */ export const CONFIG_ENDPOINTS = { current: (slug: string) => `/api/platform/tenants/${slug}/config`, @@ -144,7 +178,9 @@ export const CONFIG_ENDPOINTS = { `/api/platform/tenants/${slug}/config/versions/${version}`, rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`, wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`, - avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar` + avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`, + emailTest: (slug: string) => `/api/platform/tenants/${slug}/email/test`, + pushTest: (slug: string) => `/api/platform/tenants/${slug}/push/test` } as const /** @@ -214,6 +250,41 @@ export function uploadBrandAvatar( ) } +/** + * SMTP 测试邮件(POST .../email/test):用**已保存**的当前邮件配置真发一封 + * 到 to——表单里未保存的编辑不生效(界面需提示"先保存再测试")。SMTP 未 + * 配置 → 400(调用方 showErrorMessage: false 自行内联展示);发送失败 + * 不抛错——sent=false 时 error 带原因串。 + */ +export function testEmail( + slug: string, + body: EmailTestRequest, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.emailTest(slug), + body, + config + ) +} + +/** + * 推送连通性探测(POST .../push/test):只验 URL 可达 + 鉴权,后端发空 + * messages 批量(零线索数据)。URL 未配置 → 400;结果三态由调用方按 + * reachable/auth_ok 展示(不可达 reason 分类 dns/timeout/connect)。 + * 同样用**已保存**配置——改完 URL 先保存再探测。 + */ +export function testPush( + slug: string, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.pushTest(slug), + undefined, + config + ) +} + /** * GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。 * 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂 diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts index 4457df4..b9177ab 100644 --- a/admin/src/types/import/components.d.ts +++ b/admin/src/types/import/components.d.ts @@ -78,6 +78,7 @@ declare module 'vue' { ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] diff --git a/admin/src/views/config/center/CredsPane.vue b/admin/src/views/config/center/CredsPane.vue new file mode 100644 index 0000000..5271650 --- /dev/null +++ b/admin/src/views/config/center/CredsPane.vue @@ -0,0 +1,743 @@ + + + + + + diff --git a/admin/src/views/config/center/__tests__/model.test.ts b/admin/src/views/config/center/__tests__/model.test.ts index 3b6bc15..66e3cf1 100644 --- a/admin/src/views/config/center/__tests__/model.test.ts +++ b/admin/src/views/config/center/__tests__/model.test.ts @@ -1,5 +1,5 @@ /** - * 配置中心提示词/词表编辑模型单元测试(Phase 3 Task 7b) + * 配置中心提示词/词表/凭据编辑模型单元测试(Phase 3 Task 7b + 7c) * * 钉住 src/views/config/center/model.ts 的纯逻辑契约(事实源: * app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与 @@ -9,7 +9,11 @@ * - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的 * 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的 * 保留、buildWallPayload 的整组重组; - * - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构。 + * - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构; + * - 凭据三组(Task 7c):normalize(掩码哨兵态/数值转串/缺键防御)、 + * build(密钥哨兵往返——不动保存回 '__SET__' 保持库值;空串 = 清空; + * 新值优先;数值空串 → null)与 validate 前端预检(端口 1-65535 / + * 邮箱含 @ / push URL 协议与正数·1-10 整数边界,与后端校验同款)。 */ import { describe, expect, it } from 'vitest' import { @@ -19,13 +23,21 @@ import { POOL_FIELDS, PROMPTS_CAPS, PROMPTS_KEYS, + PUSH_PROBE_REASON_LABELS, REMINDER_VARIANTS, TRIGGER_KEYS, ZERO_NUMBER_KEYS, + asSecretMasked, + buildAiCredsPayload, + buildEmailCredsPayload, + buildPushCredsPayload, buildWallPayload, formatKB, initWallEdit, + normalizeAiCredsGroup, + normalizeEmailCredsGroup, normalizePromptsGroup, + normalizePushCredsGroup, parsePatternEntry, promptByteInfo, promptsOverCap, @@ -34,7 +46,11 @@ import { rowToSingleEntry, touchstoneSentences, utf8Bytes, - type PromptsDraft + validateEmailCredsForm, + validatePushCredsForm, + type EmailCredsForm, + type PromptsDraft, + type PushCredsForm } from '../model' // ---- 提示词四件套 ------------------------------------------------------------------- @@ -357,3 +373,254 @@ describe('试金石句集', () => { expect(touchstoneSentences([])).toEqual([]) }) }) + +// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) ----------------------------------- + +describe('asSecretMasked(GET 密钥值 → 掩码哨兵态)', () => { + it('哨兵原样 / 空串与 null·undefined → 空串(区分「配了」与「没配」)', () => { + expect(asSecretMasked('__SET__')).toBe('__SET__') + expect(asSecretMasked('')).toBe('') + expect(asSecretMasked(null)).toBe('') + expect(asSecretMasked(undefined)).toBe('') + }) + + it('掩码失效漏出的任意非空值也归哨兵(防原始密钥回传库外)', () => { + expect(asSecretMasked('sk-real-secret')).toBe('__SET__') + expect(asSecretMasked(123)).toBe('') + }) +}) + +describe('normalize 三组(GET 原样组 → 表单 + 密钥态)', () => { + it('email:smtp 完整组 → 表单值 + 密码哨兵;密钥输入恒空', () => { + const state = normalizeEmailCredsGroup({ + smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '__SET__' }, + sender_name: 'HESUPIPE', + notify_emails: ['a@x.com', 'b@x.com'] + }) + expect(state.form).toEqual({ + smtpHost: 'smtp.x.com', + smtpPort: '465', + smtpUser: 'bot@x.com', + smtpPassword: '', + senderName: 'HESUPIPE', + notifyEmails: ['a@x.com', 'b@x.com'] + }) + expect(state.passwordMasked).toBe('__SET__') + }) + + it('email:jiancai 档案形态(无 smtp 键)→ 全空槽 + 未设置密钥态', () => { + const state = normalizeEmailCredsGroup({ + sender_name: 'HESUPIPE', + notify_emails: ['hesu@hesuu.com'] + }) + expect(state.form.smtpHost).toBe('') + expect(state.form.smtpPort).toBe('') + expect(state.form.smtpPassword).toBe('') + expect(state.passwordMasked).toBe('') + }) + + it('email:smtp null / 类型漂移防御', () => { + const state = normalizeEmailCredsGroup({ smtp: null, notify_emails: [1, 'a@x.com'] }) + expect(state.form.smtpHost).toBe('') + expect(state.form.notifyEmails).toEqual(['a@x.com']) + expect(normalizeEmailCredsGroup(undefined).form.notifyEmails).toEqual([]) + }) + + it('push:完整组 → 表单(数值转串)+ api_key 哨兵', () => { + const state = normalizePushCredsGroup({ + enabled: true, + url: 'https://push.example/v1', + api_key: '__SET__', + prefix: 'ai-kefu-jc', + timeout: 5.5, + max_attempts: 3, + retry_backoff_base: 1 + }) + expect(state.form).toEqual({ + enabled: true, + url: 'https://push.example/v1', + apiKey: '', + prefix: 'ai-kefu-jc', + timeout: '5.5', + maxAttempts: '3', + backoffBase: '1' + }) + expect(state.apiKeyMasked).toBe('__SET__') + }) + + it('push:缺省/未启用 → enabled false + 数值空串(null → 空槽)', () => { + const state = normalizePushCredsGroup({ prefix: 'ai-kefu-jc' }) + expect(state.form.enabled).toBe(false) + expect(state.form.timeout).toBe('') + expect(state.form.maxAttempts).toBe('') + expect(state.form.backoffBase).toBe('') + expect(state.form.apiKey).toBe('') + expect(state.apiKeyMasked).toBe('') + }) + + it('ai:缺省组 → 空表单(出厂默认由后端深合并兜底)+ 未设置密钥态', () => { + const state = normalizeAiCredsGroup(null) + expect(state.form).toEqual({ provider: '', apiKey: '', baseUrl: '', model: '' }) + expect(state.apiKeyMasked).toBe('') + const set = normalizeAiCredsGroup({ + provider: 'glm', + api_key: '__SET__', + model: 'glm-4-flash' + }) + expect(set.form.provider).toBe('glm') + expect(set.form.apiKey).toBe('') + expect(set.apiKeyMasked).toBe('__SET__') + }) +}) + +describe('build 三组载荷(表单 → PUT 整组)+ 密钥哨兵往返', () => { + it('email:普通字段 trim、空行剔除、端口转整数', () => { + const payload = buildEmailCredsPayload( + { + smtpHost: ' smtp.x.com ', + smtpPort: ' 465 ', + smtpUser: ' bot@x.com ', + smtpPassword: '', + senderName: ' HESUPIPE ', + notifyEmails: [' a@x.com ', '', ' '] + }, + '' + ) + expect(payload).toEqual({ + smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '' }, + sender_name: 'HESUPIPE', + notify_emails: ['a@x.com'] + }) + }) + + it('email 密钥三态:未设置不动 → 空串;已设置不动 → 哨兵;输入新值 → 新值', () => { + const form = (): EmailCredsForm => ({ + smtpHost: 'smtp.x.com', + smtpPort: '465', + smtpUser: 'bot@x.com', + smtpPassword: '', + senderName: '', + notifyEmails: [] + }) + // 已设置 + 未动 → 哨兵(后端还原库值,库值不变) + expect(buildEmailCredsPayload(form(), '__SET__').smtp).toMatchObject({ + password: '__SET__' + }) + // 未设置 + 未动 → 空串(「清除」意图同为空串 = 显式清空) + expect(buildEmailCredsPayload(form(), '').smtp).toMatchObject({ password: '' }) + // 输入新值 → 新值优先(无论原态) + const typed = { ...form(), smtpPassword: 'new-pw' } + expect(buildEmailCredsPayload(typed, '__SET__').smtp).toMatchObject({ + password: 'new-pw' + }) + }) + + it('push:数值空串 → null(未设置哨兵)、enabled 布尔原样、密钥哨兵往返', () => { + const payload = buildPushCredsPayload( + { + enabled: true, + url: ' https://push.example/v1 ', + apiKey: '', + prefix: 'demo-p', + timeout: '5.5', + maxAttempts: '3', + backoffBase: '' + }, + '__SET__' + ) + expect(payload).toEqual({ + enabled: true, + url: 'https://push.example/v1', + api_key: '__SET__', + prefix: 'demo-p', + timeout: 5.5, + max_attempts: 3, + retry_backoff_base: null + }) + }) + + it('ai:api_key 哨兵/新值/清空三态 + 字段 trim', () => { + const base = { provider: ' glm ', apiKey: '', baseUrl: ' https://x.com ', model: ' glm-4 ' } + expect(buildAiCredsPayload({ ...base }, '__SET__')).toEqual({ + provider: 'glm', + api_key: '__SET__', + base_url: 'https://x.com', + model: 'glm-4' + }) + expect(buildAiCredsPayload({ ...base, apiKey: 'sk-new' }, '').api_key).toBe('sk-new') + expect(buildAiCredsPayload({ ...base }, '').api_key).toBe('') + }) +}) + +describe('validate 前端预检(后端 400 的前置层)', () => { + it('email:端口非整数/越界报错;空串合法(= 平台默认)', () => { + const ok: EmailCredsForm = { + smtpHost: '', + smtpPort: '', + smtpUser: '', + smtpPassword: '', + senderName: '', + notifyEmails: [] + } + expect(validateEmailCredsForm(ok)).toEqual([]) + for (const bad of ['0', '65536', 'abc', '1.5', '-1']) { + expect(validateEmailCredsForm({ ...ok, smtpPort: bad })).toHaveLength(1) + } + expect(validateEmailCredsForm({ ...ok, smtpPort: '465' })).toEqual([]) + expect(validateEmailCredsForm({ ...ok, smtpPort: '65535' })).toEqual([]) + }) + + it('email:通知邮箱非空行须含 @(空行提交时剔除,不报错)', () => { + const base: EmailCredsForm = { + smtpHost: '', + smtpPort: '', + smtpUser: '', + smtpPassword: '', + senderName: '', + notifyEmails: [] + } + expect(validateEmailCredsForm({ ...base, notifyEmails: ['', ' '] })).toEqual([]) + expect( + validateEmailCredsForm({ ...base, notifyEmails: ['a@x.com', 'not-an-email'] }) + ).toHaveLength(1) + }) + + it('push:URL 协议 / 正数 / 1-10 整数边界(与后端 _check_push 同款)', () => { + const ok: PushCredsForm = { + enabled: false, + url: 'https://push.example/v1', + apiKey: '', + prefix: '', + timeout: '10', + maxAttempts: '3', + backoffBase: '1' + } + expect(validatePushCredsForm(ok)).toEqual([]) + // URL 空串合法(= 平台默认);非 http(s) 协议报错 + expect(validatePushCredsForm({ ...ok, url: '' })).toEqual([]) + expect(validatePushCredsForm({ ...ok, url: 'ftp://x' })).toHaveLength(1) + // timeout 正数 + expect(validatePushCredsForm({ ...ok, timeout: '0' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, timeout: '-1' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, timeout: 'abc' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, timeout: '0.5' })).toEqual([]) + // max_attempts 1-10 整数 + expect(validatePushCredsForm({ ...ok, maxAttempts: '0' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, maxAttempts: '11' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, maxAttempts: '2.5' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, maxAttempts: '10' })).toEqual([]) + // backoff 正数;全部空串 = 平台默认,合法 + expect(validatePushCredsForm({ ...ok, backoffBase: '0' })).toHaveLength(1) + expect( + validatePushCredsForm({ ...ok, url: '', timeout: '', maxAttempts: '', backoffBase: '' }) + ).toEqual([]) + }) +}) + +describe('PUSH_PROBE_REASON_LABELS(不可达原因 → 修复提示)', () => { + it('覆盖后端 reason 全部分类(dns/timeout/connect)', () => { + expect(PUSH_PROBE_REASON_LABELS.dns).toContain('域名解析') + expect(PUSH_PROBE_REASON_LABELS.timeout).toContain('超时') + expect(PUSH_PROBE_REASON_LABELS.connect).toContain('拒绝') + }) +}) diff --git a/admin/src/views/config/center/index.vue b/admin/src/views/config/center/index.vue index 1ca4fba..63cd3b9 100644 --- a/admin/src/views/config/center/index.vue +++ b/admin/src/views/config/center/index.vue @@ -1,7 +1,7 @@ @@ -397,6 +401,29 @@ + + + @@ -418,6 +445,7 @@ } from '@/api/config' import PromptsPane from './PromptsPane.vue' import WallPane from './WallPane.vue' + import CredsPane from './CredsPane.vue' defineOptions({ name: 'ConfigCenter' }) @@ -428,7 +456,7 @@ const AVATAR_MAX_BYTES = 2 * 1024 * 1024 const AVATAR_EXTS = ['png', 'jpg', 'jpeg', 'webp'] - // ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表实装;其余占位) -------------------- + // ---- 左侧组导航(Task 7a 品牌 / Task 7b 提示词+词表 / Task 7c 凭据实装;版本占位) ------ const GROUP_NAV = [ { key: 'brand', label: '品牌' }, @@ -439,7 +467,7 @@ ] as const /** 已实装组(导航徽标) */ - const IMPLEMENTED_GROUPS = new Set(['brand', 'prompts', 'wall']) + const IMPLEMENTED_GROUPS = new Set(['brand', 'prompts', 'wall', 'creds']) type GroupKey = (typeof GROUP_NAV)[number]['key'] diff --git a/admin/src/views/config/center/model.ts b/admin/src/views/config/center/model.ts index db1389f..4ad63ab 100644 --- a/admin/src/views/config/center/model.ts +++ b/admin/src/views/config/center/model.ts @@ -1,8 +1,8 @@ /** - * 配置中心提示词/词表编辑模型(Phase 3 Task 7b) + * 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c) * - * 消费方:config/center 的 PromptsPane.vue / WallPane.vue。纯逻辑无 DOM - * 依赖,vitest 直接覆盖(__tests__/model.test.ts)。 + * 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue。 + * 纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。 * * 事实源对齐: * - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS @@ -14,8 +14,15 @@ * comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的 * 顶层键(含 "_" 前缀元信息键)原样透传; * - 语言池必填 en/zh(wall_schema._REQUIRED_POOLS),reminders 六类 / - * cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝)。 + * cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝); + * - 凭据三组(Task 7c):DEFAULT_CONFIG 的 email/push/ai 组键 + + * config_validation 数值边界(push.timeout/backoff 正数、max_attempts + * 1-10 整数、notify_emails 项含 @)+ config_views 空槽回退语义 + * (空串/None = 未设置 → 回退 env,表单提示「留空 = 平台默认」)。 + * 密钥哨兵交互见 src/api/mask.ts(GET '__SET__' → 空输入 + 占位符; + * 不动保存回传哨兵保持库值;「清除」= 提交空串)。 */ +import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask' // ---- 提示词四件套 ----------------------------------------------------------------- /** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */ @@ -423,3 +430,248 @@ export const DEFAULT_TOUCHSTONE_SENTENCES = [ export function touchstoneSentences(sentences: string[]): string[] { return sentences.map((s) => s.trim()).filter(Boolean) } + +// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) -------------------------------- + +/** 邮件凭据表单(数值/密钥输入均为字符串形态——空串 = 未设置回退 env) */ +export interface EmailCredsForm { + smtpHost: string + smtpPort: string + smtpUser: string + /** 密钥输入:初始恒空,「已设置」态由占位符表达(见 mask.ts) */ + smtpPassword: string + senderName: string + notifyEmails: string[] +} + +/** 推送凭据表单(timeout/maxAttempts/backoffBase:'' = null = 平台默认) */ +export interface PushCredsForm { + enabled: boolean + url: string + apiKey: string + prefix: string + timeout: string + maxAttempts: string + backoffBase: string +} + +/** AI 凭据表单 */ +export interface AiCredsForm { + provider: string + apiKey: string + baseUrl: string + model: string +} + +/** GET 组 → 表单 + 密钥掩码状态(掩码值驱动占位符与 secretToSubmit) */ +export interface EmailCredsState { + form: EmailCredsForm + passwordMasked: string +} + +export interface PushCredsState { + form: PushCredsForm + apiKeyMasked: string +} + +export interface AiCredsState { + apiKeyMasked: string + form: AiCredsForm +} + +/** + * 任意非空字符串 → 掩码哨兵。比 isMaskedSentinel 更保守:即使后端掩码 + * 失效漏出原始密钥(理论不可达),也按「已设置」处理——不动保存回传 + * 哨兵由后端还原库值,绝不把原始值再提交回去;空串/null/undefined → '' + * (未设置)。 + */ +export function asSecretMasked(value: unknown): string { + return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : '' +} + +/** 防御读取:数值 → 表单串(非有限数值/缺省 → '') */ +function numberToForm(value: unknown): string { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + +/** 防御读取:字符串数组(非字符串项剔除) */ +function strList(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === 'string') + : [] +} + +/** 表单串 → 整数 | null(空串 = 未设置;非整数由 validate 前置拦截) */ +function intOrNull(value: string): number | null { + const n = Number(value.trim()) + return value.trim() !== '' && Number.isInteger(n) ? n : null +} + +/** 表单串 → 数值 | null(空串 = 未设置;非数值由 validate 前置拦截) */ +function numOrNull(value: string): number | null { + const n = Number(value.trim()) + return value.trim() !== '' && Number.isFinite(n) ? n : null +} + +/** GET 的 email 组 → 邮件表单(smtp 缺省/null → 全空槽,回退 env) */ +export function normalizeEmailCredsGroup( + value: Record | null | undefined +): EmailCredsState { + const source = value ?? {} + const smtp = asRecord(source.smtp) ?? {} + return { + form: { + smtpHost: asStr(smtp.host), + smtpPort: numberToForm(smtp.port), + smtpUser: asStr(smtp.user), + smtpPassword: '', + senderName: asStr(source.sender_name), + notifyEmails: strList(source.notify_emails) + }, + passwordMasked: asSecretMasked(smtp.password) + } +} + +/** GET 的 push 组 → 推送表单 */ +export function normalizePushCredsGroup( + value: Record | null | undefined +): PushCredsState { + const source = value ?? {} + return { + form: { + enabled: source.enabled === true, + url: asStr(source.url), + apiKey: '', + prefix: asStr(source.prefix), + timeout: numberToForm(source.timeout), + maxAttempts: numberToForm(source.max_attempts), + backoffBase: numberToForm(source.retry_backoff_base) + }, + apiKeyMasked: asSecretMasked(source.api_key) + } +} + +/** GET 的 ai 组 → AI 表单 */ +export function normalizeAiCredsGroup( + value: Record | null | undefined +): AiCredsState { + const source = value ?? {} + return { + form: { + provider: asStr(source.provider), + apiKey: '', + baseUrl: asStr(source.base_url), + model: asStr(source.model) + }, + apiKeyMasked: asSecretMasked(source.api_key) + } +} + +/** + * 邮件表单 → PUT config/email 整组载荷。密钥走 secretToSubmit(未动 → + * 哨兵保持库值;「清除」按钮已把 maskedState 置 '',提交空串 = 显式清空)。 + * 数值/邮箱格式由 validateEmailCredsForm 前置把关(后端 400 二层兜底)。 + */ +export function buildEmailCredsPayload( + form: EmailCredsForm, + passwordMasked: string +): Record { + return { + smtp: { + host: form.smtpHost.trim(), + port: intOrNull(form.smtpPort), + user: form.smtpUser.trim(), + password: secretToSubmit(form.smtpPassword, passwordMasked) + }, + sender_name: form.senderName.trim(), + notify_emails: form.notifyEmails + .map((e) => e.trim()) + .filter((e) => e !== '') + } +} + +/** 推送表单 → PUT config/push 整组载荷(prefix 冲突 400 由后端跨租户查重) */ +export function buildPushCredsPayload( + form: PushCredsForm, + apiKeyMasked: string +): Record { + return { + enabled: form.enabled, + url: form.url.trim(), + api_key: secretToSubmit(form.apiKey, apiKeyMasked), + prefix: form.prefix.trim(), + timeout: numOrNull(form.timeout), + max_attempts: intOrNull(form.maxAttempts), + retry_backoff_base: numOrNull(form.backoffBase) + } +} + +/** AI 表单 → PUT config/ai 整组载荷 */ +export function buildAiCredsPayload( + form: AiCredsForm, + apiKeyMasked: string +): Record { + return { + provider: form.provider.trim(), + api_key: secretToSubmit(form.apiKey, apiKeyMasked), + base_url: form.baseUrl.trim(), + model: form.model.trim() + } +} + +/** 邮件表单预检(空槽合法——回退 env;只查非空行的格式) */ +export function validateEmailCredsForm(form: EmailCredsForm): string[] { + const errors: string[] = [] + const port = form.smtpPort.trim() + if (port !== '') { + const n = Number(port) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + errors.push('SMTP 端口:须为 1-65535 的整数(留空 = 平台默认)') + } + } + form.notifyEmails.forEach((row, i) => { + const t = row.trim() + if (t !== '' && !t.includes('@')) { + errors.push(`通知邮箱第 ${i + 1} 行:须为含 @ 的邮箱地址`) + } + }) + return errors +} + +/** 推送表单预检(与后端 _check_push 的数值边界同款:正数 / 1-10 整数) */ +export function validatePushCredsForm(form: PushCredsForm): string[] { + const errors: string[] = [] + const url = form.url.trim() + if (url !== '' && !/^https?:\/\//.test(url)) { + errors.push('推送 URL:须以 http:// 或 https:// 开头') + } + const timeout = form.timeout.trim() + if (timeout !== '') { + const n = Number(timeout) + if (!Number.isFinite(n) || n <= 0) { + errors.push('推送超时(秒):须为正数(留空 = 平台默认)') + } + } + const attempts = form.maxAttempts.trim() + if (attempts !== '') { + const n = Number(attempts) + if (!Number.isInteger(n) || n < 1 || n > 10) { + errors.push('最大尝试次数:须为 1-10 的整数(留空 = 平台默认)') + } + } + const backoff = form.backoffBase.trim() + if (backoff !== '') { + const n = Number(backoff) + if (!Number.isFinite(n) || n <= 0) { + errors.push('重试退避基数(秒):须为正数(留空 = 平台默认)') + } + } + return errors +} + +/** 推送探测不可达原因 → 修复提示文案(后端 reason 分类的前端展示层) */ +export const PUSH_PROBE_REASON_LABELS: Record = { + dns: '域名解析失败(检查 URL 拼写)', + timeout: '连接超时', + connect: '连接被拒绝或网络不可达' +} diff --git a/app/engine/services/email_service.py b/app/engine/services/email_service.py index e0bbf1a..0d96a57 100644 --- a/app/engine/services/email_service.py +++ b/app/engine/services/email_service.py @@ -1,5 +1,6 @@ import html import logging +from datetime import datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -58,16 +59,23 @@ def _cc_views(contacts: dict, cc_info: dict | None) -> tuple[dict, dict]: return body, subj +async def _smtp_send(msg: MIMEMultipart, cfg: dict) -> None: + """底层连接发送(Phase 3 Task 7c 从 _send 抽出): 直调 aiosmtplib, + 失败抛原始异常——需要错误串的调用方(配置中心测试邮件 send_test_email) + 复用同一连接逻辑;_send 在此之上包"吞异常返 bool"契约,行为不变。""" + await aiosmtplib.send( + msg, hostname=cfg["host"], port=cfg["port"], + username=cfg["user"], password=cfg["password"], + use_tls=True, + ) + + async def _send(msg: MIMEMultipart, cfg: dict) -> bool: if not all([cfg["host"], cfg["user"], cfg["notify_emails"]]): logger.warning("SMTP not configured, skipping email") return False try: - await aiosmtplib.send( - msg, hostname=cfg["host"], port=cfg["port"], - username=cfg["user"], password=cfg["password"], - use_tls=True, - ) + await _smtp_send(msg, cfg) logger.info("Lead email sent to %s", cfg["notify_emails"]) return True except Exception: @@ -170,3 +178,35 @@ async def send_correction_email( f"{_contact_rows(body_contacts)}
" f"", "html", "utf-8")) return await _send(msg, cfg) + + +async def send_test_email( + to: str, tenant_name: str = "", + config_data: dict | None = None, +) -> tuple[bool, str | None]: + """配置中心测试邮件(Phase 3 Task 7c): 按当前生效邮件配置真发一封到 + 指定收件人——主题/正文钉契约("[all-kefu] SMTP 测试邮件"+租户名+时间, + 时间与 lead_push_service 同款 +08:00 口径)。 + + 与 _send 的吞异常 bool 契约解耦: 成败与错误串如实返回(探测端点直传 + 前端,凭据对不对让收件箱与错误串说话);连接逻辑复用 _smtp_send。 + "SMTP 未配置"的 400 判定在端点层(本函数假定 cfg 已可用)。""" + cfg = resolve_email_settings(config_data) + now = datetime.now(timezone(timedelta(hours=8))) + msg = MIMEMultipart("alternative") + msg["Subject"] = "[all-kefu] SMTP 测试邮件" + msg["From"] = f"【{cfg['sender_name']}】 <{cfg['user']}>" + msg["To"] = to + msg.attach(MIMEText( + f"" + f"

这是一封 SMTP 配置测试邮件——收到本邮件说明该租户的邮件配置" + f"可以正常发信。

" + f"

租户:{_esc(tenant_name)}

" + f"

时间:{now.strftime('%Y-%m-%d %H:%M:%S')}(UTC+8)

" + f"", "html", "utf-8")) + try: + await _smtp_send(msg, cfg) + return True, None + except Exception as exc: + logger.exception("Test email to %s failed", to) + return False, repr(exc) diff --git a/app/platform/routes/config_routes.py b/app/platform/routes/config_routes.py index cac7ccd..a02aa43 100644 --- a/app/platform/routes/config_routes.py +++ b/app/platform/routes/config_routes.py @@ -19,6 +19,10 @@ 缺省用当前墙——逐句调 boundary_filter 纯函数给判定,**不落库不 bump 版本** (reset/编辑器的预览入口)。判定优先级与 conversation.py 执行侧一致: 价格墙 → 零号码墙 → 再索要墙(仅 has_contact); +- POST /email/test 与 /push/test(凭据测试,Phase 3 Task 7c):分别用 + resolve_email_settings/resolve_push_settings 的当前配置真发测试邮件 / + 探测推送连通性——**push 探测绝不携带线索数据**(空 messages 批量), + 两端点不落库不 bump 版本,成败如实返回(400 只用于"未配置"预检); - GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。本阶段只落 存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认 回退链接入归 Phase 3。 @@ -36,12 +40,15 @@ email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的 """ import copy import json +import socket +import httpx from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.engine.services import boundary_filter +from app.engine.services.email_service import send_test_email from app.engine.wall_schema import WallConfigError, compile_wall from app.platform.auth import UserContext, require_super from app.platform.config_validation import ( @@ -49,6 +56,10 @@ from app.platform.config_validation import ( ValidationContext, validate_group, ) +from app.platform.config_views import ( + resolve_email_settings, + resolve_push_settings, +) from app.platform.db import ( PlatformSetting, Tenant, @@ -488,6 +499,109 @@ async def test_wall(slug: str, request: Request, user=Depends(_tenant_guard)): return {"slug": slug, "wall_source": wall_source, "results": results} +# ---- 凭据测试端点(email/push test,Phase 3 Task 7c) ------------------------------- + +_EMAIL_TEST_KEYS = frozenset({"to"}) + + +@router.post("/{slug}/email/test") +async def test_email(slug: str, request: Request, + user=Depends(_tenant_guard)): + """SMTP 测试邮件: 用当前生效邮件配置(resolve_email_settings,租户组 + 优先/空槽回退 env SMTP_*)真发一封到 body.to——主题/正文契约见 + email_service.send_test_email。SMTP 未配置(回退后 host/user 仍空) + → 400;发送失败不 5xx,{sent: false, error} 如实返回。不落库不 bump + 版本;密钥绝不进响应。""" + body = await _json_body(request) + unknown = set(body) - _EMAIL_TEST_KEYS + if unknown: + raise HTTPException( + status_code=400, + detail=f"未知字段: {', '.join(sorted(unknown))}(合法: to)") + to = body.get("to") + if not isinstance(to, str) or "@" not in to.strip(): + raise HTTPException( + status_code=400, + detail=f"to: 须为含 @ 的收件人邮箱(实际 {to!r})") + + engine = request.app.state.platform_engine + async with AsyncSession(engine, expire_on_commit=False) as session: + tenant = await _get_tenant_or_404(session, slug) + _, snapshot = await _load_current(session, tenant.id, slug) + cfg = resolve_email_settings(snapshot) + if not cfg["host"] or not cfg["user"]: + raise HTTPException( + status_code=400, + detail="SMTP 未配置(服务器/账号在租户配置与环境回退后均为空)" + "——请先在「配置中心 → 凭据」保存邮件 SMTP") + sent, error = await send_test_email(to.strip(), tenant.name, snapshot) + return {"slug": slug, "sent": sent, "error": error} + + +def _transport_reason(exc: Exception) -> str: + """传输异常 → 不可达原因分类(前端三态展示): dns(域名解析失败)/ + timeout(TimeoutException 族)/connect(拒绝·不可达·TLS 等其余)。 + + dns 判定双通道: 沿 __cause__/__context__ 链找 gaierror(httpx 把解析 + 错误包在 ConnectError 里),并对链上异常做文案匹配——Windows 实测 + (冒烟 Task 7c)httpx/anyio 会把 errno 11001 包成裸 ConnectError, + cause 链不带 gaierror,只能从 "[Errno 11001] getaddrinfo failed" + 文案识别;分类仅供修复提示,错误原文始终随响应返回兜底。""" + node: BaseException | None = exc + while node is not None: + if isinstance(node, socket.gaierror): + return "dns" + text = str(node).lower() + if "getaddrinfo" in text or "name or service not known" in text: + return "dns" + node = node.__cause__ or node.__context__ + if isinstance(exc, httpx.TimeoutException): + return "timeout" + return "connect" + + +async def _probe_push(cfg: dict) -> dict: + """单次连通性探测(无重试): 与 lead_push_service._do_post 同款连接形态 + (timeout/follow_redirects/X-Api-Key 头),body 恒为空 messages 列表—— + **绝不携带任何线索数据**(UniService 是批量结构,空批量对方案应为 + 无操作;契约文档不在库内,这是可用的最保守预检形态)。""" + try: + async with httpx.AsyncClient(timeout=cfg["timeout"], + follow_redirects=True) as client: + resp = await client.post( + cfg["url"], json={"messages": []}, + headers={"X-Api-Key": cfg["api_key"]}) + except httpx.HTTPError as exc: + return {"reachable": False, "auth_ok": None, "status": None, + "reason": _transport_reason(exc), + "error": str(exc) or repr(exc)} + auth_ok = (True if resp.status_code < 300 + else False if resp.status_code in (401, 403) + else None) + return {"reachable": True, "auth_ok": auth_ok, + "status": resp.status_code, "reason": None, "error": None} + + +@router.post("/{slug}/push/test") +async def test_push(slug: str, request: Request, + user=Depends(_tenant_guard)): + """推送连通性探测: 只验 URL 可达 + 鉴权通过,不发真数据(空 messages + 批量,见 _probe_push)。URL 回退后仍空 → 400;其余如实返回——不可达 + (含 dns/timeout/connect 原因分类)/可达但鉴权失败(401/403)/正常(2xx); + 其余状态码可达但鉴权不定(auth_ok=null)。不落库不 bump 版本。""" + engine = request.app.state.platform_engine + async with AsyncSession(engine, expire_on_commit=False) as session: + tenant = await _get_tenant_or_404(session, slug) + _, snapshot = await _load_current(session, tenant.id, slug) + cfg = resolve_push_settings(snapshot) + if not cfg["url"]: + raise HTTPException( + status_code=400, + detail="推送 URL 未配置(租户配置与环境回退后均为空)" + "——请先在「配置中心 → 凭据」保存推送 URL") + return {"slug": slug, **await _probe_push(cfg)} + + # ---- 平台设置(仅超管;resolver 回退链接入归 Phase 3) ----------------------------- diff --git a/tests/test_email_test_endpoint.py b/tests/test_email_test_endpoint.py new file mode 100644 index 0000000..f709dd5 --- /dev/null +++ b/tests/test_email_test_endpoint.py @@ -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 发件人】 " + 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_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 diff --git a/tests/test_push_test_endpoint.py b/tests/test_push_test_endpoint.py new file mode 100644 index 0000000..2019a51 --- /dev/null +++ b/tests/test_push_test_endpoint.py @@ -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