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
This commit is contained in:
@@ -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<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 ──────────────────
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -1,14 +1,28 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
globalSetup: ['./test/global-setup.ts'],
|
||||||
setupFiles: ['./test/setup.ts'],
|
setupFiles: ['./test/setup.ts'],
|
||||||
// Disable parallel file execution so concurrent DB tests do not interfere
|
// Disable parallel file execution so concurrent DB tests do not interfere
|
||||||
// via the shared MariaDB. The global afterEach in test/setup.ts truncates
|
// via the shared MariaDB. The global afterEach in test/setup.ts truncates
|
||||||
// list tables; running test files in parallel causes FK violations when one
|
// 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.
|
// file's afterEach deletes rows that another file's test is still using.
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
env: isCI
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
DB_NAME: 'familysync_test',
|
||||||
|
DB_HOST: process.env.DB_HOST ?? '127.0.0.1',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user