const Fastify = require('fastify'); const { AppError } = require('../../../src/utils/AppError'); const { encryptStringOrThrow } = require('../../../src/services/crypto'); const notificationsRoutes = require('../../../src/routes/notifications'); function buildChannelRecord(overrides = {}) { const now = new Date('2026-03-13T00:00:00.000Z'); return { id: '11111111-1111-4111-8111-111111111111', channel_type: 'wecom', name: '采购经理群', enabled: true, config_json: { mode: 'mock', webhookUrl: null, timeoutMs: 5000, routing: {}, }, template_json: { title: '{{title}}', body: '{{body}}', footer: '来自海音达工作台', deepLinkLabel: '查看详情', }, created_at: now, created_by: 'employee-1', updated_at: now, updated_by: 'employee-1', ...overrides, }; } function createPrismaState({ channels = [] } = {}) { const stateChannels = channels.map((channel) => ({ ...channel })); const auditEntries = []; const notificationChannel = { findMany: vi.fn().mockImplementation(async () => [...stateChannels].sort((left, right) => { const typeOrder = String(left.channel_type).localeCompare(String(right.channel_type)); if (typeOrder !== 0) { return typeOrder; } return String(left.name).localeCompare(String(right.name)); }), ), findUnique: vi.fn().mockImplementation(async ({ where }) => { if (where?.id) { return stateChannels.find((item) => item.id === where.id) ?? null; } if (where?.channel_type_name) { return ( stateChannels.find( (item) => item.channel_type === where.channel_type_name.channel_type && item.name === where.channel_type_name.name, ) ?? null ); } return null; }), create: vi.fn().mockImplementation(async ({ data }) => { const duplicated = stateChannels.find( (item) => item.channel_type === data.channel_type && item.name === data.name, ); if (duplicated) { const error = new Error('duplicate_channel_name'); error.code = 'P2002'; throw error; } const now = new Date('2026-03-13T10:00:00.000Z'); const channel = buildChannelRecord({ id: `00000000-0000-4000-8000-${String(stateChannels.length + 1).padStart(12, '0')}`, channel_type: data.channel_type, name: data.name, enabled: data.enabled, config_json: data.config_json, template_json: data.template_json, created_at: now, updated_at: now, created_by: data.created_by, updated_by: data.updated_by, }); stateChannels.push(channel); return channel; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = stateChannels.findIndex((item) => item.id === where.id); if (index < 0) { return null; } const current = stateChannels[index]; const next = { ...current, ...data, config_json: data.config_json ?? current.config_json, template_json: data.template_json ?? current.template_json, updated_at: new Date('2026-03-13T11:00:00.000Z'), }; stateChannels[index] = next; return next; }), upsert: vi.fn().mockImplementation(async ({ where, create, update }) => { const current = await notificationChannel.findUnique({ where }); if (current) { return notificationChannel.update({ where: { id: current.id }, data: update }); } return notificationChannel.create({ data: create }); }), }; return { prisma: { notificationChannel, auditLog: { create: vi.fn().mockImplementation(async ({ data }) => { auditEntries.push(data); return { id: `audit-${auditEntries.length}` }; }), }, }, stateChannels, auditEntries, }; } async function buildApp({ prisma, config = {}, permissionCodes = ['notification.channel.manage'] }) { const app = Fastify(); app.decorate('prisma', prisma); app.decorate('config', { fieldEncryptionKey: '', imNotifierEnabled: true, ...config, }); app.decorate('authenticate', async (request) => { if (!request.headers.authorization) { throw new AppError({ code: 'AUTH_001', message: '未登录', statusCode: 401, }); } request.employee = { id: 'employee-1', name: '测试人', permissionCodes, }; }); app.decorate('hasPermission', (employee, permissionCode) => (employee?.permissionCodes ?? []).includes('*') || (employee?.permissionCodes ?? []).includes(permissionCode), ); app.decorate('requirePermission', (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403, }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: 'SYS_001', message: '服务器内部错误', details: {}, }, }); }); await app.register(notificationsRoutes, { prefix: '/api' }); return app; } describe('notifications channel routes', () => { const validFieldEncryptionKey = Buffer.alloc(32, 7).toString('base64'); afterEach(() => { vi.clearAllMocks(); }); it('lists channels with masked legacy secret only', async () => { const { prisma } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secret: 'legacy-secret', timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'GET', url: '/api/notifications/channels', headers: { authorization: 'Bearer test-token' }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items[0].configJson).toEqual( expect.objectContaining({ secretConfigured: true, secretMasked: expect.any(String), }), ); expect(response.json().data.items[0].configJson.secret).toBeUndefined(); await app.close(); }); it('creates wecom webhook channel with encrypted secret and masked read model', async () => { const { prisma, stateChannels } = createPrismaState(); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer test-token' }, payload: { channelType: 'wecom', name: '企微经理群', enabled: true, configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secret: 'super-secret', timeoutMs: 800, routing: { roleCodes: ['manager'], domainScopes: ['workflow'], allowedTypes: ['workflow.task_assigned'], }, }, templateJson: { title: '{{title}}', body: '{{body}}', footer: '来自海音达工作台', deepLinkLabel: '查看详情', }, }, }); expect(response.statusCode).toBe(200); expect(response.json().data.channel.configJson).toEqual( expect.objectContaining({ secretConfigured: true, secretMasked: expect.any(String), }), ); expect(response.json().data.channel.configJson.secret).toBeUndefined(); expect(stateChannels[0].config_json.secretEncrypted).toMatch(/^v1:/); expect(stateChannels[0].config_json.secret).toBeUndefined(); await app.close(); }); it('rejects secret write when FIELD_ENCRYPTION_KEY is missing', async () => { const { prisma } = createPrismaState(); const app = await buildApp({ prisma, config: { fieldEncryptionKey: '' } }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer test-token' }, payload: { channelType: 'wecom', name: '企微经理群', enabled: true, configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secret: 'super-secret', timeoutMs: 800, routing: {}, }, }, }); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe('VALID_001'); expect(response.json().error.details).toEqual(expect.objectContaining({ secret: 'field_encryption_key_missing' })); await app.close(); }); it('patches channel without replacing existing encrypted secret when secret omitted', async () => { const encryptedSecret = encryptStringOrThrow({ fieldEncryptionKey: validFieldEncryptionKey }, 'super-secret'); const { prisma, stateChannels } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secretEncrypted: encryptedSecret, timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'PATCH', url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111', headers: { authorization: 'Bearer test-token' }, payload: { name: '企微经理群-更新', configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom-next', timeoutMs: 900, routing: { roleCodes: ['manager'], domainScopes: ['workflow'], allowedTypes: [], }, }, }, }); expect(response.statusCode).toBe(200); expect(stateChannels[0].config_json.secretEncrypted).toBe(encryptedSecret); expect(response.json().data.channel.configJson.secretConfigured).toBe(true); expect(response.json().data.channel.configJson.secret).toBeUndefined(); await app.close(); }); it('clears secret on patch when clearSecret is true', async () => { const encryptedSecret = encryptStringOrThrow({ fieldEncryptionKey: validFieldEncryptionKey }, 'super-secret'); const { prisma, stateChannels } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secretEncrypted: encryptedSecret, timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'PATCH', url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111', headers: { authorization: 'Bearer test-token' }, payload: { configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', clearSecret: true, timeoutMs: 800, routing: {}, }, }, }); expect(response.statusCode).toBe(200); expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined(); expect(stateChannels[0].config_json.secret).toBeUndefined(); expect(response.json().data.channel.configJson.secretConfigured).toBe(false); expect(response.json().data.channel.configJson.secretMasked).toBeNull(); await app.close(); }); it('returns message and retryable in test result without exposing plain secret', async () => { const encryptedSecret = 'v1:bad:payload:value'; const { prisma } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secretEncrypted: encryptedSecret, timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111/test', headers: { authorization: 'Bearer test-token' }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.channel.configJson.secret).toBeUndefined(); expect(response.json().data.testResult).toEqual( expect.objectContaining({ ok: false, code: 'wecom_secret_decrypt_failed', message: 'Failed to decrypt notification channel secret', retryable: false, }), ); await app.close(); }); });