forked from wangziqi/gongxue-base
129 lines
5.1 KiB
JavaScript
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();
|