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:
@@ -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('拒绝')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user