Files
familysync/.planning/quick/260613-ndv-wire-apps-api-integration-tests-to-a-ded/260613-ndv-PLAN.md
T

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
quick-260613-ndv 01 execute 1
apps/api/test/global-setup.ts
apps/api/vitest.config.ts
apps/api/test/setup.ts
apps/api/README.md
true
QUICK-260613-ndv
truths artifacts key_links
Local `pnpm --filter @familysync/api test` runs against `familysync_test`, never the dev `familysync` DB
After a full local test run, the dev `familysync` DB `users` row count is unchanged
The new globalSetup is a no-op under CI (process.env.CI truthy), so CI keeps DB_NAME=familysync and its own migrate flow
`familysync_test` is auto-provisioned (CREATE DATABASE + GRANT to the app user) and migrated before tests run
`pnpm --filter @familysync/api typecheck` exits 0
path provides min_lines
apps/api/test/global-setup.ts vitest globalSetup: root-provisions + migrates familysync_test (local only), no-op under CI 40
path provides contains
apps/api/vitest.config.ts globalSetup wired + CI-gated test.env.DB_NAME/DB_HOST override globalSetup
from to via pattern
apps/api/vitest.config.ts apps/api/test/global-setup.ts test.globalSetup config field globalSetup
from to via pattern
apps/api/vitest.config.ts apps/api/src/db/client.ts test.env.DB_NAME=familysync_test sets the env the pool reads at module-eval DB_NAME
Wire the apps/api integration test suite to a dedicated LOCAL test database (`familysync_test`) so local test runs stop polluting the dev DB (`familysync`). A vitest `globalSetup` provisions and migrates `familysync_test` once per run (local only), and vitest's `test.env` forces `DB_NAME=familysync_test` for the test workers — both CI-gated so the existing CI `api` job (its own `familysync` service DB + `db:migrate`) is untouched.

Purpose: The operator currently runs api tests against the live dev DB, mutating real dev rows (lists, users) and causing the flaky lists.test.ts > re-populates list_shares timeout against dirty state. Isolating to familysync_test makes local runs deterministic and non-destructive.

Output: apps/api/test/global-setup.ts (new), apps/api/vitest.config.ts (globalSetup + CI-gated env override), an optional test/setup.ts clean-slate adjustment, and a short README note on running api tests locally.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @apps/api/vitest.config.ts @apps/api/test/setup.ts @apps/api/src/db/client.ts @apps/api/drizzle.config.ts @apps/api/package.json @docker-compose.dev.yml @docker-compose.yml @.gitea/workflows/ci.yml

Already-established facts (do NOT re-derive):

  • src/db/client.ts builds the pool from process.env at module-eval time (no dotenv in code): host=DB_HOST??'localhost', port=DB_PORT??3306, user=DB_USER??'familysync', password=DB_PASSWORD??'', database=DB_NAME??'familysync'. So forcing the test DB = ensuring the test workers see DB_NAME=familysync_test BEFORE client.ts is imported.
  • vitest config is the right place for the env override: test.env is applied to the test worker processes before any module (including client.ts) loads. The config file itself runs in the main process where process.env.CI is readable — so the override value can be computed conditionally at config-load time.
  • globalSetup runs ONCE in the main vitest process before any test file. It is the place to root-provision + migrate familysync_test. globalSetup runs in a SEPARATE process from the test workers, so mutating process.env.DB_NAME inside globalSetup does NOT reach the workers — the worker DB_NAME must come from test.env in the config, not from globalSetup.
  • Dev MariaDB is reachable at 127.0.0.1:3306. The familysync app user has ALL on familysync.* but only USAGE on *.* — it CANNOT create databases. Provisioning familysync_test needs ROOT. Dev compose root password is ${DB_ROOT_PASSWORD} from .env (docker-compose.yml line 41); the operator's run command does set -a; source .env; set +a, so DB_ROOT_PASSWORD is already in the shell env for local runs.
  • Migrations: committed SQL lives in apps/api/src/db/migrations/ (0000_baseline.sql + meta/). drizzle-orm/mysql2/migrator's migrate() is installed (drizzle-orm@0.45.2) and applies that folder programmatically.
  • Tests live in apps/api/tests/ (NOT src/). Per-test cleanup in test/setup.ts truncates list/push tables via afterEach (does NOT touch users).
  • CI api job: sets job-level env DB_NAME=familysync against a fresh mariadb:11 service, runs db:migrate itself, then pnpm --filter @familysync/api test. CI is already isolated. It must keep DB_NAME=familysync and skip the local provisioning entirely — gate via process.env.CI.
Task 1: Add CI-gated globalSetup that provisions and migrates familysync_test apps/api/test/global-setup.ts, apps/api/vitest.config.ts Create `apps/api/test/global-setup.ts` exporting a default async `setup()` function (vitest globalSetup contract). At the top, read `const isCI = !!process.env.CI;` and, if `isCI` is truthy, `return` immediately — CI provisions and migrates its own `familysync` service DB and must not be touched.

