Files
gongxue-base/scripts/asset-worker-integration-test.js
2026-06-29 20:00:17 +08:00

472 lines
19 KiB
JavaScript

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);
});