ADR: Client-surface JWT e2e fixture (401 unblocker)
ADR: Client-surface JWT e2e fixture
Section titled “ADR: Client-surface JWT e2e fixture”Context
Section titled “Context”The backend e2e suite cannot exercise any /api/client/* endpoint locally or in CI without a dedicated CLIENT-project Supabase test user. On main, passes-client.e2e-spec.ts 401s on every client-surface call; the new passes-covered-extras-{booking,cancel}.e2e-spec.ts on feat/passes-per-extra-coverage exhibit the same blocker. The tktspace-backend repo has two separate Supabase projects (ADMIN_SUPABASE_URL for /api/business/* and CLIENT_SUPABASE_URL for /api/client/*) with distinct JWT issuers / JWKS — tokens from one are rejected by the other surface’s strategy.
The helper shape already exists at apps/api-e2e/src/support/auth-helper.ts (getTestSession() + getClientTestToken() at lines 78-80), but getTestSession() reuses TEST_USER_EMAIL / TEST_USER_PASSWORD against BOTH projects and silently falls back to the admin token at lines 53-58 when the CLIENT sign-in fails. The CLIENT Supabase test user has been MANUALLY provisioned by the human owner (spec § 4 — MP-1 to MP-4 complete); creds are in tktspace-backend/.env as CLIENT_E2E_USER_EMAIL / CLIENT_E2E_USER_PASSWORD.
Two specs already use the client token correctly (client/activities-refundable.spec.ts, client/wallet-target-app.e2e-spec.ts). Six specs need migration: passes/passes-client.e2e-spec.ts, passes/passes-covered-extras-booking.e2e-spec.ts, passes/passes-covered-extras-cancel.e2e-spec.ts, client/payment-settings.spec.ts, client/payments.spec.ts, client/companies.spec.ts.
Pure test-infra work — no production code, no DB migrations, no API contract changes. Only tktspace-backend is touched.
Decision
Section titled “Decision”Refactor auth-helper.ts to read CLIENT_E2E_USER_* env vars for the CLIENT sign-in (admin sign-in keeps TEST_USER_* unchanged), remove the silent admin-token fallback, fail loudly only at getClientTestToken() call-sites, ship a shared seed-client-customer.ts helper that mirrors the proven linkCustomerToClientUser pattern, and migrate the 6 specs to the dual-token (admin for bootstrap, client for /api/client/*) pattern already in use in activities-refundable.spec.ts.
Considered alternatives
Section titled “Considered alternatives”- Single shared
TEST_USER_*reused across both projects. Rejected per OQ-1 — requires keeping passwords in lockstep across two Supabase dashboards; one rotation desyncs all e2e. Separate vars are clearer and the human owner explicitly chose option (b). - Programmatic CLIENT user provisioning at test boot. Rejected — requires CLIENT service-role key in CI; spec § 4 explicitly takes the manual route to avoid embedding a privileged key in the pipeline.
- Admin-token endpoint that issues a synthetic client JWT. Rejected — would require new production code paths (a
/api/internal/mint-client-jwtroute or similar). Spec is explicitly test-infra-only; out of scope per § 8.
Decisions (numbered)
Section titled “Decisions (numbered)”D1 — Helper wiring: separate CLIENT creds, no silent fallback
Section titled “D1 — Helper wiring: separate CLIENT creds, no silent fallback”getTestSession() (auth-helper.ts:27-67) is refactored to read separate env vars for the CLIENT sign-in:
- Admin sign-in:
TEST_USER_EMAIL/TEST_USER_PASSWORDagainstADMIN_SUPABASE_URL(unchanged). - Client sign-in:
CLIENT_E2E_USER_EMAIL/CLIENT_E2E_USER_PASSWORDagainstCLIENT_SUPABASE_URL(new wiring).
Lines 53-58 (the admin-token fallback) are removed. When CLIENT env vars are missing OR the CLIENT sign-in fails, the cached TestSession records clientToken / clientUserId as null rather than impersonating the admin token. This preserves backward compatibility for specs that only ever access getTestToken() (admin) — they still pass when CLIENT vars are absent — while making getClientTestToken() callers fail loudly (D2).
Internal shape (illustrative — flat nullable fields, matches what the two passing specs already destructure at activities-refundable.spec.ts:127-128 and wallet-target-app.e2e-spec.ts:117-118):
interface TestSession { token: string; userId: string; clientToken: string | null; clientUserId: string | null;}getTestToken() continues to return session.token. getClientTestToken() reads session.clientToken and throws when it is null.
D2 — Fail-loud semantics: throw inside getClientTestToken()
Section titled “D2 — Fail-loud semantics: throw inside getClientTestToken()”The throw lives inside getClientTestToken(), not inside getTestSession(). Rationale:
- Many existing specs call
getTestSession()only to populateuserId/ admin token — punishing them for missing CLIENT creds would regress R3 (see Risks). getClientTestToken()is the single chokepoint for “I need a real client JWT” — throwing there (whensession.clientToken === null) gives an actionable message (“SetCLIENT_E2E_USER_EMAIL+CLIENT_E2E_USER_PASSWORDintktspace-backend/.env; see specs/client-surface-jwt-e2e-fixture.md § 4”) at the exact failure surface.- The cache (line 14
let cached: TestSession | null = null) is retained — the throw is recomputed from cached state, no extra network round-trip.
Error message body must reference both the env-var names AND the spec/ADR slug so a future agent can find context fast.
D3 — Shared seed helper: seed-client-customer.ts
Section titled “D3 — Shared seed helper: seed-client-customer.ts”New file: apps/api-e2e/src/support/seed-client-customer.ts. Exports a single function:
export interface SeedClientCustomerArgs { companyId: string; clientEmail: string; // typically process.env.CLIENT_E2E_USER_EMAIL}
export interface SeedClientCustomerResult { companyCustomerId: string; // companies.company_customer.id (newly inserted or existing) clientUserId: string; // users.users.id (scope='client')}
export async function seedClientCustomer( args: SeedClientCustomerArgs,): Promise<SeedClientCustomerResult>;Implementation strategy (PG-direct, mirrors activities-refundable.spec.ts:88-109 and wallet-target-app.e2e-spec.ts:72-96 exactly — same sequence, just consolidated into a reusable helper):
- Open a
pg.Clientagainstprocess.env.DATABASE_URL(defaultpostgresql://postgres:postgres@localhost:5432/tktspace, matching the precedent specs). INSERT INTO companies.company_customer (company_id, email, user_id) VALUES ($1, $2, NULL) ON CONFLICT (company_id, email) DO UPDATE SET updated_at = now() RETURNING id— returnscompanyCustomerId.SELECT id FROM users.users WHERE email = $1 AND scope = 'client' LIMIT 1— resolvesclientUserId. If the SELECT returns 0 rows, throw a loud, actionable error: “CLIENT user not yet synced intousers.users(email =, scope = ‘client’). The auth-sync service populates this row on the first authenticated /api/client/*request. Ensure the CLIENT test user has logged in at least once via the e2e harness (anygetClientTestToken()+/api/client/*call will trigger it), and thatCLIENT_E2E_USER_EMAIL/CLIENT_E2E_USER_PASSWORDintktspace-backend/.envmatch a real CLIENT Supabase user. Do NOT try to provoke auth-sync from this helper — it is the spec’s responsibility.” Helper does NOT issue a warm-up HTTP request.UPDATE companies.company_customer SET user_id = $1 WHERE id = $2 AND user_id IS NULL— gated onuser_id IS NULLso repeated calls are no-ops once linked.- Close the pg client.
Reuses the pg package already pulled in by activities-refundable.spec.ts (no new dep). Does NOT use Drizzle directly — the e2e suite has standardised on raw pg for fixtures (see linkCustomerToClientUser).
Sequencing note: callers must perform at least one /api/client/* request under clientToken BEFORE calling seedClientCustomer so the auth-sync service has populated the users.users row. This is the same sequencing the precedent specs follow — they do an early client-surface read against the company before invoking the linkage SQL. Do NOT attempt to trigger auth-sync from inside the helper: a warm-up GET would silently mask a misconfigured CLIENT user (cached middleware, wrong route gating) by appearing to succeed while the SELECT still finds nothing. The loud throw in step 3 is the correct failure mode.
D4 — Per-spec migration pattern
Section titled “D4 — Per-spec migration pattern”For each of the 6 specs the diff is mechanically uniform:
- Add
getClientTestTokento the existingimport { authHeaders, getTestToken } from '../support/auth-helper'line. - Add a second token state var in
beforeAll:let token: string; // admin — for /api/business/* bootstraplet clientToken: string; // client — for /api/client/* exercised by the suite...token = await getTestToken();clientToken = await getClientTestToken(); - Find every
axios.{get,post,put,patch,delete}('/api/client/...', ..., { headers: authHeaders(token, ...) })and replace thetokenargument withclientToken. Admin tokens stay on/api/business/*calls. - After the company is created and (where the suite books a pass / activity), invoke
seedClientCustomer({ companyId, clientEmail: process.env.CLIENT_E2E_USER_EMAIL }). The helper handles users.users resolution + companyCustomer INSERT + ON CONFLICT. - Leave
afterAllcleanup as-is (DELETE company cascades to companyCustomer rows via FK).
Specs that need ONLY the token swap (no seedClientCustomer):
client/payment-settings.spec.ts— read-only against/api/client/payment-settings. Does NOT touch bookings → no customer linkage required.client/payments.spec.ts— POST-shaped but exercises validation paths with bogus IDs that 400/404 inside the controller BEFORE anyresolveCustomerlookup runs, so the missing companyCustomer linkage never blocks an assertion. Token swap alone is sufficient.client/companies.spec.ts— read-only on/api/client/companies.
Specs that need token swap AND seedClientCustomer:
passes/passes-client.e2e-spec.ts— exercisesbookings-client.service.purchasePassandbookWithPass, both of which callresolveCustomer→ require thecompanyCustomerrow linked to the CLIENT user.passes/passes-covered-extras-booking.e2e-spec.ts— same booking flow.passes/passes-covered-extras-cancel.e2e-spec.ts— same booking flow (cancel branch).
The two read-only spec subgroups confirm this isn’t a blanket “token swap + seed” change — keep the seed call scoped to specs that actually purchase/book.
D5 — Test data ownership: per-spec insert + cascade cleanup
Section titled “D5 — Test data ownership: per-spec insert + cascade cleanup”Per-spec beforeAll insert via seedClientCustomer; cleanup is the existing afterAll DELETE /api/business/companies/{companyId} (FK cascade removes the companyCustomer row). No new afterAll block is added to the helper itself, and no per-spec afterAll companyCustomer cleanup is introduced — the cascade from the existing company-delete suffices, and adding a dedicated cleanup would only duplicate that path. This keeps each suite hermetic — the CLIENT user is shared (one Supabase user) but the (companyId, email) row is local to the test company, which itself is deleted.
The ON CONFLICT (company_id, email) DO UPDATE clause in the seed helper handles the edge case where two specs in the same file create distinct companies but both link the same CLIENT_E2E_USER_EMAIL — each (companyId, email) pair is unique, so no conflict in practice; the clause is defence-in-depth against test-file repeats (e.g. re-runs of an interrupted suite).
D6 — Grep guard: defer to follow-up
Section titled “D6 — Grep guard: defer to follow-up”A CI-friendly assertion that fails if any e2e spec uses admin token against /api/client/* would be useful, but:
- It requires either a Jest meta-spec or a Node script — both non-trivial to make reliably grep-resistant against legitimate exceptions (e.g. an explicit 401 negative test).
- The 6 specs in scope here are the entire current population; once migrated, the surface is empty.
- Spec § 7 lists the grep guard as a “cheap CI smoke” but does NOT list it under § 5 (Acceptance criteria).
Decision: defer. Tracked as a separate hardening ticket (suggested title: “e2e: structural guard — no admin token on /api/client/* surface”) if a future spec re-introduces the regression. Phase B will instead grep the repo by hand at the end of migration and confirm zero remaining getTestToken + /api/client/ pairs in spec files.
Files to touch
Section titled “Files to touch”Helper edits (1 file)
Section titled “Helper edits (1 file)”apps/api-e2e/src/support/auth-helper.ts— refactor per D1 + D2.
New helper (1 file)
Section titled “New helper (1 file)”apps/api-e2e/src/support/seed-client-customer.ts— per D3.
Spec migrations (6 files)
Section titled “Spec migrations (6 files)”apps/api-e2e/src/passes/passes-client.e2e-spec.ts— token swap +seedClientCustomer.apps/api-e2e/src/passes/passes-covered-extras-booking.e2e-spec.ts— token swap +seedClientCustomer.apps/api-e2e/src/passes/passes-covered-extras-cancel.e2e-spec.ts— token swap +seedClientCustomer.apps/api-e2e/src/client/payment-settings.spec.ts— token swap only.apps/api-e2e/src/client/payments.spec.ts— token swap only.apps/api-e2e/src/client/companies.spec.ts— token swap only.
Env (1 file)
Section titled “Env (1 file)”tktspace-backend/.env.example— addCLIENT_E2E_USER_EMAIL=andCLIENT_E2E_USER_PASSWORD=placeholder lines (with a brief comment pointing at the spec § 4 manual provisioning steps).tktspace-backend/.env— already populated by the human owner; no edits from the build pipeline (gitignored).
Not touched
Section titled “Not touched”tktspace-backend/apps/api-e2e/jest.config.cts— unchanged.tktspace-backend/apps/api-e2e/src/support/global-setup.ts— unchanged (env loader already pulls repo-root.env, which is whereCLIENT_E2E_USER_*live).- Production code under
tktspace-backend/libs/**andapps/api/**— untouched. _workflow/contracts/*.openapi.yaml— untouched (no API surface change).
Test plan
Section titled “Test plan”Phase B / C will run (from tktspace-backend/):
# Backend dev server must be running on :5005 (or PORT env overridden).# Each command must execute to completion — assertions may pass OR fail on# product logic, but NO suite may 401-block before the assertion runs.
npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='passes-client'npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='passes-covered-extras-booking'npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='passes-covered-extras-cancel'npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='client/payment-settings'npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='client/payments'npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='client/companies'
# Non-regression for the two already-passing client specs:npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='activities-refundable'npx jest --config apps/api-e2e/jest.config.cts --testPathPatterns='wallet-target-app'Manual grep-guard (Phase B end):
# Should return zero matches:rg -n "getTestToken" apps/api-e2e/src/ | rg "client/" -B1 -A1R1 — CLIENT Supabase signin rate-limit / throttle
Section titled “R1 — CLIENT Supabase signin rate-limit / throttle”If e2e runs all 6 client-surface specs in parallel under Jest, each Jest worker could independently call signInWithPassword against the CLIENT project. Supabase Auth has per-IP throttles (typically 4-30 req/min depending on plan).
Mitigation: auth-helper.ts:14 already has a module-scoped cached: TestSession | null — within one Node process the second call short-circuits. BUT Jest by default forks one worker per CPU core, and module-scoped cache is NOT shared across workers. Phase B verification: confirm apps/api-e2e/jest.config.cts runs in-band (--runInBand) OR set maxWorkers: 1 for the affected paths OR introduce a file-system-backed token cache. Concrete check Phase B must perform: read jest.config.cts; if maxWorkers > 1 or no --runInBand, decide between (a) adding --runInBand for the client-surface batch or (b) accepting the per-worker signin and verifying CLIENT Supabase tier headroom. Default recommendation: --runInBand for the client-surface batch — the suites are already serial-ish due to docker:up dependencies.
R2 — Stale companyCustomer rows on crash
Section titled “R2 — Stale companyCustomer rows on crash”If a spec crashes between beforeAll (which seeds the row) and afterAll (which deletes the company → cascades), stale rows accumulate in the dev DB.
Mitigation: seedClientCustomer uses ON CONFLICT (company_id, email) DO UPDATE — re-runs are idempotent. Company cleanup in afterAll is the existing pattern and already swallows errors gracefully. Stale rows are dev-DB-only (CI runs against ephemeral DB), so blast radius is low. Defer a periodic “orphaned companies older than 24h” cleanup script to a future hardening pass if the dev DB bloats noticeably.
R3 — Helper “fail loud” breaking admin-only suites
Section titled “R3 — Helper “fail loud” breaking admin-only suites”If getClientTestToken() is the throw site (D2), suites that only call getTestToken() are unaffected. But if a future refactor moves the throw into getTestSession(), every admin-only spec breaks the moment CLIENT_E2E_USER_* are unset.
Mitigation: D2 explicitly fixes the throw inside getClientTestToken(). The ADR + the helper docblock both call this out. A test against getTestSession() itself (does NOT throw when CLIENT vars missing, populates client: { error }) would be useful as a meta-spec but is deferred along with D6.
Rollout plan
Section titled “Rollout plan”No feature flag, no phased rollout — pure test-infra. The build pipeline opens an MR with:
- Helper refactor (D1, D2).
- New
seed-client-customer.ts(D3). - Six spec migrations (D4).
.env.exampleupdate.
All commits land in one MR on a feat/client-surface-jwt-e2e-fixture branch. CI runs the Phase B test plan. Reviewer (human owner) verifies their local run also passes (their .env is populated). No production impact, no consumer regen — contracts/ is untouched.
- The two specs already on the dual-token pattern (
activities-refundable.spec.ts,wallet-target-app.e2e-spec.ts) currently use an inlinelinkCustomerToClientUserhelper. Phase B may opportunistically migrate them to importseedClientCustomerinstead, but this is not required by any AC. Mark as a follow-up if Phase B time is tight.
STATUS: READY_FOR_REVIEW