feat(P23IM2-1,P23IM2-2,P23IM2-3): 密钥加密持久化/遮罩读模型/企微可重试语义/迁移脚本
这个提交包含在:
@@ -487,6 +487,46 @@ describe('dispatchOutboxEvent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps encrypted secret decrypt failure as non-retryable IM dispatch error', async () => {
|
||||
await expect(
|
||||
dispatchOutboxEvent({
|
||||
prisma: {
|
||||
notificationChannel: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'channel-6',
|
||||
channel_type: 'wecom',
|
||||
name: 'wecom-secret-bad',
|
||||
enabled: true,
|
||||
config_json: {
|
||||
mode: 'wecom_webhook',
|
||||
webhookUrl: 'https://example.com/wecom',
|
||||
secretEncrypted: 'v1:bad:payload:value',
|
||||
timeoutMs: 500,
|
||||
routing: {},
|
||||
},
|
||||
template_json: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
fieldEncryptionKey: Buffer.alloc(32, 7).toString('base64'),
|
||||
},
|
||||
eventType: 'notification.im',
|
||||
payload: {
|
||||
channelId: 'channel-6',
|
||||
channelType: 'wecom',
|
||||
title: 'test',
|
||||
body: 'body',
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'IntegrationDispatchError',
|
||||
code: 'wecom_secret_decrypt_failed',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error for unsupported event', async () => {
|
||||
await expect(
|
||||
dispatchOutboxEvent({
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,43 @@ describe("Integration Dispatcher", () => {
|
||||
expect(result.code).toBe("mock_sent");
|
||||
});
|
||||
|
||||
it("marks encrypted secret decrypt failure as non-retryable IM dispatch error", async () => {
|
||||
const encryptedSecret = "v1:bad:payload:value";
|
||||
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
prisma: {
|
||||
notificationChannel: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "channel-2",
|
||||
channel_type: "wecom",
|
||||
name: "test",
|
||||
enabled: true,
|
||||
config_json: {
|
||||
mode: "wecom_webhook",
|
||||
webhookUrl: "https://example.com/wecom",
|
||||
secretEncrypted: encryptedSecret,
|
||||
timeoutMs: 500,
|
||||
routing: {},
|
||||
},
|
||||
template_json: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64"),
|
||||
},
|
||||
eventType: "notification.im",
|
||||
payload: { channelId: "channel-2", channelType: "wecom", title: "title", body: "body" },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "IntegrationDispatchError",
|
||||
code: "wecom_secret_decrypt_failed",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error for unsupported event", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
|
||||
在新工单中引用
屏蔽一个用户