From 8453b9751ebafb457ae334fb4c696e216fe9d20e Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 16:56:55 -0400 Subject: [PATCH] chore(quick-260613-ndv-01): add globalSetup for familysync_test isolation - Create apps/api/test/global-setup.ts: root-provisions + grants + migrates familysync_test (local only); no-op when process.env.CI is truthy (T-ndv-04) - Update apps/api/vitest.config.ts: wire globalSetup; add CI-gated test.env override (DB_NAME=familysync_test, DB_HOST) so workers never touch dev DB --- apps/api/test/global-setup.ts | 103 ++++++++++++++++++++++++++++++++++ apps/api/vitest.config.ts | 14 +++++ 2 files changed, 117 insertions(+) create mode 100644 apps/api/test/global-setup.ts diff --git a/apps/api/test/global-setup.ts b/apps/api/test/global-setup.ts new file mode 100644 index 0000000..d6ffd01 --- /dev/null +++ b/apps/api/test/global-setup.ts @@ -0,0 +1,103 @@ +/** + * 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 ────────────────── + const appConn = await mysql.createConnection({ + host, + port, + user: appUser, + password: appPassword, + database: TEST_DB, + multipleStatements: true, // required by drizzle migrator for multi-statement SQL files + }); + + try { + const db = drizzle({ client: appConn, mode: 'default' }); + const migrationsFolder = fileURLToPath( + new URL('../src/db/migrations', import.meta.url), + ); + await migrate(db, { migrationsFolder }); + } finally { + await appConn.end(); + } + + console.log('[global-setup] provisioned + migrated familysync_test'); +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 608eb54..f6c17e1 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,14 +1,28 @@ import { defineConfig } from 'vitest/config'; +// Under CI the job-level env already sets DB_NAME=familysync and DB_HOST=mariadb +// (the ephemeral service container). Do not override those — CI provisions and +// migrates its own DB via the db:migrate step (T-ndv-04). +// Locally, force workers to connect to the isolated test DB so the dev +// `familysync` DB is never mutated by the test suite (T-ndv-02). +const isCI = !!process.env.CI; + export default defineConfig({ test: { environment: 'node', globals: true, + globalSetup: ['./test/global-setup.ts'], setupFiles: ['./test/setup.ts'], // Disable parallel file execution so concurrent DB tests do not interfere // via the shared MariaDB. The global afterEach in test/setup.ts truncates // list tables; running test files in parallel causes FK violations when one // file's afterEach deletes rows that another file's test is still using. fileParallelism: false, + env: isCI + ? {} + : { + DB_NAME: 'familysync_test', + DB_HOST: process.env.DB_HOST ?? '127.0.0.1', + }, }, });