Skip to content

ADR: Client-surface JWT e2e fixture (401 unblocker)

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.

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.

  1. 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).
  2. 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.
  3. Admin-token endpoint that issues a synthetic client JWT. Rejected — would require new production code paths (a /api/internal/mint-client-jwt route or similar). Spec is explicitly test-infra-only; out of scope per § 8.

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_PASSWORD against ADMIN_SUPABASE_URL (unchanged).
  • Client sign-in: CLIENT_E2E_USER_EMAIL / CLIENT_E2E_USER_PASSWORD against CLIENT_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 populate userId / 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 (when session.clientToken === null) gives an actionable message (“Set CLIENT_E2E_USER_EMAIL + CLIENT_E2E_USER_PASSWORD in tktspace-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):

  1. Open a pg.Client against process.env.DATABASE_URL (default postgresql://postgres:postgres@localhost:5432/tktspace, matching the precedent specs).
  2. 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 — returns companyCustomerId.
  3. SELECT id FROM users.users WHERE email = $1 AND scope = 'client' LIMIT 1 — resolves clientUserId. If the SELECT returns 0 rows, throw a loud, actionable error: “CLIENT user not yet synced into users.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 (any getClientTestToken() + /api/client/* call will trigger it), and that CLIENT_E2E_USER_EMAIL / CLIENT_E2E_USER_PASSWORD in tktspace-backend/.env match 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.
  4. UPDATE companies.company_customer SET user_id = $1 WHERE id = $2 AND user_id IS NULL — gated on user_id IS NULL so repeated calls are no-ops once linked.
  5. 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.

For each of the 6 specs the diff is mechanically uniform:

  1. Add getClientTestToken to the existing import { authHeaders, getTestToken } from '../support/auth-helper' line.
  2. Add a second token state var in beforeAll:
    let token: string; // admin — for /api/business/* bootstrap
    let clientToken: string; // client — for /api/client/* exercised by the suite
    ...
    token = await getTestToken();
    clientToken = await getClientTestToken();
  3. Find every axios.{get,post,put,patch,delete}('/api/client/...', ..., { headers: authHeaders(token, ...) }) and replace the token argument with clientToken. Admin tokens stay on /api/business/* calls.
  4. 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.
  5. Leave afterAll cleanup 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 any resolveCustomer lookup 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 — exercises bookings-client.service.purchasePass and bookWithPass, both of which call resolveCustomer → require the companyCustomer row 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).

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.

  • apps/api-e2e/src/support/auth-helper.ts — refactor per D1 + D2.
  • apps/api-e2e/src/support/seed-client-customer.ts — per D3.
  • 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.
  • tktspace-backend/.env.example — add CLIENT_E2E_USER_EMAIL= and CLIENT_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).
  • 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 where CLIENT_E2E_USER_* live).
  • Production code under tktspace-backend/libs/** and apps/api/** — untouched.
  • _workflow/contracts/*.openapi.yaml — untouched (no API surface change).

Phase B / C will run (from tktspace-backend/):

Terminal window
# 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):

Terminal window
# Should return zero matches:
rg -n "getTestToken" apps/api-e2e/src/ | rg "client/" -B1 -A1

R1 — 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.

No feature flag, no phased rollout — pure test-infra. The build pipeline opens an MR with:

  1. Helper refactor (D1, D2).
  2. New seed-client-customer.ts (D3).
  3. Six spec migrations (D4).
  4. .env.example update.

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 inline linkCustomerToClientUser helper. Phase B may opportunistically migrate them to import seedClientCustomer instead, but this is not required by any AC. Mark as a follow-up if Phase B time is tight.

STATUS: READY_FOR_REVIEW