PSA/server/test-utils/dbReset.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

263 lines
7.8 KiB
TypeScript

import knex, { Knex } from 'knex';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { verifyTestDatabase } from './dbConfig';
import { getSecret } from '../src/lib/utils/getSecret';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const serverRoot = path.resolve(__dirname, '..');
/**
* Options for database reset
*/
export interface DbResetOptions {
/**
* Tables to clean up after reset (will be deleted in order)
* Useful for tables that aren't dropped by schema reset
*/
cleanupTables?: string[];
/**
* Whether to run seeds after migrations
* @default true
*/
runSeeds?: boolean;
/**
* Custom SQL commands to run after schema reset but before migrations
* Useful for setting up test-specific database state
*/
preSetupCommands?: string[];
/**
* Custom SQL commands to run after migrations and seeds
* Useful for additional test setup
*/
postSetupCommands?: string[];
}
/**
* Resets the database to a clean state
* @param db Knex database instance
* @param options Reset options
*/
export async function resetDatabase(
db: Knex,
options: DbResetOptions = {}
): Promise<void> {
const {
cleanupTables = [],
runSeeds = true,
preSetupCommands = [],
postSetupCommands = []
} = options;
try {
const clientConfig = db.client.config;
const originalConnection = clientConfig.connection;
if (!originalConnection || typeof originalConnection === 'string') {
throw new Error('Expected object connection configuration for test database');
}
const connectionConfig = { ...originalConnection } as Record<string, any>;
const targetDatabase = connectionConfig.database as string | undefined;
if (!targetDatabase) {
throw new Error('Test database connection must specify a database name');
}
const adminPassword = await getSecret('postgres_password', 'DB_PASSWORD_ADMIN', 'test_password');
if (!connectionConfig.password) {
const candidatePassword = (clientConfig.connection as Record<string, any> | undefined)?.password;
connectionConfig.password = candidatePassword ?? adminPassword;
}
verifyTestDatabase(targetDatabase);
const adminUser = process.env.DB_USER_ADMIN || 'postgres';
const appUser = process.env.DB_USER_SERVER || 'app_user';
const appPassword = await getSecret('db_password_server', 'DB_PASSWORD_SERVER', adminPassword);
const adminDb = knex({
client: clientConfig.client,
asyncStackTraces: true,
connection: {
...connectionConfig,
user: adminUser,
database: 'postgres',
password: adminPassword,
},
pool: {
min: 1,
max: 2,
},
});
const safeDatabaseName = targetDatabase.replace(/"/g, '""');
const safeRoleName = sanitizeSqlIdentifier(appUser);
try {
// Tear down any existing connections from the main pool before recycling the database
await db.destroy().catch(() => undefined);
// Terminate lingering sessions that might block DROP DATABASE
await adminDb.raw(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = ?
AND pid <> pg_backend_pid()
AND state <> 'terminated'`,
[targetDatabase]
);
await adminDb.raw(`DROP DATABASE IF EXISTS "${safeDatabaseName}"`);
await adminDb.raw(`CREATE DATABASE "${safeDatabaseName}"`);
// Ensure the app role exists and can connect. (TestContext uses the app user for migrations/seeds.)
await adminDb.raw(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${safeRoleName}') THEN
CREATE ROLE ${safeRoleName} WITH LOGIN PASSWORD '${escapeSqlLiteral(appPassword)}';
ELSE
ALTER ROLE ${safeRoleName} WITH LOGIN PASSWORD '${escapeSqlLiteral(appPassword)}';
END IF;
END;
$$;`);
await adminDb.raw(`ALTER DATABASE "${safeDatabaseName}" OWNER TO ${safeRoleName}`);
await adminDb.raw(`GRANT ALL PRIVILEGES ON DATABASE "${safeDatabaseName}" TO ${safeRoleName}`);
// Some migrations assume they can `ALTER ... OWNER TO postgres`. If they are executed
// under the app role in certain environments, make the app role a member of the
// admin role (typically `postgres`) for test resets only.
if (adminUser !== safeRoleName) {
await adminDb.raw(`GRANT ${sanitizeSqlIdentifier(adminUser)} TO ${safeRoleName}`);
}
} finally {
await adminDb.destroy();
}
const refreshedDb = knex({
...clientConfig,
asyncStackTraces: true,
connection: {
...connectionConfig,
user: adminUser,
password: adminPassword,
},
migrations: {
directory: path.join(serverRoot, 'migrations'),
},
seeds: {
directory: path.join(serverRoot, 'seeds', 'dev'),
},
});
try {
for (const command of preSetupCommands) {
await refreshedDb.raw(command);
}
await refreshedDb.migrate.latest();
if (runSeeds) {
await refreshedDb.seed.run();
}
for (const command of postSetupCommands) {
await refreshedDb.raw(command);
}
} finally {
await refreshedDb.destroy().catch(() => undefined);
}
// Clean up specified tables
// for (const table of cleanupTables) {
// await db(table).del();
// }
} catch (error) {
console.error('Error resetting database:', error);
throw error;
}
}
function sanitizeSqlIdentifier(identifier: string): string {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
throw new Error(`Unsafe SQL identifier: ${identifier}`);
}
return identifier;
}
function escapeSqlLiteral(value: string): string {
return value.replace(/'/g, "''");
}
/**
* Cleans up specific tables in reverse order
* Useful for cleaning up related tables with foreign key constraints
* @param db Knex database instance
* @param tables Tables to clean up (will be processed in reverse order)
* @param options Options for cleanup
*/
export async function cleanupTables(
db: Knex,
tables: string[],
options: {
/**
* Whether to ignore errors during cleanup
* @default false
*/
ignoreErrors?: boolean;
} = {}
): Promise<void> {
const { ignoreErrors = false } = options;
// Process tables in reverse order to handle foreign key dependencies
for (const table of [...tables].reverse()) {
try {
await db(table).del();
} catch (error) {
if (!ignoreErrors) {
throw error;
}
console.warn(`Warning: Failed to clean up table ${table}:`, error);
}
}
}
/**
* Creates a transaction-safe database reset function
* Useful for tests that need to reset the database within a transaction
* @param db Knex database instance
* @returns Function that resets the database within the current transaction
*/
export function createTransactionSafeReset(db: Knex) {
return async function resetDatabaseInTransaction(options: DbResetOptions = {}) {
// Save current transaction level
const { rows: [{ level }] } = await db.raw('SELECT current_setting(\'transaction_isolation\') as level');
try {
// Set transaction level to SERIALIZABLE for safety
await db.raw('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
await resetDatabase(db, options);
} finally {
// Restore original transaction level
await db.raw(`SET TRANSACTION ISOLATION LEVEL ${level}`);
}
};
}
/**
* Helper to create a common cleanup function for beforeEach/afterEach hooks
* @param db Knex database instance
* @param tables Tables to clean up
* @returns Function suitable for test cleanup hooks
*/
export function createCleanupHook(db: Knex, tables: string[]) {
return async () => {
await cleanupTables(db, tables, { ignoreErrors: true });
};
}