From a8ec86016c71a5a159ec35c6cb372b0be63862e4 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 13 Mar 2026 21:50:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(P23IM2-1,P23IM2-2,P23IM2-3):=20=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E5=8A=A0=E5=AF=86=E6=8C=81=E4=B9=85=E5=8C=96/?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E8=AF=BB=E6=A8=A1=E5=9E=8B/=E4=BC=81?= =?UTF-8?q?=E5=BE=AE=E5=8F=AF=E9=87=8D=E8=AF=95=E8=AF=AD=E4=B9=89/?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/scripts/regression-im-notifier.sh | 130 ++++- server/src/routes/notifications.js | 126 +++-- .../migrate-notification-channel-secrets.js | 107 +++++ server/src/services/integrations/im/wecom.js | 43 +- .../services/notifications/channelConfig.js | 164 +++++++ server/src/services/notifications/channels.js | 10 +- .../unit/integrations.dispatcher.test.js | 40 ++ .../tests/unit/routes/notifications.test.js | 446 ++++++++++++++++++ .../services/integrations/dispatcher.test.js | 37 ++ 9 files changed, 1063 insertions(+), 40 deletions(-) create mode 100644 server/src/scripts/migrate-notification-channel-secrets.js create mode 100644 server/src/services/notifications/channelConfig.js create mode 100644 server/tests/unit/routes/notifications.test.js diff --git a/server/scripts/regression-im-notifier.sh b/server/scripts/regression-im-notifier.sh index 2bce257f..17ea33da 100755 --- a/server/scripts/regression-im-notifier.sh +++ b/server/scripts/regression-im-notifier.sh @@ -39,6 +39,11 @@ if [[ -z "${DATABASE_URL:-}" ]]; then exit 1 fi +if [[ -z "${FIELD_ENCRYPTION_KEY:-}" ]]; then + echo "[ERROR] FIELD_ENCRYPTION_KEY is required for IM secret regression" >&2 + exit 1 +fi + TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR" @@ -175,13 +180,15 @@ create_channel() { local name="$2" local mode="$3" local webhook_url="$4" - local out_file="$5" + local secret="$5" + local out_file="$6" local body body="$(jq -nc \ --arg name "$name" \ --arg mode "$mode" \ --arg webhookUrl "$webhook_url" \ + --arg secret "$secret" \ '{ channelType:"wecom", name:$name, @@ -189,7 +196,7 @@ create_channel() { configJson:{ mode:$mode, webhookUrl:(if $webhookUrl == "" then null else $webhookUrl end), - secret:null, + secret:(if $secret == "" then null else $secret end), timeoutMs:800, routing:{ roleCodes:["manager","ceo","admin"], @@ -222,6 +229,91 @@ patch_channel_enabled() { assert_status "$status" "200" "patch channel $channel_id enabled=$enabled" "$out_file" } +create_legacy_secret_channel() { + local name="$1" + local webhook_url="$2" + local secret="$3" + + DATABASE_URL="$DATABASE_URL" CHANNEL_NAME="$name" WEBHOOK_URL="$webhook_url" CHANNEL_SECRET="$secret" node - <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const channel = await prisma.notificationChannel.create({ + data: { + channel_type: 'wecom', + name: process.env.CHANNEL_NAME, + enabled: true, + config_json: { + mode: 'wecom_webhook', + webhookUrl: process.env.WEBHOOK_URL, + secret: process.env.CHANNEL_SECRET, + timeoutMs: 800, + routing: { + roleCodes: ['manager', 'ceo', 'admin'], + domainScopes: ['workflow'], + allowedTypes: ['workflow.task_assigned'], + }, + }, + template_json: { + title: '{{title}}', + body: '{{body}}\n\n查看详情:{{detailUrl}}', + footer: '来自海音达工作台', + deepLinkLabel: '查看详情', + }, + created_by: null, + updated_by: null, + }, + select: { id: true }, + }); + process.stdout.write(channel.id); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +query_channel_secret_state() { + local channel_id="$1" + DATABASE_URL="$DATABASE_URL" CHANNEL_ID="$channel_id" node - <<'NODE' +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const channel = await prisma.notificationChannel.findUnique({ + where: { id: process.env.CHANNEL_ID }, + select: { config_json: true }, + }); + const config = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {}; + process.stdout.write(JSON.stringify({ + hasSecret: typeof config.secret === 'string' && config.secret.trim().length > 0, + hasSecretEncrypted: typeof config.secretEncrypted === 'string' && config.secretEncrypted.trim().length > 0, + })); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +run_secret_migration() { + local mode="$1" + FIELD_ENCRYPTION_KEY="$FIELD_ENCRYPTION_KEY" DATABASE_URL="$DATABASE_URL" node "./src/scripts/migrate-notification-channel-secrets.js" "$mode" +} + create_and_submit_purchase() { local token="$1" local label="$2" @@ -492,8 +584,8 @@ DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" BUDGET_POOL_ID="${FIXTURE_IDS[4]}" echo "[2/7] create mock + failing wecom channels" -MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "$TMP_DIR/create-mock-channel.json")" -FAIL_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Failing ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "$TMP_DIR/create-failing-channel.json")" +MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "" "$TMP_DIR/create-mock-channel.json")" +FAIL_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Failing ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "" "$TMP_DIR/create-failing-channel.json")" echo "[3/7] submit purchase request and verify IM outbox written" PURCHASE_ID_A="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression A ${RUN_SUFFIX}" "purchase-a" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" @@ -521,9 +613,35 @@ dispatch_outboxes_by_ids "$MANUAL_OUTBOX_ID" "false" > "$TMP_DIR/outbox-job-im-d query_outbox_by_id "$MANUAL_OUTBOX_ID" > "$TMP_DIR/manual-outbox.json" assert_jq "$TMP_DIR/manual-outbox.json" '.status == "succeeded" and .attempts == 0' "im disabled outbox skipped as succeeded" -echo "[7/7] verify channel test API still works for saved channel" +echo "[7/10] verify channel test API still works for saved channel" test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$MOCK_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/channel-test.json" '{}')" assert_status "$test_status" "200" "test channel by id" "$TMP_DIR/channel-test.json" -assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.testResult.code == "mock_sent"' "test channel endpoint mock sent" +assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.testResult.code == "mock_sent" and .data.testResult.retryable == false' "test channel endpoint mock sent" + +echo "[8/10] create encrypted secret channel and verify masked read model" +SECRET_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Secret ${RUN_SUFFIX}" "mock" "" "secret-${RUN_SUFFIX}" "$TMP_DIR/create-secret-channel.json")" +assert_jq "$TMP_DIR/create-secret-channel.json" '.data.channel.configJson.secretConfigured == true and (.data.channel.configJson.secretMasked | type == "string") and (.data.channel.configJson | has("secret") | not)' "create channel masks secret on read" +secret_test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$SECRET_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/secret-channel-test.json" '{}')" +assert_status "$secret_test_status" "200" "test encrypted channel by id" "$TMP_DIR/secret-channel-test.json" +assert_jq "$TMP_DIR/secret-channel-test.json" '.data.testResult.ok == true and .data.testResult.code == "mock_sent" and .data.channel.configJson.secretConfigured == true' "encrypted secret channel test still works" + +echo "[9/10] migrate legacy plain secret channel to encrypted storage and keep test path working" +LEGACY_CHANNEL_ID="$(create_legacy_secret_channel "IM Legacy ${RUN_SUFFIX}" "http://127.0.0.1:9/wecom" "legacy-${RUN_SUFFIX}")" +query_channel_secret_state "$LEGACY_CHANNEL_ID" > "$TMP_DIR/legacy-secret-before.json" +assert_jq "$TMP_DIR/legacy-secret-before.json" '.hasSecret == true and .hasSecretEncrypted == false' "legacy secret starts as plain text" +legacy_test_before_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$LEGACY_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/legacy-channel-test-before.json" '{}')" +assert_status "$legacy_test_before_status" "200" "test legacy plain-secret channel before migration" "$TMP_DIR/legacy-channel-test-before.json" +assert_jq "$TMP_DIR/legacy-channel-test-before.json" '.data.testResult.code == "wecom_network_error" and .data.testResult.retryable == true' "legacy plain-secret channel uses normal provider path before migration" +run_secret_migration --encrypt > "$TMP_DIR/migrate-encrypt.log" +query_channel_secret_state "$LEGACY_CHANNEL_ID" > "$TMP_DIR/legacy-secret-after-encrypt.json" +assert_jq "$TMP_DIR/legacy-secret-after-encrypt.json" '.hasSecret == false and .hasSecretEncrypted == true' "legacy secret migrated to encrypted storage" +legacy_test_after_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$LEGACY_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/legacy-channel-test-after.json" '{}')" +assert_status "$legacy_test_after_status" "200" "test legacy channel after encrypt migration" "$TMP_DIR/legacy-channel-test-after.json" +assert_jq "$TMP_DIR/legacy-channel-test-after.json" '.data.testResult.code == "wecom_network_error" and .data.testResult.retryable == true' "encrypted legacy channel still follows provider path after migration" + +echo "[10/10] decrypt migration restores rollback-ready plain secret shape" +run_secret_migration --decrypt > "$TMP_DIR/migrate-decrypt.log" +query_channel_secret_state "$LEGACY_CHANNEL_ID" > "$TMP_DIR/legacy-secret-after-decrypt.json" +assert_jq "$TMP_DIR/legacy-secret-after-decrypt.json" '.hasSecret == true and .hasSecretEncrypted == false' "decrypt migration restores legacy secret shape" echo "[OK] IM notifier regression passed" diff --git a/server/src/routes/notifications.js b/server/src/routes/notifications.js index 26f50c28..c0c25895 100644 --- a/server/src/routes/notifications.js +++ b/server/src/routes/notifications.js @@ -4,6 +4,10 @@ const { AppError } = require('../utils/AppError'); const { sendData } = require('../utils/reply'); const { buildAuditLogData } = require('../utils/audit'); const { sendInstantMessageByChannel } = require('../services/notifications/channels'); +const { + buildStoredSecretFields, + getChannelSecretReadModel, +} = require('../services/notifications/channelConfig'); const { ROLE_PERMISSIONS } = require('../constants/permissions'); const { IM_SUPPORTED_NOTIFICATION_TYPES, @@ -36,7 +40,7 @@ const ROUTING_SCHEMA = z }) .strict(); -const CONFIG_SCHEMA = z +const CONFIG_CREATE_SCHEMA = z .object({ mode: z.enum(CHANNEL_MODE_VALUES), webhookUrl: z.string().trim().max(2000).nullish(), @@ -55,6 +59,26 @@ const CONFIG_SCHEMA = z } }); +const CONFIG_PATCH_SCHEMA = z + .object({ + mode: z.enum(CHANNEL_MODE_VALUES), + webhookUrl: z.string().trim().max(2000).nullish(), + secret: z.string().trim().max(500).nullish(), + clearSecret: z.boolean().optional(), + timeoutMs: z.coerce.number().int().min(100).max(60000).optional(), + routing: ROUTING_SCHEMA.optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.mode === 'wecom_webhook' && !String(value.webhookUrl ?? '').trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['webhookUrl'], + message: 'wecom_webhook_requires_webhook_url', + }); + } + }); + const TEMPLATE_SCHEMA = z .object({ title: z.string().trim().min(1).max(200).optional(), @@ -69,7 +93,7 @@ const CHANNEL_CREATE_SCHEMA = z channelType: z.enum(CHANNEL_TYPE_VALUES), name: z.string().trim().min(1).max(100), enabled: z.boolean().optional(), - configJson: CONFIG_SCHEMA.optional(), + configJson: CONFIG_CREATE_SCHEMA.optional(), templateJson: TEMPLATE_SCHEMA.optional(), }) .strict(); @@ -78,7 +102,7 @@ const CHANNEL_PATCH_SCHEMA = z .object({ name: z.string().trim().min(1).max(100).optional(), enabled: z.boolean().optional(), - configJson: CONFIG_SCHEMA.optional(), + configJson: CONFIG_PATCH_SCHEMA.optional(), templateJson: TEMPLATE_SCHEMA.optional(), }) .strict() @@ -141,11 +165,16 @@ function normalizeRouting(routing) { }; } -function normalizeConfigJson(input = {}) { +function normalizeConfigJson(input = {}, options = {}) { return { mode: input.mode ?? 'mock', webhookUrl: String(input.webhookUrl ?? '').trim() || null, - secret: String(input.secret ?? '').trim() || null, + ...buildStoredSecretFields({ + config: options.config, + existingConfig: options.existingConfig, + inputSecret: input.secret, + clearSecret: Boolean(input.clearSecret), + }), timeoutMs: Math.max(100, Number(input.timeoutMs ?? 5000)), routing: normalizeRouting(input.routing), }; @@ -161,6 +190,36 @@ function normalizeTemplateJson(input = {}) { }; } +function mapChannelConfigForRead(configJson, options = {}) { + const rawConfig = configJson && typeof configJson === 'object' ? configJson : {}; + const secretModel = getChannelSecretReadModel({ + config: options.config, + configJson: rawConfig, + logger: options.logger, + }); + + return { + mode: rawConfig.mode ?? 'mock', + webhookUrl: String(rawConfig.webhookUrl ?? '').trim() || null, + timeoutMs: Math.max(100, Number(rawConfig.timeoutMs ?? 5000)), + routing: normalizeRouting(rawConfig.routing), + secretConfigured: secretModel.secretConfigured, + secretMasked: secretModel.secretMasked, + }; +} + +function mapTestResult(result) { + return { + ok: result.ok, + code: result.code, + message: result.message ?? null, + retryable: typeof result.retryable === 'boolean' ? result.retryable : null, + previewMessage: result.previewMessage ?? null, + response: result.response ?? null, + skipped: result.skipped ?? false, + }; +} + function mapNotificationItem(record) { return { id: record.id, @@ -174,8 +233,8 @@ function mapNotificationItem(record) { }; } -function mapNotificationChannel(record) { - const configJson = normalizeConfigJson(record.config_json); +function mapNotificationChannel(record, options = {}) { + const configJson = mapChannelConfigForRead(record.config_json, options); const templateJson = normalizeTemplateJson(record.template_json ?? undefined); return { @@ -191,9 +250,19 @@ function mapNotificationChannel(record) { } function handleNotificationChannelWriteError(error) { + if (error instanceof AppError) { + throw error; + } + if (error?.code === 'P2002') { throw buildValidationError({ name: 'duplicate_channel_name' }, 409); } + + const message = String(error?.message ?? error ?? ''); + if (message.includes('FIELD_ENCRYPTION_KEY') || message.includes('Invalid encryption key')) { + throw buildValidationError({ secret: 'field_encryption_key_missing' }); + } + throw error; } @@ -358,7 +427,7 @@ async function notificationsRoutes(fastify) { }); return sendData(reply, request, { - items: items.map(mapNotificationChannel), + items: items.map((item) => mapNotificationChannel(item, { config: fastify.config, logger: fastify.log })), }); }, ); @@ -381,7 +450,7 @@ async function notificationsRoutes(fastify) { channel_type: input.channelType, name: input.name, enabled: input.enabled ?? true, - config_json: normalizeConfigJson(input.configJson), + config_json: normalizeConfigJson(input.configJson, { config: fastify.config }), template_json: normalizeTemplateJson(input.templateJson), created_by: request.employee.id, updated_by: request.employee.id, @@ -402,7 +471,7 @@ async function notificationsRoutes(fastify) { }); return sendData(reply, request, { - channel: mapNotificationChannel(channel), + channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), }); } catch (error) { handleNotificationChannelWriteError(error); @@ -422,7 +491,7 @@ async function notificationsRoutes(fastify) { async (request, reply) => { const channelId = parseNotificationId(request.params?.id); const input = parseBody(CHANNEL_PATCH_SCHEMA, request.body); - await loadNotificationChannelOrThrow(fastify.prisma, channelId); + const existingChannel = await loadNotificationChannelOrThrow(fastify.prisma, channelId); try { const channel = await fastify.prisma.notificationChannel.update({ @@ -430,7 +499,7 @@ async function notificationsRoutes(fastify) { data: { ...(input.name ? { name: input.name } : {}), ...(typeof input.enabled === 'boolean' ? { enabled: input.enabled } : {}), - ...(input.configJson ? { config_json: normalizeConfigJson(input.configJson) } : {}), + ...(input.configJson ? { config_json: normalizeConfigJson(input.configJson, { config: fastify.config, existingConfig: existingChannel.config_json }) } : {}), ...(input.templateJson ? { template_json: normalizeTemplateJson(input.templateJson) } : {}), updated_by: request.employee.id, }, @@ -450,7 +519,7 @@ async function notificationsRoutes(fastify) { }); return sendData(reply, request, { - channel: mapNotificationChannel(channel), + channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), }); } catch (error) { handleNotificationChannelWriteError(error); @@ -497,14 +566,8 @@ async function notificationsRoutes(fastify) { }); return sendData(reply, request, { - channel: mapNotificationChannel(channel), - testResult: { - ok: result.ok, - code: result.code, - previewMessage: result.previewMessage ?? null, - response: result.response ?? null, - skipped: result.skipped ?? false, - }, + channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), + testResult: mapTestResult(result), }); }, ); @@ -539,7 +602,7 @@ async function notificationsRoutes(fastify) { }, update: { enabled: true, - config_json: normalizeConfigJson({ mode: 'mock' }), + config_json: normalizeConfigJson({ mode: 'mock' }, { config: fastify.config }), template_json: normalizeTemplateJson(), updated_by: request.employee.id, }, @@ -547,7 +610,7 @@ async function notificationsRoutes(fastify) { channel_type: input.channelType, name: input.name, enabled: true, - config_json: normalizeConfigJson({ mode: 'mock' }), + config_json: normalizeConfigJson({ mode: 'mock' }, { config: fastify.config }), template_json: normalizeTemplateJson(), created_by: request.employee.id, updated_by: request.employee.id, @@ -583,17 +646,18 @@ async function notificationsRoutes(fastify) { }); return sendData(reply, request, { - channel: mapNotificationChannel(channel), - testResult: { - ok: result.ok, - code: result.code, - previewMessage: result.previewMessage ?? null, - response: result.response ?? null, - skipped: result.skipped ?? false, - }, + channel: mapNotificationChannel(channel, { config: fastify.config, logger: fastify.log }), + testResult: mapTestResult(result), }); }, ); } +notificationsRoutes.__testables = { + mapChannelConfigForRead, + mapTestResult, + normalizeConfigJson, + normalizeRouting, +}; + module.exports = notificationsRoutes; diff --git a/server/src/scripts/migrate-notification-channel-secrets.js b/server/src/scripts/migrate-notification-channel-secrets.js new file mode 100644 index 00000000..809c89a6 --- /dev/null +++ b/server/src/scripts/migrate-notification-channel-secrets.js @@ -0,0 +1,107 @@ +require('dotenv/config'); + +const { getConfig } = require('../config'); +const { createPrismaClient } = require('../prismaClient'); +const { + decryptStringOrThrow, + encryptStringOrThrow, +} = require('../services/crypto'); +const { readStoredSecret } = require('../services/notifications/channelConfig'); + +function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function parseMode(argv) { + if (argv.includes('--encrypt')) { + return 'encrypt'; + } + + if (argv.includes('--decrypt')) { + return 'decrypt'; + } + + throw new Error('Usage: node src/scripts/migrate-notification-channel-secrets.js --encrypt|--decrypt'); +} + +(async () => { + const mode = parseMode(process.argv.slice(2)); + const config = getConfig(); + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + + try { + const rows = await prisma.notificationChannel.findMany({ + select: { + id: true, + name: true, + config_json: true, + }, + orderBy: [{ created_at: 'asc' }], + }); + + let updated = 0; + let skipped = 0; + + for (const row of rows) { + const current = asObject(row.config_json); + const { secretEncrypted, legacySecret } = readStoredSecret(current); + + if (mode === 'encrypt') { + if (!legacySecret) { + skipped += 1; + continue; + } + + const next = { + ...current, + secretEncrypted: encryptStringOrThrow(config, legacySecret), + }; + delete next.secret; + + await prisma.notificationChannel.update({ + where: { id: row.id }, + data: { + config_json: next, + updated_by: null, + }, + }); + updated += 1; + continue; + } + + if (!secretEncrypted) { + skipped += 1; + continue; + } + + const decryptedSecret = decryptStringOrThrow(config, secretEncrypted); + if (!decryptedSecret) { + throw new Error(`notification_channel_secret_empty_after_decrypt:${row.id}`); + } + + const next = { + ...current, + secret: decryptedSecret, + }; + delete next.secretEncrypted; + + await prisma.notificationChannel.update({ + where: { id: row.id }, + data: { + config_json: next, + updated_by: null, + }, + }); + updated += 1; + } + + process.stdout.write(`mode=${mode} updated=${updated} skipped=${skipped}\n`); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/src/services/integrations/im/wecom.js b/server/src/services/integrations/im/wecom.js index cb37a4e3..4be0f0dc 100644 --- a/server/src/services/integrations/im/wecom.js +++ b/server/src/services/integrations/im/wecom.js @@ -1,5 +1,7 @@ const crypto = require('node:crypto'); +const { resolveChannelSecret } = require('../../notifications/channelConfig'); + function toSafeString(value, maxLength = 2000) { if (value == null) { return ''; @@ -47,7 +49,31 @@ function buildSignedWebhookUrl(webhookUrl, secret) { return `${rawUrl}${separator}timestamp=${timestamp}&sign=${sign}`; } -async function sendWecomMessage({ channel, message }) { +function mapSecretError(result, previewMessage) { + if (result?.errorCode === 'secret_missing_field_encryption_key') { + return { + ok: false, + skipped: false, + retryable: false, + code: 'wecom_secret_missing_field_encryption_key', + message: result.errorMessage ?? 'Missing FIELD_ENCRYPTION_KEY for encrypted WeCom secret', + response: null, + previewMessage, + }; + } + + return { + ok: false, + skipped: false, + retryable: false, + code: 'wecom_secret_decrypt_failed', + message: result?.errorMessage ?? 'Failed to decrypt WeCom secret', + response: null, + previewMessage, + }; +} + +async function sendWecomMessage({ config, channel, message }) { const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {}; const mode = String(configJson.mode ?? 'mock').trim() || 'mock'; const previewMessage = buildWecomMarkdownMessage(message); @@ -56,7 +82,9 @@ async function sendWecomMessage({ channel, message }) { return { ok: true, skipped: false, + retryable: false, code: 'mock_sent', + message: null, response: { channelType: 'wecom', mode, @@ -90,12 +118,20 @@ async function sendWecomMessage({ channel, message }) { }; } + const secretResult = resolveChannelSecret({ + config, + configJson, + }); + if (secretResult.configured && !secretResult.secret) { + return mapSecretError(secretResult, previewMessage); + } + const timeoutMs = Math.max(100, Number(configJson.timeoutMs ?? 5000)); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(buildSignedWebhookUrl(webhookUrl, configJson.secret ?? null), { + const response = await fetch(buildSignedWebhookUrl(webhookUrl, secretResult.secret), { method: 'POST', headers: { 'content-type': 'application/json', @@ -146,7 +182,9 @@ async function sendWecomMessage({ channel, message }) { return { ok: true, skipped: false, + retryable: false, code: 'wecom_sent', + message: null, response: responseBody, previewMessage, }; @@ -178,6 +216,7 @@ async function sendWecomMessage({ channel, message }) { } module.exports = { + buildSignedWebhookUrl, buildWecomMarkdownMessage, sendWecomMessage, }; diff --git a/server/src/services/notifications/channelConfig.js b/server/src/services/notifications/channelConfig.js new file mode 100644 index 00000000..ab0a27df --- /dev/null +++ b/server/src/services/notifications/channelConfig.js @@ -0,0 +1,164 @@ +const { + decryptStringOrThrow, + encryptStringOrThrow, + resolveFieldEncryptionKey, +} = require('../crypto'); + +function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function trimToNull(value, maxLength = 2000) { + const normalized = String(value ?? '').trim(); + if (!normalized) { + return null; + } + return normalized.slice(0, maxLength); +} + +function maskSecret(secret) { + const value = trimToNull(secret, 500); + if (!value) { + return null; + } + + if (value.length <= 2) { + return '*'.repeat(value.length); + } + + if (value.length <= 6) { + return `${value.slice(0, 1)}${'*'.repeat(value.length - 2)}${value.slice(-1)}`; + } + + return `${value.slice(0, 2)}${'*'.repeat(Math.max(4, value.length - 4))}${value.slice(-2)}`; +} + +function readStoredSecret(configJson) { + const normalized = asObject(configJson); + return { + secretEncrypted: trimToNull(normalized.secretEncrypted, 4000), + legacySecret: trimToNull(normalized.secret, 500), + }; +} + +function hasFieldEncryptionKey(config) { + return Boolean(resolveFieldEncryptionKey(config)); +} + +function resolveChannelSecret({ config, configJson, logger } = {}) { + const { secretEncrypted, legacySecret } = readStoredSecret(configJson); + + if (secretEncrypted) { + if (!hasFieldEncryptionKey(config)) { + return { + configured: true, + secret: null, + source: 'encrypted', + errorCode: 'secret_missing_field_encryption_key', + errorMessage: 'Missing FIELD_ENCRYPTION_KEY for encrypted notification channel secret', + }; + } + + try { + const secret = decryptStringOrThrow(config, secretEncrypted); + if (!secret) { + return { + configured: true, + secret: null, + source: 'encrypted', + errorCode: 'secret_decrypt_failed', + errorMessage: 'Failed to decrypt notification channel secret', + }; + } + + return { + configured: true, + secret, + source: 'encrypted', + errorCode: null, + errorMessage: null, + }; + } catch (error) { + logger?.warn?.({ err: error }, 'Notification channel secret decrypt failed'); + const message = String(error?.message ?? 'Failed to decrypt notification channel secret'); + return { + configured: true, + secret: null, + source: 'encrypted', + errorCode: message.includes('FIELD_ENCRYPTION_KEY') + ? 'secret_missing_field_encryption_key' + : 'secret_decrypt_failed', + errorMessage: message, + }; + } + } + + if (legacySecret) { + return { + configured: true, + secret: legacySecret, + source: 'legacy', + errorCode: null, + errorMessage: null, + }; + } + + return { + configured: false, + secret: null, + source: null, + errorCode: null, + errorMessage: null, + }; +} + +function getChannelSecretReadModel({ config, configJson, logger } = {}) { + const resolved = resolveChannelSecret({ config, configJson, logger }); + return { + secretConfigured: resolved.configured, + secretMasked: resolved.secret ? maskSecret(resolved.secret) : resolved.configured ? '******' : null, + }; +} + +function buildStoredSecretFields({ config, existingConfig, inputSecret, clearSecret = false } = {}) { + if (clearSecret) { + return {}; + } + + const nextSecret = trimToNull(inputSecret, 500); + if (nextSecret) { + return { + secretEncrypted: encryptStringOrThrow(config, nextSecret), + }; + } + + const { secretEncrypted, legacySecret } = readStoredSecret(existingConfig); + if (secretEncrypted) { + return { + secretEncrypted, + }; + } + + if (legacySecret) { + if (hasFieldEncryptionKey(config)) { + return { + secretEncrypted: encryptStringOrThrow(config, legacySecret), + }; + } + + return { + secret: legacySecret, + }; + } + + return {}; +} + +module.exports = { + buildStoredSecretFields, + getChannelSecretReadModel, + hasFieldEncryptionKey, + maskSecret, + readStoredSecret, + resolveChannelSecret, +}; diff --git a/server/src/services/notifications/channels.js b/server/src/services/notifications/channels.js index 7e5898aa..9af43003 100644 --- a/server/src/services/notifications/channels.js +++ b/server/src/services/notifications/channels.js @@ -46,7 +46,9 @@ async function sendInstantMessageByChannel({ config, channel, payload }) { return { ok: true, skipped: true, + retryable: false, code: 'im_notifier_disabled', + message: 'IM notifier disabled', response: { channelType: normalizedChannelType || null }, previewMessage: null, }; @@ -56,7 +58,9 @@ async function sendInstantMessageByChannel({ config, channel, payload }) { return { ok: true, skipped: true, + retryable: false, code: 'channel_not_found', + message: 'Notification channel not found', response: { channelType: normalizedChannelType || null }, previewMessage: null, }; @@ -66,7 +70,9 @@ async function sendInstantMessageByChannel({ config, channel, payload }) { return { ok: true, skipped: true, + retryable: false, code: 'channel_disabled', + message: 'Notification channel disabled', response: { channelType: normalizedChannelType || null }, previewMessage: null, }; @@ -76,14 +82,16 @@ async function sendInstantMessageByChannel({ config, channel, payload }) { return { ok: true, skipped: true, + retryable: false, code: 'unsupported_channel_type', + message: 'Unsupported notification channel type', response: { channelType: normalizedChannelType || null }, previewMessage: null, }; } const message = buildNotificationMessage(channel, payload); - return sendWecomMessage({ channel, message }); + return sendWecomMessage({ config, channel, message }); } module.exports = { diff --git a/server/tests/unit/integrations.dispatcher.test.js b/server/tests/unit/integrations.dispatcher.test.js index 51dfb165..254e3fd8 100644 --- a/server/tests/unit/integrations.dispatcher.test.js +++ b/server/tests/unit/integrations.dispatcher.test.js @@ -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({ diff --git a/server/tests/unit/routes/notifications.test.js b/server/tests/unit/routes/notifications.test.js new file mode 100644 index 00000000..34da4233 --- /dev/null +++ b/server/tests/unit/routes/notifications.test.js @@ -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(); + }); +}); diff --git a/server/tests/unit/services/integrations/dispatcher.test.js b/server/tests/unit/services/integrations/dispatcher.test.js index 4abec747..01457bb4 100644 --- a/server/tests/unit/services/integrations/dispatcher.test.js +++ b/server/tests/unit/services/integrations/dispatcher.test.js @@ -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({