Files
gongxue-base/scripts/tenant-foreign-key-audit.js
2026-07-12 19:26:57 +08:00

129 lines
5.1 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import pg from 'pg';
import {
assertDestructiveTestDatabase,
describeDatabaseTarget,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
import {
TENANT_FOREIGN_KEY_AUDIT_KIND,
auditTenantForeignKeyData,
loadTenantForeignKeyRelations,
summarizeTenantForeignKeySchema,
tenantForeignKeyExceptions,
} from './lib/tenant-foreign-key-audit.js';
const { Client } = pg;
const BLOCKED_TARGET_PATTERN = /(?:^|[-_.])tikupro(?:-pg)?(?:$|[-_.])/i;
function argumentValue(argv, name) {
const index = argv.indexOf(name);
if (index >= 0) return String(argv[index + 1] || '').trim();
const prefix = `${name}=`;
const item = argv.find(value => value.startsWith(prefix));
return item ? item.slice(prefix.length).trim() : '';
}
export function parseTenantForeignKeyAuditOptions(argv = process.argv.slice(2), env = process.env) {
const databaseUrl = String(env.DATABASE_URL || '').trim();
if (!databaseUrl) throw new Error('DATABASE_URL is required');
const timeoutValue = argumentValue(argv, '--statement-timeout-ms') || env.TENANT_FK_AUDIT_STATEMENT_TIMEOUT_MS || '120000';
const statementTimeoutMs = Number(timeoutValue);
if (!Number.isInteger(statementTimeoutMs) || statementTimeoutMs < 1_000 || statementTimeoutMs > 900_000) {
throw new Error('statement timeout must be an integer between 1000 and 900000 milliseconds');
}
return {
databaseUrl,
statementTimeoutMs,
confirmation: resolveDestructiveTestConfirmation(env, argv),
json: argv.includes('--json'),
quiet: argv.includes('--quiet'),
writePath: argumentValue(argv, '--write'),
};
}
export function assertTenantForeignKeyAuditTarget(databaseUrl) {
const target = describeDatabaseTarget(databaseUrl);
const host = target.host.replace(/^\[(.*)\]$/, '$1').toLowerCase();
if (['127.0.0.1', 'localhost', '::1'].includes(host) && target.port === '5432') {
throw new Error('Refusing tenant foreign key audit: local port 5432 is reserved for tikupro-pg');
}
if ([target.host, target.database, target.user].some(value => BLOCKED_TARGET_PATTERN.test(value))) {
throw new Error('Refusing tenant foreign key audit: tikupro-pg targets are forbidden');
}
return target;
}
export async function runTenantForeignKeyAudit(options) {
const startedAt = new Date();
const target = assertTenantForeignKeyAuditTarget(options.databaseUrl);
const client = new Client({
connectionString: options.databaseUrl,
application_name: 'tiku-tenant-foreign-key-audit',
});
await client.connect();
try {
const safety = await assertDestructiveTestDatabase({
client,
databaseUrl: options.databaseUrl,
confirmation: options.confirmation,
operation: 'tenant foreign key full-data audit on an isolated clone',
});
const relations = await loadTenantForeignKeyRelations(client);
const schema = summarizeTenantForeignKeySchema(relations);
const violations = schema.schemaMatches
? await auditTenantForeignKeyData(client, relations, options.statementTimeoutMs)
: [];
const completedAt = new Date();
return {
schemaVersion: 1,
kind: TENANT_FOREIGN_KEY_AUDIT_KIND,
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
durationMs: completedAt.getTime() - startedAt.getTime(),
target,
safety: { databaseEnvironment: safety.environment },
schema,
exceptions: tenantForeignKeyExceptions(),
data: {
auditedRelations: schema.schemaMatches ? relations.length : 0,
invalidRelations: violations.length,
violations,
},
status: schema.schemaMatches && violations.length === 0 ? 'pass' : 'fail',
};
} finally {
await client.end();
}
}
async function main() {
let options;
try {
options = parseTenantForeignKeyAuditOptions();
const artifact = await runTenantForeignKeyAudit(options);
if (options.writePath) {
const outputPath = path.resolve(process.cwd(), options.writePath);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
}
if (options.json) console.log(JSON.stringify(artifact, null, 2));
else if (!options.quiet) {
console.log(`Tenant foreign key audit: ${artifact.status.toUpperCase()}`);
console.log(`Relations: ${artifact.schema.relationCount}; exceptions: ${artifact.schema.exceptionCount}; invalid: ${artifact.data.invalidRelations}`);
if (options.writePath) console.log(`Artifact: ${path.resolve(process.cwd(), options.writePath)}`);
}
if (artifact.status !== 'pass') process.exitCode = 1;
} catch (error) {
const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) };
if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2));
else console.error(failure.error);
process.exitCode = 1;
}
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();