Files
gongxue-base/scripts/rls-tenant-isolation-test.js
2026-07-12 19:26:57 +08:00

809 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import pg from 'pg';
import {
assertDestructiveTestDatabase,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
const { Pool } = pg;
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const destructiveTestConfirmation = resolveDestructiveTestConfirmation();
const ids = {
mainTenant: '00000000-0000-0000-0000-000000000001',
partnerTenant: '00000000-0000-0000-0000-000000000901',
normalAuthUser: '00000000-0000-0000-0000-00000000a101',
platformAdminAuthUser: '00000000-0000-0000-0000-00000000a999',
partnerAdminUser: '00000000-0000-0000-0000-000000000907',
};
const readChecks = [
{
name: 'tenant_branding',
sql: 'select tenant_id::text as tenant_id, brand_name as label from public.tenant_branding order by tenant_id',
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'tenant_settings',
sql: 'select tenant_id::text as tenant_id, public_config::text as label from public.tenant_settings order by tenant_id',
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'tenant_domains',
sql: 'select tenant_id::text as tenant_id, host as label from public.tenant_domains order by tenant_id',
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'tenant_memberships',
sql: 'select tenant_id::text as tenant_id, role as label from public.tenant_memberships order by tenant_id, role',
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'regions',
sql: 'select tenant_id::text as tenant_id, name as label from public.regions order by tenant_id',
requiredTenantIds: [ids.mainTenant],
},
{
name: 'questions',
sql: 'select tenant_id::text as tenant_id, legacy_id as label from public.questions order by tenant_id, id',
requiredTenantIds: [ids.mainTenant],
},
{
name: 'student_profiles',
sql: 'select tenant_id::text as tenant_id, user_id::text as label from public.student_profiles order by tenant_id, user_id',
requiredTenantIds: [ids.mainTenant],
},
{
name: 'orders',
sql: 'select tenant_id::text as tenant_id, order_no as label from public.orders order by tenant_id',
requiredTenantIds: [ids.mainTenant],
},
{
name: 'content_assets',
sql: 'select tenant_id::text as tenant_id, asset_key as label from public.content_assets order by tenant_id',
requiredTenantIds: [ids.mainTenant],
},
{
name: 'tenant_subscriptions',
sql: 'select tenant_id::text as tenant_id, plan_code as label from public.tenant_subscriptions order by tenant_id',
requiredTenantIds: [ids.partnerTenant],
},
{
name: 'tenant_invoices',
sql: 'select tenant_id::text as tenant_id, invoice_no as label from public.tenant_invoices order by tenant_id',
requiredTenantIds: [ids.partnerTenant],
},
{
name: 'tenant_usage_records',
sql: 'select tenant_id::text as tenant_id, metric_key as label from public.tenant_usage_records order by tenant_id, metric_key',
requiredTenantIds: [ids.partnerTenant],
},
];
const writeChecks = [
{
name: 'tenant_branding cross-tenant update',
setupRole: 'authenticated',
tenantId: ids.mainTenant,
roleClaim: 'authenticated',
sql: `
update public.tenant_branding
set slogan = slogan
where tenant_id = $1::uuid
returning tenant_id::text
`,
params: [ids.partnerTenant],
},
{
name: 'regions cross-tenant insert',
setupRole: 'authenticated',
tenantId: ids.mainTenant,
roleClaim: 'authenticated',
sql: `
insert into public.regions (tenant_id, legacy_id, name, code, sort_order, is_active)
values ($1::uuid, 'rls-cross-tenant-probe', 'RLS 越权探针', 'RLS-X', 9999, false)
returning tenant_id::text
`,
params: [ids.partnerTenant],
expectErrorCode: '42501',
},
];
const probeGrantStatements = [
'grant usage on schema app to authenticated, anon',
'grant usage on schema public to authenticated, anon',
`grant select on
public.tenant_branding,
public.tenant_settings,
public.tenant_domains,
public.tenant_memberships,
public.regions,
public.questions,
public.student_profiles,
public.orders,
public.content_assets,
public.tenant_subscriptions,
public.tenant_invoices,
public.tenant_usage_records
to authenticated, anon`,
'grant update on public.tenant_branding to authenticated',
'grant insert on public.regions to authenticated',
];
const pool = new Pool({ connectionString: databaseUrl });
const results = [];
function pass(name, detail = {}) {
results.push({ status: 'pass', name, detail });
console.log(`PASS ${name}`);
}
function fail(name, message, detail = {}) {
results.push({ status: 'fail', name, message, detail });
console.error(`FAIL ${name}: ${message}`);
if (Object.keys(detail).length > 0) console.error(JSON.stringify(detail, null, 2));
}
function assert(condition, name, message, detail = {}) {
if (condition) pass(name, detail);
else fail(name, message, detail);
}
function onlyTenantRows(rows, tenantId) {
return rows.every(row => row.tenant_id === tenantId);
}
function tenantRows(rows, tenantId) {
return rows.filter(row => row.tenant_id === tenantId);
}
function canonicalRows(rows) {
return rows.map(row => JSON.stringify(row)).sort();
}
function sameRows(actual, expected) {
return JSON.stringify(canonicalRows(actual)) === JSON.stringify(canonicalRows(expected));
}
async function withRlsContext(
client,
{ dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' },
action,
extraGrantStatements = [],
) {
await client.query('begin');
try {
for (const statement of [...probeGrantStatements, ...extraGrantStatements]) await client.query(statement);
await client.query(`set local role ${dbRole}`);
if (tenantId) {
await client.query("select set_config('request.jwt.claim.tenant_id', $1, true)", [tenantId]);
} else {
await client.query("select set_config('request.jwt.claim.tenant_id', '', true)");
}
await client.query("select set_config('request.jwt.claim.role', $1, true)", [roleClaim]);
await client.query("select set_config('request.jwt.claim.app_role', $1, true)", [roleClaim]);
if (sub) await client.query("select set_config('request.jwt.claim.sub', $1, true)", [sub]);
const value = await action();
await client.query('rollback');
return value;
} catch (error) {
await client.query('rollback').catch(() => {});
throw error;
}
}
async function queryAs(client, context, sql, params = []) {
return withRlsContext(client, context, async () => {
const result = await client.query(sql, params);
return result.rows;
});
}
async function captureReadBaselines(client) {
const baselines = new Map();
for (const check of readChecks) {
const result = await client.query(check.sql);
baselines.set(check.name, result.rows);
}
return baselines;
}
async function runReadIsolationChecks(client, baselines) {
for (const check of readChecks) {
const baselineRows = baselines.get(check.name) || [];
const expectedMainRows = tenantRows(baselineRows, ids.mainTenant);
const mainRows = await queryAs(client, { tenantId: ids.mainTenant }, check.sql);
assert(
onlyTenantRows(mainRows, ids.mainTenant),
`rls.read.${check.name}.main_no_leak`,
'主租户上下文不应看到其它租户数据',
{ rows: mainRows },
);
assert(
sameRows(mainRows, expectedMainRows),
`rls.read.${check.name}.main_expected_seed`,
'主租户上下文应返回基线快照中该租户的完整数据子集',
{ expectedRows: expectedMainRows, rows: mainRows },
);
const expectedPartnerRows = tenantRows(baselineRows, ids.partnerTenant);
const partnerRows = await queryAs(client, { tenantId: ids.partnerTenant }, check.sql);
assert(
onlyTenantRows(partnerRows, ids.partnerTenant),
`rls.read.${check.name}.partner_no_leak`,
'伙伴租户上下文不应看到其它租户数据',
{ rows: partnerRows },
);
assert(
sameRows(partnerRows, expectedPartnerRows),
`rls.read.${check.name}.partner_expected_seed`,
'伙伴租户上下文应返回基线快照中该租户的完整数据子集',
{ expectedRows: expectedPartnerRows, rows: partnerRows },
);
const anonymousRows = await queryAs(client, { dbRole: 'anon', tenantId: '', roleClaim: 'anon' }, check.sql);
assert(
anonymousRows.length === 0,
`rls.read.${check.name}.no_tenant_claim_empty`,
'没有 tenant_id claim 的上下文不应看到租户数据',
{ rows: anonymousRows },
);
}
}
async function runPlatformAdminChecks(client, baselines) {
for (const check of readChecks) {
const rows = await queryAs(
client,
{ tenantId: '', roleClaim: 'platform_admin', sub: ids.platformAdminAuthUser },
check.sql,
);
const baselineRows = baselines.get(check.name) || [];
assert(
sameRows(rows, baselineRows),
`rls.platform_admin.${check.name}.cross_tenant_visibility`,
'平台管理员 RLS 旁路应返回当前表的完整基线快照',
{ expectedRows: baselineRows, rows },
);
}
}
async function runForgedPlatformAdminChecks(client, baselines) {
for (const check of readChecks) {
const rows = await queryAs(
client,
{
tenantId: ids.mainTenant,
roleClaim: 'platform_admin',
sub: ids.normalAuthUser,
},
check.sql,
);
assert(
onlyTenantRows(rows, ids.mainTenant),
`rls.forged_platform_admin.${check.name}.no_cross_tenant_leak`,
'伪造 platform_admin claim 的普通用户不应获得跨租户可见性',
{ rows },
);
const expectedMainRows = tenantRows(baselines.get(check.name) || [], ids.mainTenant);
assert(
sameRows(rows, expectedMainRows),
`rls.forged_platform_admin.${check.name}.tenant_scope_preserved`,
'伪造 platform_admin claim 后仍应按普通租户上下文执行 RLS',
{ expectedRows: expectedMainRows, rows },
);
}
}
async function runPlatformPrivilegeEscalationCheck(client) {
const result = await withRlsContext(
client,
{
tenantId: ids.mainTenant,
roleClaim: 'platform_admin',
sub: ids.normalAuthUser,
},
async () => {
const before = await client.query(
`
select auth_user_id::text, primary_role, status, platform_permissions
from public.platform_users
where auth_user_id = $1::uuid
`,
[ids.normalAuthUser],
);
const update = await client.query(
`
update public.platform_users
set primary_role = 'platform_admin',
status = 'disabled',
platform_permissions = '{"*":true}'::jsonb
where auth_user_id = $1::uuid
`,
[ids.normalAuthUser],
);
const after = await client.query(
`
select auth_user_id::text, primary_role, status, platform_permissions
from public.platform_users
where auth_user_id = $1::uuid
`,
[ids.normalAuthUser],
);
return { before: before.rows, updateRowCount: update.rowCount, after: after.rows };
},
['grant select, update on public.platform_users to authenticated'],
);
assert(
result.before.length === 1,
'rls.platform_users.normal_user_self_read_probe',
'权限提升探针需要能读取普通用户自身记录',
result,
);
assert(
result.updateRowCount === 0,
'rls.platform_users.self_privilege_escalation_blocked',
'即使误授 authenticated UPDATERLS 也不应允许用户将自身提升为平台超管',
result,
);
assert(
result.after.length === 1
&& result.after[0].primary_role === result.before[0].primary_role
&& result.after[0].status === result.before[0].status
&& JSON.stringify(result.after[0].platform_permissions) === JSON.stringify(result.before[0].platform_permissions),
'rls.platform_users.sensitive_fields_unchanged',
'普通用户的角色、状态和平台权限字段不应被客户端修改',
result,
);
}
async function runWriteIsolationChecks(client) {
for (const check of writeChecks) {
try {
const rows = await queryAs(
client,
{ dbRole: check.setupRole, tenantId: check.tenantId, roleClaim: check.roleClaim },
check.sql,
check.params,
);
assert(
rows.length === 0,
`rls.write.${check.name}`,
'跨租户写入不应成功返回任何行',
{ rows },
);
} catch (error) {
if (check.expectErrorCode && error.code === check.expectErrorCode) {
pass(`rls.write.${check.name}`, { expectedErrorCode: error.code });
} else {
fail(`rls.write.${check.name}`, error.message, { code: error.code });
}
}
}
}
async function runQuestionVersionIntegrityChecks(client) {
const constraints = await client.query(
`
select conname, convalidated
from pg_constraint
where conrelid in ('public.questions'::regclass, 'public.question_versions'::regclass)
and conname = any($1::text[])
`,
[[
'questions_tenant_id_id_key',
'question_versions_tenant_question_id_id_key',
'question_versions_tenant_question_fkey',
'questions_tenant_current_version_fkey',
]],
);
const constraintState = new Map(
constraints.rows.map(row => [row.conname, row.convalidated === true]),
);
for (const name of [
'questions_tenant_id_id_key',
'question_versions_tenant_question_id_id_key',
'question_versions_tenant_question_fkey',
'questions_tenant_current_version_fkey',
]) {
assert(
constraintState.get(name) === true,
`schema.question_versions.${name}.validated`,
'Question version tenant integrity constraints must exist and be validated',
{ constraints: constraints.rows },
);
}
await client.query('begin');
try {
const questionA = await client.query(
`
insert into public.questions (tenant_id, legacy_id, type, status)
values ($1, $2, 'choice', 'draft')
returning id
`,
[ids.mainTenant, `question-integrity-a-${Date.now()}`],
);
const questionB = await client.query(
`
insert into public.questions (tenant_id, legacy_id, type, status)
values ($1, $2, 'choice', 'draft')
returning id
`,
[ids.mainTenant, `question-integrity-b-${Date.now()}`],
);
const versionA = await client.query(
`
insert into public.question_versions (tenant_id, question_id, version_no, content)
values ($1, $2, 1, 'question integrity probe')
returning id
`,
[ids.mainTenant, questionA.rows[0].id],
);
await client.query('savepoint cross_tenant_version');
try {
await client.query(
`
insert into public.question_versions (tenant_id, question_id, version_no, content)
values ($1, $2, 2, 'must be rejected')
`,
[ids.partnerTenant, questionA.rows[0].id],
);
fail(
'schema.question_versions.cross_tenant_parent_rejected',
'A question version must not reference a question from another tenant',
);
} catch (error) {
assert(
error.code === '23503',
'schema.question_versions.cross_tenant_parent_rejected',
'Cross-tenant question version insertion must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint cross_tenant_version');
}
await client.query('savepoint cross_question_pointer');
try {
await client.query(
'update public.questions set current_version_id = $1 where id = $2',
[versionA.rows[0].id, questionB.rows[0].id],
);
fail(
'schema.questions.cross_question_current_version_rejected',
'A question must not point at another question\'s current version',
);
} catch (error) {
assert(
error.code === '23503',
'schema.questions.cross_question_current_version_rejected',
'Cross-question current version assignment must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint cross_question_pointer');
}
const validPointer = await client.query(
'update public.questions set current_version_id = $1 where id = $2 returning id',
[versionA.rows[0].id, questionA.rows[0].id],
);
assert(
validPointer.rowCount === 1,
'schema.questions.same_question_current_version_allowed',
'A question must be able to reference its own version in the same tenant',
);
} finally {
await client.query('rollback').catch(() => {});
}
}
async function runCoreTenantForeignKeyIntegrityChecks(client) {
const constraintNames = [
'question_versions_tenant_id_id_key',
'practice_sessions_tenant_id_id_key',
'orders_tenant_id_id_key',
'content_assets_tenant_id_id_key',
'practice_sessions_tenant_user_id_key',
'answer_records_question_version_requires_question_check',
'answer_records_tenant_question_fkey',
'answer_records_tenant_question_version_pair_fkey',
'answer_records_tenant_user_session_fkey',
'favorite_questions_tenant_question_fkey',
'wrong_questions_tenant_question_fkey',
'payments_tenant_order_fkey',
'content_export_jobs_tenant_asset_fkey',
];
const constraints = await client.query(
`
select conname, convalidated
from pg_constraint
where conname = any($1::text[])
`,
[constraintNames],
);
const constraintState = new Map(
constraints.rows.map(row => [row.conname, row.convalidated === true]),
);
for (const name of constraintNames) {
assert(
constraintState.get(name) === true,
`schema.core_tenant_foreign_keys.${name}.validated`,
'Core tenant foreign key constraints must exist and be validated',
{ constraints: constraints.rows },
);
}
await client.query('begin');
try {
const parent = await client.query(
`
select
version.question_id as "questionId",
version.id as "versionId",
session.id as "sessionId",
session.user_id as "sessionUserId",
(select o.id from public.orders o where o.tenant_id = $1 order by o.id limit 1) as "orderId",
(select a.id from public.content_assets a where a.tenant_id = $1 order by a.id limit 1) as "assetId"
from lateral (
select v.id, v.question_id
from public.question_versions v
where v.tenant_id = $1
order by v.id
limit 1
) version
cross join lateral (
select s.id, s.user_id
from public.practice_sessions s
where s.tenant_id = $1
order by s.id
limit 1
) session
`,
[ids.mainTenant],
);
const references = parent.rows[0];
assert(
Object.values(references || {}).every(Boolean),
'schema.core_tenant_foreign_keys.parent_fixtures_available',
'Core tenant foreign key probes require main-tenant parent fixtures',
{ references },
);
const probes = [
{
name: 'answer_question',
sql: `insert into public.answer_records (tenant_id, user_id, question_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
},
{
name: 'answer_question_version',
sql: `insert into public.answer_records (tenant_id, user_id, question_id, question_version_id)
values ($1, $2, $3, $4)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId, references.versionId],
},
{
name: 'answer_practice_session',
sql: `insert into public.answer_records (tenant_id, user_id, practice_session_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.sessionId],
},
{
name: 'favorite_question',
sql: `insert into public.favorite_questions (tenant_id, user_id, question_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
},
{
name: 'wrong_question',
sql: `insert into public.wrong_questions (tenant_id, user_id, question_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
},
{
name: 'payment_order',
sql: `insert into public.payments (tenant_id, order_id, provider, amount_cents)
values ($1, $2, 'rls-integrity-probe', 1)`,
params: [ids.partnerTenant, references.orderId],
},
{
name: 'export_asset',
sql: `insert into public.content_export_jobs (
tenant_id, export_type, format, scope_type, scope_id, asset_id
) values ($1, 'questions', 'json', 'entry', gen_random_uuid(), $2)`,
params: [ids.partnerTenant, references.assetId],
},
];
for (const probe of probes) {
const savepoint = `core_tenant_fk_${probe.name}`;
await client.query(`savepoint ${savepoint}`);
try {
await client.query(probe.sql, probe.params);
fail(
`schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`,
'Cross-tenant parent references must be rejected',
);
} catch (error) {
assert(
error.code === '23503',
`schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`,
'Cross-tenant parent references must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query(`rollback to savepoint ${savepoint}`);
}
}
const validAnswer = await client.query(
`
insert into public.answer_records (
tenant_id, user_id, question_id, question_version_id, practice_session_id
)
values ($1, $2, $3, $4, $5)
returning id
`,
[ids.mainTenant, references.sessionUserId, references.questionId, references.versionId, references.sessionId],
);
assert(
validAnswer.rowCount === 1,
'schema.core_tenant_foreign_keys.same_tenant_answer_allowed',
'A same-tenant answer record must remain writable after composite foreign keys',
);
const differentQuestion = await client.query(
`
select id
from public.questions
where tenant_id = $1 and id <> $2
order by id
limit 1
`,
[ids.mainTenant, references.questionId],
);
assert(
Boolean(differentQuestion.rows[0]?.id),
'schema.core_tenant_foreign_keys.different_question_fixture_available',
'Answer version-question integrity probe requires a second question',
);
await client.query('savepoint answer_version_question_pair');
try {
await client.query(
`
insert into public.answer_records (
tenant_id, user_id, question_id, question_version_id
) values ($1, $2, $3, $4)
`,
[ids.mainTenant, '00000000-0000-0000-0000-000000000101', differentQuestion.rows[0].id, references.versionId],
);
fail(
'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected',
'An answer must not combine a question with another question\'s version',
);
} catch (error) {
assert(
error.code === '23503',
'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected',
'Mismatched answer question/version pairs must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint answer_version_question_pair');
}
const alternateUser = await client.query(
`
select user_id as "userId"
from public.tenant_memberships
where tenant_id = $1 and user_id <> $2
order by user_id
limit 1
`,
[ids.mainTenant, references.sessionUserId],
);
assert(
Boolean(alternateUser.rows[0]?.userId),
'schema.core_tenant_foreign_keys.alternate_session_user_fixture_available',
'Answer session-user integrity probe requires another tenant user',
);
await client.query('savepoint answer_session_user_pair');
try {
await client.query(
`
insert into public.answer_records (
tenant_id, user_id, practice_session_id
) values ($1, $2, $3)
`,
[ids.mainTenant, alternateUser.rows[0].userId, references.sessionId],
);
fail(
'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected',
'An answer must not reference another user\'s practice session',
);
} catch (error) {
assert(
error.code === '23503',
'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected',
'Mismatched answer user/session pairs must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint answer_session_user_pair');
}
} finally {
await client.query('rollback').catch(() => {});
}
}
async function verifySeed(client, baselines) {
const result = await client.query(
`
select tenant_id::text, count(*)::int as count
from public.tenant_branding
where tenant_id in ($1::uuid, $2::uuid)
group by tenant_id
`,
[ids.mainTenant, ids.partnerTenant],
);
const counts = new Map(result.rows.map(row => [row.tenant_id, Number(row.count)]));
assert(
counts.get(ids.mainTenant) === 1 && counts.get(ids.partnerTenant) === 1,
'rls.seed.main_and_partner_tenants',
'需要先运行 npm run db:smoke-seed确保主租户和伙伴租户 seed 都存在',
{ rows: result.rows },
);
for (const check of readChecks) {
const rows = baselines.get(check.name) || [];
for (const tenantId of check.requiredTenantIds) {
const fixtures = tenantRows(rows, tenantId);
assert(
fixtures.length > 0,
`rls.seed.${check.name}.${tenantId === ids.mainTenant ? 'main' : 'partner'}_fixture`,
'RLS 深测表必须有明确的非空租户夹具,避免空快照假通过',
{ tenantId, rows },
);
}
}
}
async function main() {
const client = await pool.connect();
try {
await assertDestructiveTestDatabase({
client,
databaseUrl,
confirmation: destructiveTestConfirmation,
operation: 'RLS tenant isolation test',
});
const baselines = await captureReadBaselines(client);
await verifySeed(client, baselines);
await runReadIsolationChecks(client, baselines);
await runPlatformAdminChecks(client, baselines);
await runForgedPlatformAdminChecks(client, baselines);
await runPlatformPrivilegeEscalationCheck(client);
await runWriteIsolationChecks(client);
await runQuestionVersionIntegrityChecks(client);
await runCoreTenantForeignKeyIntegrityChecks(client);
} finally {
client.release();
await pool.end();
}
const failed = results.filter(item => item.status === 'fail');
console.log(`\nRLS tenant isolation checks: ${results.length - failed.length} passed, ${failed.length} failed.`);
if (failed.length > 0) process.exitCode = 1;
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});