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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
263 lines
7.8 KiB
TypeScript
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 });
|
|
};
|
|
}
|