/** * Vitest globalSetup — provisions and migrates familysync_test for local runs. * * Runs ONCE in the main vitest process before any test file. * Under CI (process.env.CI truthy), returns immediately — CI provisions its own * `familysync` service DB and must not be touched. * * Local flow: * 1. Root-connects → CREATE DATABASE IF NOT EXISTS familysync_test * 2. GRANT ALL PRIVILEGES ON familysync_test.* to the app user * 3. App-user connects → drizzle migrate() applies committed SQL migrations * * Security: * - Root password read from process.env.DB_ROOT_PASSWORD (default 'root' matches dev compose only) * - App user validated against /^[A-Za-z0-9_]+$/ before GRANT interpolation (T-ndv-03) * - This file never runs in CI (T-ndv-04) */ import mysql from 'mysql2/promise'; import { drizzle } from 'drizzle-orm/mysql2'; import { migrate } from 'drizzle-orm/mysql2/migrator'; import { fileURLToPath } from 'node:url'; const TEST_DB = 'familysync_test'; export default async function setup(): Promise { const isCI = !!process.env.CI; if (isCI) { // CI provisions its own database via the mariadb service + db:migrate step. // Do nothing here so the CI flow is completely unaffected (T-ndv-04). return; } // ── Connection params (dev defaults match docker-compose.dev.yml) ────────── const host = process.env.DB_HOST ?? '127.0.0.1'; const port = Number(process.env.DB_PORT ?? 3306); const appUser = process.env.DB_USER ?? 'familysync'; const appPassword = process.env.DB_PASSWORD ?? ''; // ── Root creds — read from env; dev default 'root' only for convenience ──── const rootUser = process.env.DB_ROOT_USER ?? 'root'; const rootPassword = process.env.DB_ROOT_PASSWORD ?? 'root'; // ── Security: validate appUser before interpolating into GRANT ────────────── if (!/^[A-Za-z0-9_]+$/.test(appUser)) { throw new Error( `[global-setup] DB_USER '${appUser}' contains characters not allowed in a GRANT statement. Aborting.`, ); } // ── Step 1–3: Root connection — CREATE DATABASE + GRANT ──────────────────── const rootConn = await mysql.createConnection({ host, port, user: rootUser, password: rootPassword, // No database selected — we are creating one }); try { await rootConn.query(`CREATE DATABASE IF NOT EXISTS \`${TEST_DB}\``); // GRANT does not accept parameterised identifiers; user is validated above (T-ndv-03). // Try @'%' first (Docker/network access); fall back to @'localhost' if the user was // created with a localhost host qualifier. try { await rootConn.query( `GRANT ALL PRIVILEGES ON \`${TEST_DB}\`.* TO '${appUser}'@'%'`, ); } catch { // If @'%' fails (user only exists as @'localhost'), try that form. await rootConn.query( `GRANT ALL PRIVILEGES ON \`${TEST_DB}\`.* TO '${appUser}'@'localhost'`, ); } await rootConn.query('FLUSH PRIVILEGES'); } finally { await rootConn.end(); } // ── Step 4: Apply committed migrations to familysync_test ────────────────── // Use a Pool (not a Connection) — drizzle-orm/mysql2 session.all() calls // client.execute() and transaction() calls client.getConnection(), both of // which are pool methods. createPool with connectionLimit:1 is the minimal form. const appPool = mysql.createPool({ host, port, user: appUser, password: appPassword, database: TEST_DB, connectionLimit: 1, multipleStatements: true, // required by drizzle migrator for multi-statement SQL files }); try { // Pass pool as the first arg + config as the second. Do NOT use the // { client: pool, mode } combined-config form — drizzle-orm@0.45.2 has a // bug in isConfig() where the `mode` branch always returns false (its OR // condition is a tautology), so the combined form falls through to // construct({ client, mode }, undefined) and the session client becomes // the plain config object (no .query()). The two-arg form is safe. const db = drizzle(appPool, { mode: 'default' }); const migrationsFolder = fileURLToPath( new URL('../src/db/migrations', import.meta.url), ); await migrate(db, { migrationsFolder }); // Clean slate each run: truncate every table (except drizzle's migration // ledger) so familysync_test does NOT accumulate rows across runs. Without // this, CREATE DATABASE IF NOT EXISTS reuses the prior run's data and the // users table grows unbounded — re-creating the slow/flaky list_shares // fan-out the dev DB used to suffer, just in the test DB. FK-safe via the // session FOREIGN_KEY_CHECKS toggle (scoped to this one connection). const conn = await appPool.getConnection(); try { const [rows] = await conn.query( `SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' AND table_name <> '__drizzle_migrations'`, [TEST_DB], ); await conn.query('SET FOREIGN_KEY_CHECKS = 0'); for (const { t } of rows as Array<{ t: string }>) { await conn.query(`TRUNCATE TABLE \`${t}\``); } await conn.query('SET FOREIGN_KEY_CHECKS = 1'); } finally { conn.release(); } } finally { await appPool.end(); } console.log('[global-setup] provisioned + migrated + reset familysync_test'); }