import assert from 'node:assert/strict'; import pg from 'pg'; import { spawn } from 'node:child_process'; import http from 'node:http'; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const tenantId = '00000000-0000-0000-0000-000000000001'; const ids = { okAsset: '20000000-0000-0000-0000-000000000801', badAsset: '20000000-0000-0000-0000-000000000802', scanBadAsset: '20000000-0000-0000-0000-000000000803', httpPassAsset: '20000000-0000-0000-0000-000000000804', httpFailAsset: '20000000-0000-0000-0000-000000000805', httpUnavailableAsset: '20000000-0000-0000-0000-000000000806', }; const checksumA = 'a'.repeat(64); const checksumB = 'b'.repeat(64); async function runWorkerOnce(extraEnv = {}) { const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'assets'], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl, WORKER_ASSET_BATCH_SIZE: '20', WORKER_ASSET_MIN_AGE_SECONDS: '0', WORKER_ASSET_RECHECK_INTERVAL_SECONDS: '86400', WORKER_ASSET_REQUEST_TIMEOUT_MS: '5000', WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules', WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: '', WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: '', WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS: '10000', WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false', STORAGE_REQUIRE_TENANT_PREFIX: 'true', ...extraEnv, }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let output = ''; child.stdout.on('data', chunk => { output += chunk.toString(); }); child.stderr.on('data', chunk => { output += chunk.toString(); }); const code = await new Promise(resolve => child.on('exit', resolve)); assert.equal(code, 0, `worker should exit 0\n${output}`); assert.match(output, /assets batch processed=\d+/, 'worker output should include assets summary'); return output; } async function startScannerServer() { const requests = []; const server = http.createServer((request, response) => { let body = ''; request.on('data', chunk => { body += chunk.toString(); }); request.on('end', () => { const payload = JSON.parse(body || '{}'); requests.push({ authorization: request.headers.authorization, payload, }); response.setHeader('content-type', 'application/json'); if (payload.assetId === ids.httpFailAsset) { response.end(JSON.stringify({ status: 'failed', riskLevel: 'critical', issueCodes: ['virus_detected'], details: { engine: 'fake-av', signature: 'EICAR-Test-File', accessToken: 'must-be-redacted', }, })); return; } response.end(JSON.stringify({ status: 'passed', riskLevel: 'none', issueCodes: [], details: { engine: 'fake-av', scannedBytes: payload.fileSizeBytes, }, })); }); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); assert.ok(address && typeof address === 'object', 'scanner server should listen on a TCP port'); return { endpoint: `http://127.0.0.1:${address.port}/scan`, requests, close: () => new Promise(resolve => server.close(resolve)), }; } function countFromWorkerOutput(output, name) { const match = output.match(new RegExp(`${name}=(\\d+)`)); return match ? Number(match[1]) : 0; } async function cleanup(pool) { await pool.query( ` delete from public.audit_logs where tenant_id = $1 and target_type = 'content_asset' and target_id = any($2::text[]) `, [tenantId, Object.values(ids)], ); await pool.query( ` delete from public.content_asset_security_scan_events where tenant_id = $1 and asset_id = any($2::uuid[]) `, [tenantId, Object.values(ids)], ); await pool.query( ` delete from public.content_assets where tenant_id = $1 and id = any($2::uuid[]) `, [tenantId, Object.values(ids)], ); } async function seed(pool) { await pool.query( ` insert into public.content_assets ( id, tenant_id, asset_key, title, asset_type, storage_provider, bucket, object_key, file_name, mime_type, file_size_bytes, checksum_sha256, visibility, status, upload_status, verified_at, verified_size_bytes, verified_checksum_sha256, verification_details, security_scan_status, security_scan_provider, security_scan_summary, security_flags, source, created_at, updated_at ) values ( $1, $2, 'asset-worker-ok', '资源复检正常 PDF', 'pdf', 'local_dev', 'tenant-assets', $3, 'ok.pdf', 'application/pdf', 4096, $4, 'tenant', 'active', 'verified', now() - interval '2 days', 4096, $4, '{"source":"asset-worker-test"}'::jsonb, 'pending', 'metadata_rules', '{}'::jsonb, '{}'::jsonb, 'integration-test', now() - interval '2 days', now() - interval '2 days' ), ( $5, $2, 'asset-worker-bad', '资源复检异常 PDF', 'pdf', 'local_dev', 'tenant-assets', $6, 'bad.pdf', 'application/pdf', 1024, $7, 'tenant', 'active', 'verified', now() - interval '2 days', 2048, $7, '{"source":"asset-worker-test"}'::jsonb, 'pending', 'metadata_rules', '{}'::jsonb, '{}'::jsonb, 'integration-test', now() - interval '2 days', now() - interval '2 days' ), ( $8, $2, 'asset-worker-scan-bad', '资源安全扫描异常 PDF', 'pdf', 'local_dev', 'tenant-assets', $9, 'scan-bad.pdf', 'application/pdf', 4096, $4, 'tenant', 'active', 'verified', now() - interval '2 days', 4096, $4, '{"source":"asset-worker-test"}'::jsonb, 'pending', 'metadata_rules', '{"securityScanForceFail":true}'::jsonb, '{}'::jsonb, 'integration-test', now() - interval '2 days', now() - interval '2 days' ), ( $10, $2, 'asset-worker-http-pass', '外部扫描正常 PDF', 'pdf', 'local_dev', 'tenant-assets', $11, 'http-pass.pdf', 'application/pdf', 4096, $4, 'tenant', 'active', 'verified', now() - interval '2 days', 4096, $4, '{"source":"asset-worker-test"}'::jsonb, 'pending', 'metadata_rules', '{}'::jsonb, '{}'::jsonb, 'integration-test', now() - interval '2 days', now() - interval '2 days' ), ( $12, $2, 'asset-worker-http-fail', '外部扫描异常 PDF', 'pdf', 'local_dev', 'tenant-assets', $13, 'http-fail.pdf', 'application/pdf', 4096, $4, 'tenant', 'active', 'verified', now() - interval '2 days', 4096, $4, '{"source":"asset-worker-test"}'::jsonb, 'pending', 'metadata_rules', '{}'::jsonb, '{}'::jsonb, 'integration-test', now() - interval '2 days', now() - interval '2 days' ), ( $14, $2, 'asset-worker-http-unavailable', '外部扫描不可用 PDF', 'pdf', 'local_dev', 'tenant-assets', $15, 'http-unavailable.pdf', 'application/pdf', 4096, $4, 'tenant', 'active', 'verified', now() - interval '2 days', 4096, $4, '{"source":"asset-worker-test"}'::jsonb, 'pending', 'metadata_rules', '{}'::jsonb, '{}'::jsonb, 'integration-test', now() - interval '2 days', now() - interval '2 days' ) `, [ ids.okAsset, tenantId, `${tenantId}/assets/worker-ok.pdf`, checksumA, ids.badAsset, `${tenantId}/assets/worker-bad.pdf`, checksumB, ids.scanBadAsset, `${tenantId}/assets/worker-scan-bad.pdf`, ids.httpPassAsset, `${tenantId}/assets/worker-http-pass.pdf`, ids.httpFailAsset, `${tenantId}/assets/worker-http-fail.pdf`, ids.httpUnavailableAsset, `${tenantId}/assets/worker-http-unavailable.pdf`, ], ); } async function resetAssetsForHttp(pool) { await pool.query( ` update public.content_assets set status = 'active', upload_status = 'verified', verified_at = now() - interval '2 days', verification_details = '{"source":"asset-worker-test"}'::jsonb, security_scan_status = 'pending', security_scan_provider = 'metadata_rules', security_scan_summary = '{}'::jsonb, security_flags = '{}'::jsonb, updated_at = now() - interval '2 days' where tenant_id = $1 and id = any($2::uuid[]) `, [tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]], ); await pool.query( ` delete from public.content_asset_security_scan_events where tenant_id = $1 and asset_id = any($2::uuid[]) `, [tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]], ); } async function main() { const pool = new pg.Pool({ connectionString: databaseUrl }); let seeded = false; try { await pool.query('begin'); await cleanup(pool); await seed(pool); await pool.query('commit'); seeded = true; const output = await runWorkerOnce(); assert.ok(countFromWorkerOutput(output, 'verified') >= 3, 'worker should verify metadata-safe assets'); assert.ok(countFromWorkerOutput(output, 'failed') >= 2, 'worker should fail metadata and security scan assets'); const assets = await pool.query( ` select id, status, upload_status, verification_details, security_scan_status, security_scan_provider, security_scan_summary, security_flags from public.content_assets where tenant_id = $1 and id = any($2::uuid[]) order by id `, [tenantId, [ids.okAsset, ids.badAsset, ids.scanBadAsset, ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]], ); const okAsset = assets.rows.find(row => row.id === ids.okAsset); const badAsset = assets.rows.find(row => row.id === ids.badAsset); const scanBadAsset = assets.rows.find(row => row.id === ids.scanBadAsset); assert.equal(okAsset?.status, 'active', 'verified asset should remain active'); assert.equal(okAsset?.upload_status, 'verified', 'verified asset should remain verified'); assert.equal(okAsset?.security_scan_status, 'passed', 'verified asset should pass security scan'); assert.equal(okAsset?.security_scan_provider, 'metadata_rules', 'verified asset should record scan provider'); assert.equal(okAsset?.verification_details?.assetWorker?.lastResult, 'verified', 'verified asset should record worker result'); assert.equal(okAsset?.security_flags?.assetRecheckFailed, undefined, 'verified asset should not keep recheck failure flag'); assert.equal(badAsset?.status, 'draft', 'mismatched asset should be unpublished'); assert.equal(badAsset?.upload_status, 'failed', 'mismatched asset should be marked failed'); assert.equal(badAsset?.security_scan_status, 'skipped', 'mismatched asset should skip security scan'); assert.equal(badAsset?.security_flags?.assetRecheckFailed, true, 'mismatched asset should record security flag'); assert.deepEqual( badAsset?.verification_details?.assetWorker?.issues, ['file_size_mismatch'], 'mismatched asset should record exact issue', ); assert.equal(scanBadAsset?.status, 'draft', 'security failed asset should be unpublished'); assert.equal(scanBadAsset?.upload_status, 'verified', 'security failed asset should keep upload verification evidence'); assert.equal(scanBadAsset?.security_scan_status, 'failed', 'security failed asset should record failed scan status'); assert.equal(scanBadAsset?.security_flags?.assetSecurityScanFailed, true, 'security failed asset should record security flag'); assert.ok( scanBadAsset?.security_scan_summary?.issueCodes?.includes('security_scan_forced_failure'), 'security failed asset should record issue code', ); const scanEvents = await pool.query( ` select asset_id, scan_status, risk_level, issue_codes, provider, details from public.content_asset_security_scan_events where tenant_id = $1 and asset_id in ($2::uuid, $3::uuid) order by created_at asc `, [tenantId, ids.okAsset, ids.scanBadAsset], ); assert.ok( scanEvents.rows.some(row => row.asset_id === ids.okAsset && row.scan_status === 'passed' && row.provider === 'metadata_rules'), 'worker should write passed security scan event', ); assert.ok( scanEvents.rows.some(row => row.asset_id === ids.scanBadAsset && row.scan_status === 'failed' && row.issue_codes.includes('security_scan_forced_failure')), 'worker should write failed security scan event', ); const skippedScanEvents = await pool.query( ` select asset_id, scan_status, risk_level, issue_codes, provider, details from public.content_asset_security_scan_events where tenant_id = $1 and asset_id = $2::uuid order by created_at asc `, [tenantId, ids.badAsset], ); assert.ok( skippedScanEvents.rows.some(row => row.scan_status === 'skipped' && row.issue_codes.includes('file_size_mismatch')), 'worker should write skipped scan event when metadata verification fails', ); const audits = await pool.query( ` select action, details from public.audit_logs where tenant_id = $1 and target_type = 'content_asset' and target_id in ($2, $3, $4) order by created_at asc `, [tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset], ); assert.ok( audits.rows.some(row => row.action === 'content.asset.rechecked' && row.details?.result === 'verified'), 'worker should write verified audit log', ); assert.ok( audits.rows.some(row => row.action === 'content.asset.recheck_failed' && row.details?.issues?.includes('file_size_mismatch')), 'worker should write failed audit log', ); assert.ok( audits.rows.some(row => row.action === 'content.asset.security_scan_failed' && row.details?.issueCodes?.includes('security_scan_forced_failure')), 'worker should write failed security scan audit log', ); await resetAssetsForHttp(pool); const scanner = await startScannerServer(); try { const httpOutput = await runWorkerOnce({ WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules,http', WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: scanner.endpoint, WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 'scanner-test-token', WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS: '5000', WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false', }); assert.ok(countFromWorkerOutput(httpOutput, 'verified') >= 1, 'worker should pass externally clean assets'); assert.ok(countFromWorkerOutput(httpOutput, 'failed') >= 1, 'worker should fail externally rejected assets'); assert.equal(scanner.requests.length, 3, 'external scanner should receive metadata-clean assets only'); assert.ok( scanner.requests.every(item => item.authorization === 'Bearer scanner-test-token'), 'external scanner should receive bearer token', ); const httpAssets = await pool.query( ` select id, status, security_scan_status, security_scan_provider, security_scan_summary, security_flags from public.content_assets where tenant_id = $1 and id = any($2::uuid[]) order by id `, [tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]], ); const httpPassAsset = httpAssets.rows.find(row => row.id === ids.httpPassAsset); const httpFailAsset = httpAssets.rows.find(row => row.id === ids.httpFailAsset); const httpUnavailableAsset = httpAssets.rows.find(row => row.id === ids.httpUnavailableAsset); assert.equal(httpPassAsset?.status, 'active', 'external scan pass asset should stay active'); assert.equal(httpPassAsset?.security_scan_status, 'passed', 'external scan pass asset should pass'); assert.equal( httpPassAsset?.security_scan_provider, 'metadata_rules+http_security_scan', 'external scan pass asset should record merged provider', ); assert.equal(httpFailAsset?.status, 'draft', 'external scan failed asset should be unpublished'); assert.equal(httpFailAsset?.security_scan_status, 'failed', 'external scan failed asset should fail'); assert.ok( httpFailAsset?.security_scan_summary?.issueCodes?.includes('virus_detected'), 'external scan failed asset should include provider issue code', ); const scannerDetails = httpFailAsset?.security_scan_summary?.details?.results?.find( result => result.provider === 'http_security_scan', )?.details?.scannerDetails; assert.equal(scannerDetails?.accessToken, '[redacted]', 'scanner details should redact secret-like keys'); assert.equal(httpUnavailableAsset?.security_scan_status, 'passed', 'available scanner should pass clean third asset'); const httpScanEvents = await pool.query( ` select asset_id, scan_status, provider, issue_codes, details from public.content_asset_security_scan_events where tenant_id = $1 and asset_id = any($2::uuid[]) order by created_at asc `, [tenantId, [ids.httpPassAsset, ids.httpFailAsset]], ); assert.ok( httpScanEvents.rows.some(row => row.asset_id === ids.httpPassAsset && row.provider === 'metadata_rules+http_security_scan' && row.scan_status === 'passed'), 'worker should write external pass event', ); assert.ok( httpScanEvents.rows.some(row => row.asset_id === ids.httpFailAsset && row.issue_codes.includes('virus_detected')), 'worker should write external failure event', ); } finally { await scanner.close(); } await resetAssetsForHttp(pool); const unavailableOutput = await runWorkerOnce({ WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules,http', WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: 'http://127.0.0.1:9/scan', WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 'scanner-test-token', WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS: '300', WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false', }); assert.ok(countFromWorkerOutput(unavailableOutput, 'failed') >= 3, 'fail-closed scanner outage should fail assets'); const unavailableAssets = await pool.query( ` select id, status, security_scan_status, security_scan_summary, security_flags from public.content_assets where tenant_id = $1 and id = any($2::uuid[]) `, [tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]], ); for (const row of unavailableAssets.rows) { assert.equal(row.status, 'draft', 'fail-closed scanner outage should unpublish active asset'); assert.equal(row.security_scan_status, 'failed', 'fail-closed scanner outage should fail scan'); assert.ok( row.security_scan_summary?.issueCodes?.includes('external_security_scan_unavailable'), 'fail-closed scanner outage should record unavailable issue', ); assert.equal(row.security_flags?.assetSecurityScanFailed, true, 'fail-closed scanner outage should record security flag'); } console.log('Asset worker integration test complete.'); } catch (error) { await pool.query('rollback').catch(() => {}); throw error; } finally { if (seeded) { await cleanup(pool).catch(() => {}); } await pool.end(); } } main().catch(error => { console.error(error); process.exit(1); });