import assert from 'node:assert/strict'; import http from 'node:http'; import pg from 'pg'; import { spawn } from 'node:child_process'; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const ids = { tenant: '00000000-0000-0000-0000-00000000ab01', auditLog: '00000000-0000-0000-0000-00000000ab02', alert: '00000000-0000-0000-0000-00000000ab03', }; function getFreePort() { return new Promise((resolve, reject) => { const server = http.createServer(); server.listen(0, '127.0.0.1', () => { const address = server.address(); server.close(() => resolve(address.port)); }); server.on('error', reject); }); } async function startWebhookServer() { const port = await getFreePort(); const requests = []; const server = http.createServer((req, res) => { let raw = ''; req.on('data', chunk => { raw += chunk.toString(); }); req.on('end', () => { requests.push({ url: req.url, headers: req.headers, body: raw ? JSON.parse(raw) : {}, }); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); }); await new Promise(resolve => server.listen(port, '127.0.0.1', resolve)); return { url: `http://127.0.0.1:${port}/platform-audit`, requests, close: () => new Promise(resolve => server.close(resolve)), }; } function runWorkerOnce() { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-audit-notifications'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE: '20', WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true', WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS: '5000', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let output = ''; child.stdout.on('data', chunk => { output += chunk.toString(); }); child.stderr.on('data', chunk => { output += chunk.toString(); }); return new Promise((resolve, reject) => { child.on('error', reject); child.on('exit', code => { try { assert.equal(code, 0, `worker should exit 0\n${output}`); assert.match(output, /platform-audit-notifications batch enqueued=\d+/, 'worker output should include notification summary'); resolve(output); } catch (error) { reject(error); } }); }); } async function cleanup(pool) { await pool.query("delete from public.platform_audit_notification_events where alert_id = $1", [ids.alert]); await pool.query("delete from public.platform_audit_notification_channels where channel_code = 'worker_platform_audit_test'"); await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_audit_test'"); await pool.query('delete from public.platform_audit_alerts where id = $1 or audit_log_id = $2', [ids.alert, ids.auditLog]); await pool.query('delete from public.audit_logs where id = $1 or tenant_id = $2', [ids.auditLog, ids.tenant]); await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenants where id = $1', [ids.tenant]); } async function seed(pool, webhookUrl) { await pool.query( ` insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata) values ($1, 'platform-audit-notification-worker', '平台审计通知租户', '平台审计通知有限公司', 'active', 'saas', 'active', '{"source":"platform-audit-notification-worker-test"}'::jsonb) `, [ids.tenant], ); await pool.query( ` insert into public.audit_logs ( id, tenant_id, actor_user_id, action, target_type, target_id, details, ip_address, user_agent, created_at ) values ( $1, $2::uuid, null, 'platform.tenant.status_updated', 'tenant', $3, '{"status":"suspended","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb, '127.0.0.1', 'platform-audit-notification-worker-test', now() ) `, [ids.auditLog, ids.tenant, ids.tenant], ); const rule = await pool.query("select id from public.platform_audit_alert_rules where code = 'platform_tenant_status_changed' limit 1"); await pool.query( ` insert into public.platform_audit_alerts ( id, rule_id, audit_log_id, tenant_id, severity, status, action, target_type, target_id, title, summary, details, first_seen_at, last_seen_at ) values ( $1, $2, $3, $4::uuid, 'high', 'open', 'platform.tenant.status_updated', 'tenant', $4, '租户状态变更告警', 'platform audit notification integration alert', '{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb, now(), now() ) `, [ids.alert, rule.rows[0].id, ids.auditLog, ids.tenant], ); await pool.query( ` insert into app_private.platform_secrets (secret_scope, secret_key, secret_value, provider, last_rotated_at) values ('webhook', 'worker_platform_audit_test', 'platform-audit-notification-secret', 'generic', now()) on conflict (secret_scope, secret_key) do update set secret_value = excluded.secret_value, provider = excluded.provider, last_rotated_at = now(), updated_at = now() `, ); await pool.query( ` insert into public.platform_audit_notification_channels ( channel_code, name, enabled, provider, webhook_url, secret_ref, min_severity, status_filter, action_patterns, timeout_sec ) values ( 'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic', $1, 'app_private.platform_secrets:webhook:worker_platform_audit_test', 'medium', array['open']::text[], array['platform.tenant.*']::text[], 5 ) on conflict (channel_code) do update set enabled = excluded.enabled, provider = excluded.provider, webhook_url = excluded.webhook_url, secret_ref = excluded.secret_ref, min_severity = excluded.min_severity, status_filter = excluded.status_filter, action_patterns = excluded.action_patterns, timeout_sec = excluded.timeout_sec, updated_at = now() `, [webhookUrl], ); } async function main() { const webhook = await startWebhookServer(); const pool = new pg.Pool({ connectionString: databaseUrl }); try { await cleanup(pool); await seed(pool, webhook.url); const firstOutput = await runWorkerOnce(); assert.match(firstOutput, /sent=1/, 'worker should send one platform audit notification'); assert.equal(webhook.requests.length, 1, 'worker should call webhook exactly once'); assert.equal(webhook.requests[0].body.event, 'platform.audit.alert', 'generic notification should use platform audit event name'); assert.equal(webhook.requests[0].body.alert.id, ids.alert, 'webhook body should include alert id'); assert.equal(webhook.requests[0].body.alert.details.apiKey, '[REDACTED]', 'webhook body should redact token-like keys'); assert.equal(webhook.requests[0].body.alert.details.nested.password, '[REDACTED]', 'webhook body should redact nested password'); assert.ok(!JSON.stringify(webhook.requests[0]).includes('must-not-leak'), 'webhook request must not leak sensitive values'); assert.ok(!JSON.stringify(webhook.requests[0]).includes('platform-audit-notification-secret'), 'webhook request must not leak signing secret'); const events = await pool.query( ` select status, attempts, last_http_code, sent_at, request_payload from public.platform_audit_notification_events where alert_id = $1 limit 1 `, [ids.alert], ); assert.equal(events.rowCount, 1, 'worker should create one notification event'); assert.equal(events.rows[0].status, 'sent', 'notification event should be sent'); assert.equal(events.rows[0].attempts, 1, 'notification event should record one attempt'); assert.equal(events.rows[0].last_http_code, 200, 'notification event should record HTTP 200'); assert.ok(events.rows[0].sent_at, 'notification event should record sent_at'); assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('must-not-leak'), 'stored request payload should be redacted'); assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('platform-audit-notification-secret'), 'stored request payload should not leak secret'); const secondOutput = await runWorkerOnce(); assert.match(secondOutput, /sent=0/, 'second worker run should not resend sent notification'); assert.equal(webhook.requests.length, 1, 'worker should not duplicate sent notification'); console.log('Platform audit notification worker integration test complete.'); } finally { await cleanup(pool).catch(() => {}); await pool.end(); await webhook.close(); } } main().catch(error => { console.error(error); process.exit(1); });