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 |
|
true |
|
|
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.ymlAlready-established facts (do NOT re-derive):
src/db/client.tsbuilds the pool fromprocess.envat 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 seeDB_NAME=familysync_testBEFORE client.ts is imported.- vitest config is the right place for the env override:
test.envis applied to the test worker processes before any module (including client.ts) loads. The config file itself runs in the main process whereprocess.env.CIis readable — so the override value can be computed conditionally at config-load time. globalSetupruns ONCE in the main vitest process before any test file. It is the place to root-provision + migratefamilysync_test. globalSetup runs in a SEPARATE process from the test workers, so mutatingprocess.env.DB_NAMEinside globalSetup does NOT reach the workers — the worker DB_NAME must come fromtest.envin the config, not from globalSetup.- Dev MariaDB is reachable at 127.0.0.1:3306. The
familysyncapp user has ALL onfamilysync.*but only USAGE on*.*— it CANNOT create databases. Provisioningfamilysync_testneeds ROOT. Dev compose root password is${DB_ROOT_PASSWORD}from.env(docker-compose.yml line 41); the operator's run command doesset -a; source .env; set +a, soDB_ROOT_PASSWORDis 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'smigrate()is installed (drizzle-orm@0.45.2) and applies that folder programmatically. - Tests live in
apps/api/tests/(NOTsrc/). Per-test cleanup intest/setup.tstruncates list/push tables viaafterEach(does NOT touchusers). - CI
apijob: sets job-level envDB_NAME=familysyncagainst a freshmariadb:11service, runsdb:migrateitself, thenpnpm --filter @familysync/api test. CI is already isolated. It must keepDB_NAME=familysyncand skip the local provisioning entirely — gate viaprocess.env.CI.
For the local (non-CI) branch:
- 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). Useconst TEST_DB = 'familysync_test';. - Read DB connection params from env with dev defaults that match docker-compose: host
process.env.DB_HOST ?? '127.0.0.1', portNumber(process.env.DB_PORT ?? 3306), app userprocess.env.DB_USER ?? 'familysync', app passwordprocess.env.DB_PASSWORD ?? ''. - Read ROOT creds from env with dev defaults:
const rootUser = process.env.DB_ROOT_USER ?? 'root';andconst rootPassword = process.env.DB_ROOT_PASSWORD ?? 'root';. NEVER hardcode a production secret —DB_ROOT_PASSWORDis already in the operator's shell (sourced from.env); the'root'default matches the dev compose convention only. - Open a ROOT connection via
mysql.createConnectionfrommysql2/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. - Apply committed migrations to
familysync_testprogrammatically: open amysql.createConnection(orcreatePool) as the APP user against databasefamilysync_test, wrap withdrizzle(client, { mode: 'default' })fromdrizzle-orm/mysql2, and callawait migrate(db, { migrationsFolder: <abs path to apps/api/src/db/migrations> })fromdrizzle-orm/mysql2/migrator. Resolve the migrations folder relative to this file usingfileURLToPath(new URL('../src/db/migrations', import.meta.url))so it is path-independent of cwd. Close the connection/pool after migrate resolves. - Log a single line
console.log('[global-setup] provisioned + migrated familysync_test')so the operator can confirm the local branch ran. Useimport 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 thetestblock. - Add a CI-gated env override so the test WORKERS connect to
familysync_testlocally but keep CI's values: at the top of the config module computeconst isCI = !!process.env.CI;and setenv: isCI ? {} : { DB_NAME: 'familysync_test', DB_HOST: process.env.DB_HOST ?? '127.0.0.1' }insidetest. This is the load-bearing override —test.envis applied to worker processes beforeclient.tsis imported, so the pool readsfamilysync_test. Under CI the override is empty, so the job-levelDB_NAME=familysyncandDB_HOST=mariadbare preserved untouched. Keep the existingfileParallelism: falseandsetupFiles: ['./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.
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> |
<success_criteria>
- Local api tests run exclusively against
familysync_test, proven by the devfamilysyncusers count being unchanged andfamilysync_testcontaining the seeded/migrated tables. familysync_testis auto-created, granted to the app user, and migrated by globalSetup with no manual operator setup beyondDB_ROOT_PASSWORDbeing in.env.- The CI
api(andharness) jobs are demonstrably unaffected by code inspection: globalSetup early-returns and the env override is empty whenCIis set. typecheckpasses; the previously-flakylists.test.ts > re-populates list_sharesno longer times out. </success_criteria>