For the local (non-CI) branch:

  1. Resolve the test DB name from a single constant const TEST_DB = process.env.DB_NAME ?? 'familysync_test'; — but DO NOT rely on the shell DB_NAME here; default to the literal 'familysync_test' so this file is self-describing (the worker override is set in vitest.config.ts, see Task wiring below). Use const TEST_DB = 'familysync_test';.
  2. Read DB connection params from env with dev defaults that match docker-compose: host process.env.DB_HOST ?? '127.0.0.1', port Number(process.env.DB_PORT ?? 3306), app user process.env.DB_USER ?? 'familysync', app password process.env.DB_PASSWORD ?? ''.
  3. Read ROOT creds from env with dev defaults: const rootUser = process.env.DB_ROOT_USER ?? 'root'; and const rootPassword = process.env.DB_ROOT_PASSWORD ?? 'root';. NEVER hardcode a production secret — DB_ROOT_PASSWORD is already in the operator's shell (sourced from .env); the 'root' default matches the dev compose convention only.
  4. Open a ROOT connection via mysql.createConnection from mysql2/promise (host/port from step 2, user/password = root creds, NO database selected). Run, in order: CREATE DATABASE IF NOT EXISTS \familysync_test`; GRANT ALL PRIVILEGES ON `familysync_test`.* TO ?@'%'binding the app user (use a parameterized identifier carefully — GRANT does not accept?for identifiers, so build the statement as ``GRANT ALL PRIVILEGES ON `familysync_test`.* TO '${appUser}'@'%'`` after validatingappUsermatches/^[A-Za-z0-9_]+$/to avoid injection); thenFLUSH PRIVILEGES. Close the root connection. Wrap GRANT in try/catch — if the app user was created as @'localhost'rather than@'%', also attempt the @'localhost'` grant; ignore "operation not allowed" only if the user already has the privilege.
  5. Apply committed migrations to familysync_test programmatically: open a mysql.createConnection (or createPool) as the APP user against database familysync_test, wrap with drizzle(client, { mode: 'default' }) from drizzle-orm/mysql2, and call await migrate(db, { migrationsFolder: <abs path to apps/api/src/db/migrations> }) from drizzle-orm/mysql2/migrator. Resolve the migrations folder relative to this file using fileURLToPath(new URL('../src/db/migrations', import.meta.url)) so it is path-independent of cwd. Close the connection/pool after migrate resolves.
  6. Log a single line console.log('[global-setup] provisioned + migrated familysync_test') so the operator can confirm the local branch ran. Use import mysql from 'mysql2/promise', import { drizzle } from 'drizzle-orm/mysql2', import { migrate } from 'drizzle-orm/mysql2/migrator', import { fileURLToPath } from 'node:url'. No fenced code in this plan — follow the named imports and statement order above.

Then wire it into apps/api/vitest.config.ts:

  • Add globalSetup: ['./test/global-setup.ts'] to the test block.
  • Add a CI-gated env override so the test WORKERS connect to familysync_test locally but keep CI's values: at the top of the config module compute const isCI = !!process.env.CI; and set env: isCI ? {} : { DB_NAME: 'familysync_test', DB_HOST: process.env.DB_HOST ?? '127.0.0.1' } inside test. This is the load-bearing override — test.env is applied to worker processes before client.ts is imported, so the pool reads familysync_test. Under CI the override is empty, so the job-level DB_NAME=familysync and DB_HOST=mariadb are preserved untouched. Keep the existing fileParallelism: false and setupFiles: ['./test/setup.ts']. cd /home/luc/Projects/familysync && grep -q "globalSetup" apps/api/vitest.config.ts && grep -q "familysync_test" apps/api/vitest.config.ts && grep -q "process.env.CI" apps/api/test/global-setup.ts && grep -q "migrate(" apps/api/test/global-setup.ts && pnpm --filter @familysync/api typecheck globalSetup file exists, returns early under CI, root-provisions + grants + migrates familysync_test locally; vitest.config.ts wires globalSetup and applies a CI-gated DB_NAME=familysync_test worker override; typecheck exits 0.
Task 2: Make per-test cleanup clean-slate (fold users) and prove isolation end-to-end apps/api/test/setup.ts, apps/api/README.md The flaky `lists.test.ts > re-populates list_shares` times out against dirty/leftover state. Make the test DB deterministic between tests. In `apps/api/test/setup.ts`, keep the existing `afterEach` FK-safe truncation of `listItems`, `listShares`, `pushSubscriptions`, `lists`. Because this now runs against the isolated `familysync_test` DB (never dev data), it is safe to also reset `users` to a known baseline IF tests depend on user rows.

Decision (justify in the SUMMARY): do NOT blanket-delete(users) in afterEach — many tests seed user id=1 (dev user) once and reuse it; deleting users between tests would break FK-dependent rows mid-suite and add churn. Instead, leave users cleanup OUT of afterEach (matching current behavior) and rely on globalSetup's fresh-migrated familysync_test for a clean baseline at run start. If any test currently leaks users rows in a way that affects another test, scope a targeted delete inside that test's own setup rather than globally. Add a clarifying comment in test/setup.ts updating the file header: tests now run against familysync_test (provisioned by test/global-setup.ts), not the dev DB; afterEach truncates list/push tables only; users is left intact across tests within a run.

