forked from wangziqi/gongxue-base
223 lines
9.1 KiB
JavaScript
223 lines
9.1 KiB
JavaScript
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);
|
|
});
|