447 行
13 KiB
JavaScript
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();
|
|
});
|
|
});
|