Update apps/api/README.md (create a short ## Running API tests locally section if absent): document that local api tests run against familysync_test, auto-provisioned by test/global-setup.ts; the run command is set -a; source .env; set +a; DB_HOST=127.0.0.1 pnpm --filter @familysync/api test; note that DB_ROOT_PASSWORD must be present in .env for the one-time CREATE DATABASE/GRANT, and that CI is unaffected because globalSetup no-ops when CI is set. cd /home/luc/Projects/familysync && set -a && source .env && set +a && DEV_BEFORE=$(DB_HOST=127.0.0.1 node --input-type=module -e "import mysql from 'mysql2/promise'; const c=await mysql.createConnection({host:'127.0.0.1',port:3306,user:process.env.DB_USER,password:process.env.DB_PASSWORD,database:'familysync'}); const [r]=await c.query('SELECT COUNT() n FROM users'); console.log(r[0].n); await c.end();") && DB_HOST=127.0.0.1 pnpm --filter @familysync/api test && DEV_AFTER=$(DB_HOST=127.0.0.1 node --input-type=module -e "import mysql from 'mysql2/promise'; const c=await mysql.createConnection({host:'127.0.0.1',port:3306,user:process.env.DB_USER,password:process.env.DB_PASSWORD,database:'familysync'}); const [r]=await c.query('SELECT COUNT() n FROM users'); console.log(r[0].n); await c.end();") && echo "dev users before=$DEV_BEFORE after=$DEV_AFTER" && [ "$DEV_BEFORE" = "$DEV_AFTER" ] && DB_HOST=127.0.0.1 node --input-type=module -e "import mysql from 'mysql2/promise'; const c=await mysql.createConnection({host:'127.0.0.1',port:3306,user:process.env.DB_USER,password:process.env.DB_PASSWORD,database:'familysync_test'}); const [r]=await c.query('SHOW TABLES'); console.log('familysync_test tables:', r.length); await c.end();" Full local api test suite passes against familysync_test; dev familysync users count is identical before and after the run; familysync_test exists with migrated tables; the previously-flaky list_shares test no longer times out. README documents the local run flow.

<threat_model>

Trust Boundaries

Boundary Description
test process → dev MariaDB (root) globalSetup connects as ROOT to provision a DB; root creds cross into a Node test process
vitest config → test workers DB_NAME/DB_HOST override decides which DB the suite mutates

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-ndv-01 Information Disclosure root DB creds in global-setup.ts mitigate Read root creds from process.env.DB_ROOT_USER/DB_ROOT_PASSWORD with dev-only defaults; never hardcode prod secret; dev default 'root' matches docker-compose.dev convention and is used only on the non-CI branch
T-ndv-02 Tampering accidental write to dev familysync DB mitigate test.env.DB_NAME=familysync_test forces workers off the dev DB; verify step captures dev users count before/after and asserts equality
T-ndv-03 Elevation of Privilege SQL injection via interpolated user/db identifiers in GRANT mitigate DB name is a fixed literal familysync_test; app user validated against /^[A-Za-z0-9_]+$/ before interpolation into GRANT
T-ndv-04 Denial of Service globalSetup runs root provisioning in CI and breaks the isolated CI flow mitigate if (process.env.CI) return; first line of globalSetup; config env override is {} under CI — both CI-gated
T-ndv-SC Tampering npm/pip/cargo installs accept No new packages installed; uses already-installed mysql2, drizzle-orm, drizzle-orm/mysql2/migrator (verified present)
</threat_model>
- `apps/api/test/global-setup.ts` returns early when `process.env.CI` is truthy (grep + read). - `apps/api/vitest.config.ts` declares `globalSetup` and a CI-gated `DB_NAME=familysync_test` worker override; `fileParallelism:false` and `setupFiles` retained. - Local run: `set -a; source .env; set +a; DB_HOST=127.0.0.1 pnpm --filter @familysync/api test` passes. - Dev `familysync` `users` row count is identical before and after the run (captured in the Task 2 verify). - `familysync_test` exists and has migrated tables after the run. - `pnpm --filter @familysync/api typecheck` exits 0. - CI inspection (no CI run): job-level `DB_NAME=familysync` + `db:migrate` step unchanged; globalSetup no-ops and the config env override is empty under `CI=true`, so CI keeps its own DB.

<success_criteria>

  • Local api tests run exclusively against familysync_test, proven by the dev familysync users count being unchanged and familysync_test containing the seeded/migrated tables.
  • familysync_test is auto-created, granted to the app user, and migrated by globalSetup with no manual operator setup beyond DB_ROOT_PASSWORD being in .env.
  • The CI api (and harness) jobs are demonstrably unaffected by code inspection: globalSetup early-returns and the env override is empty when CI is set.
  • typecheck passes; the previously-flaky lists.test.ts > re-populates list_shares no longer times out. </success_criteria>
Create `.planning/quick/260613-ndv-wire-apps-api-integration-tests-to-a-ded/260613-ndv-SUMMARY.md` when done.