#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-im-notifier}" EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}" FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}" RUN_SUFFIX="$(date +%s)-$RANDOM" if ! command -v curl >/dev/null 2>&1; then echo "[ERROR] curl not found" >&2 exit 1 fi if ! command -v jq >/dev/null 2>&1; then echo "[ERROR] jq not found" >&2 exit 1 fi if ! command -v node >/dev/null 2>&1; then echo "[ERROR] node not found" >&2 exit 1 fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" # shellcheck source=scripts/lib/load-env-defaults.sh source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 exit 1 fi TEST_FIELD_ENCRYPTION_KEY_DEFAULT="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT}" export FIELD_ENCRYPTION_KEY TMP_DIR="$(mktemp -d)" REGRESSION_CHANNEL_IDS=() track_regression_channel() { local channel_id="$1" REGRESSION_CHANNEL_IDS+=("$channel_id") } disable_channels_by_ids() { if [[ "${#REGRESSION_CHANNEL_IDS[@]}" -eq 0 || -z "${DATABASE_URL:-}" ]]; then return 0 fi local channel_ids_csv channel_ids_csv="$(IFS=,; printf '%s' "${REGRESSION_CHANNEL_IDS[*]}")" DATABASE_URL="$DATABASE_URL" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' >/dev/null 2>&1 || true const { createPrismaClient } = require('./src/prismaClient'); (async () => { const channelIds = (process.env.CHANNEL_IDS || '').split(',').filter(Boolean); if (!channelIds.length) return; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.notificationChannel.updateMany({ where: { id: { in: channelIds } }, data: { enabled: false }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch(() => {}); NODE } cleanup() { disable_channels_by_ids rm -rf "$TMP_DIR" } trap cleanup EXIT disable_stale_regression_channels() { DATABASE_URL="$DATABASE_URL" node - <<'NODE' >/dev/null 2>&1 || true const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.notificationChannel.updateMany({ where: { OR: [ { name: { startsWith: 'IM Mock ' } }, { name: { startsWith: 'IM Failing ' } }, { name: { startsWith: 'IM Secret ' } }, { name: { startsWith: 'IM Legacy ' } }, ], }, data: { enabled: false }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch(() => {}); NODE } assert_status() { local actual="$1" local expected="$2" local name="$3" local body_file="${4:-}" if [[ "$actual" != "$expected" ]]; then echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 [[ -n "$body_file" && -f "$body_file" ]] && cat "$body_file" >&2 exit 1 fi } assert_jq() { local file_path="$1" local jq_expr="$2" local name="$3" if ! jq -e "$jq_expr" "$file_path" >/dev/null; then echo "[ERROR] $name assertion failed: $jq_expr" >&2 cat "$file_path" >&2 exit 1 fi } login_user() { local identifier="$1" local password="$2" local out_file="$3" local status status="$(curl -sS -o "$out_file" -w "%{http_code}" \ -X POST "$BASE_URL/api/auth/login" \ -H "content-type: application/json" \ -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" assert_status "$status" "200" "login $identifier" "$out_file" jq -er '.data.accessToken' "$out_file" } api_json_auth() { local method="$1" local url="$2" local token="$3" local out_file="$4" local body="${5:-}" if [[ -n "$body" ]]; then curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" \ -H "content-type: application/json" \ -d "$body" else curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" fi } resolve_fixture_ids() { DATABASE_URL="$DATABASE_URL" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); const { resolveApplicableBudgetPoolByContext } = require('./src/services/budgetPool'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const employee = await prisma.employee.findUnique({ where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? 'employee@hyinda.com' }, select: { department_id: true, legal_entity_id: true }, }); const costCenter = await prisma.costCenter.findFirst({ where: { enabled: true, archived_at: null, legal_entity_id: employee.legal_entity_id, }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const supplier = await prisma.supplier.findFirst({ where: { enabled: true, archived_at: null, OR: [{ legal_entity_id: employee.legal_entity_id }, { legal_entity_id: null }], }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const documentCategory = await prisma.documentCategory.findFirst({ where: { enabled: true, archived_at: null, entity_type: 'purchase_request', }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const budgetPool = await resolveApplicableBudgetPoolByContext(prisma, { departmentId: employee.department_id, legalEntityId: employee.legal_entity_id, costCenterId: costCenter?.id ?? null, effectiveDate: new Date(), }); process.stdout.write([ employee.legal_entity_id ?? '', costCenter?.id ?? '', supplier?.id ?? '', documentCategory?.id ?? '', budgetPool?.id ?? '', ].join('\n')); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } create_channel() { local token="$1" local name="$2" local mode="$3" local webhook_url="$4" 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, enabled:true, configJson:{ mode:$mode, webhookUrl:(if $webhookUrl == "" then null else $webhookUrl end), secret:(if $secret == "" then null else $secret end), timeoutMs:800, routing:{ roleCodes:["manager","ceo","admin"], domainScopes:["workflow"], allowedTypes:["workflow.task_assigned"] } }, templateJson:{ title:"{{title}}", body:"{{body}}\n\n查看详情:{{detailUrl}}", footer:"来自海音达工作台", deepLinkLabel:"查看详情" } }')" local status status="$(api_json_auth POST "$BASE_URL/api/notifications/channels" "$token" "$out_file" "$body")" assert_status "$status" "200" "create channel $name" "$out_file" jq -er '.data.channel.id' "$out_file" } patch_channel_enabled() { local token="$1" local channel_id="$2" local enabled="$3" local out_file="$4" local status status="$(api_json_auth PATCH "$BASE_URL/api/notifications/channels/$channel_id" "$token" "$out_file" "{\"enabled\":$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" local out_prefix="$3" local legal_entity_id="$4" local cost_center_id="$5" local supplier_id="$6" local document_category_id="$7" local budget_pool_id="$8" local create_status create_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests" "$token" "$TMP_DIR/${out_prefix}-create.json" '{}')" assert_status "$create_status" "200" "create purchase request $label" "$TMP_DIR/${out_prefix}-create.json" local purchase_id purchase_id="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/${out_prefix}-create.json")" local patch_body if [[ -n "$budget_pool_id" ]]; then patch_body="$(jq -nc \ --arg title "$label" \ --arg le "$legal_entity_id" \ --arg cc "$cost_center_id" \ --arg sp "$supplier_id" \ --arg dc "$document_category_id" \ --arg bp "$budget_pool_id" \ '{ title:$title, legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, budgetPoolId:$bp, items:[{description:"IM notifier regression", specification:"mock event", quantity:"1.00", unitPrice:"88.00", isAssetCandidate:false}] }')" else patch_body="$(jq -nc \ --arg title "$label" \ --arg le "$legal_entity_id" \ --arg cc "$cost_center_id" \ --arg sp "$supplier_id" \ --arg dc "$document_category_id" \ '{ title:$title, legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:"IM notifier regression", specification:"mock event", quantity:"1.00", unitPrice:"88.00", isAssetCandidate:false}] }')" fi local patch_status patch_status="$(api_json_auth PATCH "$BASE_URL/api/purchase-requests/$purchase_id" "$token" "$TMP_DIR/${out_prefix}-patch.json" "$patch_body")" assert_status "$patch_status" "200" "patch purchase request $label" "$TMP_DIR/${out_prefix}-patch.json" local submit_status submit_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$purchase_id/submit" "$token" "$TMP_DIR/${out_prefix}-submit.json" '{}')" assert_status "$submit_status" "200" "submit purchase request $label" "$TMP_DIR/${out_prefix}-submit.json" assert_jq "$TMP_DIR/${out_prefix}-submit.json" '.data.status == "reviewing" and (.data.currentTaskId | length > 0)' "purchase submit reviewing $label" printf '%s\n' "$purchase_id" } query_im_deliveries_for_entity() { local entity_id="$1" local channel_ids_csv="$2" DATABASE_URL="$DATABASE_URL" ENTITY_ID="$entity_id" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const entityId = process.env.ENTITY_ID; const channelIds = (process.env.CHANNEL_IDS || '').split(',').filter(Boolean); const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const instances = await prisma.workflowInstance.findMany({ where: { purchase_request_id: entityId }, select: { workflow_tasks: { select: { id: true }, }, }, }); const taskIds = instances.flatMap((instance) => instance.workflow_tasks.map((task) => task.id)); const notifications = taskIds.length ? await prisma.notification.findMany({ where: { type: 'workflow.task_assigned', target_type: 'workflow_task', target_id: { in: taskIds }, }, select: { id: true }, }) : []; const notificationIds = notifications.map((notification) => notification.id); const deliveries = notificationIds.length ? await prisma.notificationDelivery.findMany({ where: { notification_id: { in: notificationIds }, channel_id: { in: channelIds }, }, orderBy: [{ channel_id: 'asc' }, { attempt_no: 'asc' }], select: { id: true, notification_id: true, channel_id: true, status: true, attempt_no: true, result_code: true, error_message: true, }, }) : []; const items = deliveries.map((item) => ({ id: item.id, notificationId: item.notification_id, status: item.status, attemptNo: item.attempt_no, resultCode: item.result_code, errorMessage: item.error_message, channelId: item.channel_id, })); process.stdout.write(JSON.stringify(items)); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } enqueue_manual_im_outbox() { local channel_id="$1" DATABASE_URL="$DATABASE_URL" CHANNEL_ID="$channel_id" RUN_SUFFIX="$RUN_SUFFIX" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); const { enqueueIntegrationEvent } = require('./src/services/integrations/outbox'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const outbox = await enqueueIntegrationEvent(prisma, { eventType: 'notification.im', dedupeKey: `manual-im-disabled-${process.env.RUN_SUFFIX}`, maxAttempts: 5, actorId: null, payload: { channelId: process.env.CHANNEL_ID, channelType: 'wecom', notificationType: 'workflow.task_assigned', domainScope: 'workflow', title: '【待办】IM disabled regression', body: 'IM disabled regression body', targetType: 'workflow_task', targetId: `manual-task-${process.env.RUN_SUFFIX}`, targetPath: '/workflow/tasks/manual-task', detailUrl: 'https://app.example.com/workflow/tasks/manual-task', recipientEmployeeIds: [], recipientRoleCodes: ['manager'], entityType: 'purchase_request', entityId: `manual-pr-${process.env.RUN_SUFFIX}`, }, }); process.stdout.write(outbox.id); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } query_outbox_by_id() { local outbox_id="$1" DATABASE_URL="$DATABASE_URL" OUTBOX_ID="$outbox_id" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const row = await prisma.integrationOutbox.findUnique({ where: { id: process.env.OUTBOX_ID }, select: { id: true, status: true, last_error: true, attempts: true, }, }); process.stdout.write(JSON.stringify(row)); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } dispatch_outboxes_by_ids() { local outbox_ids_csv="$1" local im_enabled="$2" DATABASE_URL="$DATABASE_URL" OUTBOX_IDS="$outbox_ids_csv" IM_NOTIFIER_ENABLED="$im_enabled" node - <<'NODE' require('dotenv/config'); const { createPrismaClient } = require('./src/prismaClient'); const { getConfig } = require('./src/config'); const { dispatchOutboxEvent } = require('./src/services/integrations/dispatcher'); const { markOutboxFailed, markOutboxProcessing, markOutboxSucceeded, } = require('./src/services/integrations/outbox'); (async () => { const ids = (process.env.OUTBOX_IDS || '').split(',').filter(Boolean); const config = getConfig(); const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { for (const id of ids) { const item = await prisma.integrationOutbox.findUnique({ where: { id }, select: { id: true, event_type: true, payload: true, attempts: true, max_attempts: true, }, }); if (!item) { throw new Error(`outbox_not_found:${id}`); } await markOutboxProcessing(prisma, item.id, null); try { await dispatchOutboxEvent({ prisma, config, eventType: item.event_type, payload: item.payload, }); await markOutboxSucceeded(prisma, item.id, null); } catch (error) { const retryable = typeof error?.retryable === 'boolean' ? error.retryable : true; await markOutboxFailed(prisma, config, item, error, null, { retryable }); console.error({ err: error, retryable, outboxId: item.id, eventType: item.event_type, channelId: item.payload?.channelId ?? null, channelType: item.payload?.channelType ?? null, notificationType: item.payload?.notificationType ?? null, }, 'Integration outbox dispatch failed'); } } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } disable_stale_regression_channels echo "[1/10] login employee + finance_mgr" EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")" FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")" mapfile -t FIXTURE_IDS < <(resolve_fixture_ids) LEGAL_ENTITY_ID="${FIXTURE_IDS[0]}" COST_CENTER_ID="${FIXTURE_IDS[1]}" SUPPLIER_ID="${FIXTURE_IDS[2]}" DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" BUDGET_POOL_ID="${FIXTURE_IDS[4]}" echo "[2/10] create mock + failing wecom channels" MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "" "$TMP_DIR/create-mock-channel.json")" track_regression_channel "$MOCK_CHANNEL_ID" 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")" track_regression_channel "$FAIL_CHANNEL_ID" echo "[3/10] submit purchase request and verify IM deliveries" 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")" query_im_deliveries_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-a.json" assert_jq "$TMP_DIR/deliveries-a.json" 'length == 4 and (map(select(.status == "success" and .resultCode == "mock_sent")) | length) == 1 and (map(select(.status == "failed" and .resultCode == "wecom_network_error" and (.errorMessage | type == "string") and (.errorMessage | length > 0))) | length) == 3' "submit purchase writes IM delivery attempts" echo "[4/10] verify failing webhook is captured without failing business submission" assert_jq "$TMP_DIR/deliveries-a.json" '(map(select(.status == "failed")) | length) >= 1 and (map(select(.status == "failed"))[0].errorMessage | type == "string")' "failing webhook captured delivery error" echo "[5/10] disable channels and verify business submission safely skips IM delivery" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "false" "$TMP_DIR/disable-mock-channel.json" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$FAIL_CHANNEL_ID" "false" "$TMP_DIR/disable-fail-channel.json" PURCHASE_ID_B="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression B ${RUN_SUFFIX}" "purchase-b" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" query_im_deliveries_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-b.json" assert_jq "$TMP_DIR/deliveries-b.json" 'length == 0' "disabled channels should not write IM deliveries" echo "[6/10] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "true" "$TMP_DIR/re-enable-mock-channel.json" MANUAL_OUTBOX_ID="$(enqueue_manual_im_outbox "$MOCK_CHANNEL_ID")" dispatch_outboxes_by_ids "$MANUAL_OUTBOX_ID" "false" > "$TMP_DIR/outbox-job-im-disabled.log" 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/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" 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}" "wecom_webhook" "http://127.0.0.1:9/wecom" "secret-${RUN_SUFFIX}" "$TMP_DIR/create-secret-channel.json")" track_regression_channel "$SECRET_CHANNEL_ID" 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" query_channel_secret_state "$SECRET_CHANNEL_ID" > "$TMP_DIR/secret-channel-storage.json" assert_jq "$TMP_DIR/secret-channel-storage.json" '.hasSecret == false and .hasSecretEncrypted == true' "channel stores only encrypted secret" 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 == false and .data.testResult.code == "wecom_network_error" and .data.testResult.retryable == true and .data.channel.configJson.secretConfigured == true' "encrypted secret channel follows loopback provider failure path" 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}")" track_regression_channel "$LEGACY_CHANNEL_ID" 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"