Files
gongxue-base/scripts/lib/destructive-test-database-guard.js
2026-07-12 19:26:57 +08:00

149 lines
4.3 KiB
JavaScript

export const DESTRUCTIVE_TEST_CONFIRMATION = 'SMOKE_SEED_LOCAL_OR_CI_ONLY';
const ALLOWED_ENVIRONMENTS = new Set(['local', 'test', 'ci']);
const KNOWN_PRODUCTION_DATABASE_USERS = new Set(['tiku_api', 'tiku_worker']);
function argumentValue(argv, name) {
const directIndex = argv.indexOf(name);
if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim();
const prefix = `${name}=`;
return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim();
}
export function resolveDestructiveTestConfirmation(
env = process.env,
argv = process.argv.slice(2),
) {
return argumentValue(argv, '--confirm') || String(env.SMOKE_SEED_CONFIRM || '').trim();
}
export function describeDatabaseTarget(databaseUrl) {
if (!databaseUrl || typeof databaseUrl !== 'string') {
throw new Error('DATABASE_URL is required');
}
let parsed;
try {
parsed = new URL(databaseUrl);
} catch {
throw new Error('DATABASE_URL must be a valid PostgreSQL URL');
}
if (!['postgres:', 'postgresql:'].includes(parsed.protocol)) {
throw new Error('DATABASE_URL must use the postgres or postgresql protocol');
}
if (!parsed.hostname || !parsed.pathname || parsed.pathname === '/') {
throw new Error('DATABASE_URL must include a host and database name');
}
return {
host: parsed.hostname,
port: parsed.port || '5432',
database: decodeURIComponent(parsed.pathname.slice(1)),
user: decodeURIComponent(parsed.username || ''),
};
}
function targetText(target, environment = 'unavailable') {
const safe = value => String(value || '[missing]')
.replace(/[\u0000-\u001f\u007f\s]+/g, '_')
.slice(0, 160);
return [
`host=${safe(target.host)}`,
`port=${safe(target.port)}`,
`database=${safe(target.database)}`,
`user=${safe(target.user)}`,
`databaseEnvironment=${safe(environment)}`,
].join(' ');
}
function refusal(operation, reason, target, environment) {
return new Error(
`Refusing ${operation}: ${reason}. Target: ${targetText(target, environment)}`,
);
}
function knownProductionReason(target) {
const user = target.user.toLowerCase();
const host = target.host.toLowerCase();
if (KNOWN_PRODUCTION_DATABASE_USERS.has(user)) {
return 'database user is reserved for production runtime';
}
if (host === 'tjszsb.com' || host.endsWith('.tjszsb.com')) {
return 'database host belongs to the production domain';
}
return '';
}
export async function assertDestructiveTestDatabase({
client,
databaseUrl,
confirmation = resolveDestructiveTestConfirmation(),
operation = 'destructive database test',
} = {}) {
let target;
try {
target = describeDatabaseTarget(databaseUrl);
} catch (error) {
throw new Error(`Refusing ${operation}: ${error.message}`);
}
if (confirmation !== DESTRUCTIVE_TEST_CONFIRMATION) {
throw refusal(
operation,
`explicit confirmation ${DESTRUCTIVE_TEST_CONFIRMATION} is required`,
target,
);
}
const productionReason = knownProductionReason(target);
if (productionReason) {
throw refusal(operation, productionReason, target);
}
if (!client || typeof client.query !== 'function') {
throw refusal(operation, 'a connected PostgreSQL client is required', target);
}
let result;
try {
result = await client.query(
`
select environment,
allow_destructive_tests as "allowDestructiveTests"
from app_private.environment_safety
where id = true
limit 1
`,
);
} catch {
throw refusal(
operation,
'database safety marker is unavailable or unreadable',
target,
);
}
const marker = result?.rows?.[0];
const environment = String(marker?.environment || 'missing').toLowerCase();
if (!marker) {
throw refusal(operation, 'database safety marker row is missing', target, environment);
}
if (!ALLOWED_ENVIRONMENTS.has(environment)) {
throw refusal(
operation,
`database environment ${environment} is not approved for destructive tests`,
target,
environment,
);
}
if (marker.allowDestructiveTests !== true) {
throw refusal(
operation,
'database marker does not allow destructive tests',
target,
environment,
);
}
return { target, environment, allowDestructiveTests: true };
}