文件
hyb-oa/server/tests/unit/routes/notifications.test.js
T

447 行
13 KiB
JavaScript

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();
});
});