CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m21s
CI / api (pull_request) Successful in 59s
CI / harness (pull_request) Successful in 3m55s
CI / security (pull_request) Successful in 41s
CI / gate (pull_request) Successful in 2s
131 lines
5.4 KiB
TypeScript
131 lines
5.4 KiB
TypeScript
/**
|
||
* 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<void> {
|
||
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');
|
||
}
|