feat(P23IM2-1,P23IM2-2,P23IM2-3): 密钥加密持久化/遮罩读模型/企微可重试语义/迁移脚本
这个提交包含在:
@@ -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"
|
||||
|
||||
在新工单中引用
屏蔽一个用户