732 行
26 KiB
Bash
可执行文件
732 行
26 KiB
Bash
可执行文件
#!/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 ?? '[email protected]' },
|
|
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}" "mock" "" "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"
|
|
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}")"
|
|
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"
|