Skip to main content

Real-token HTTP testing

Scope: minting a real org-active Clerk token and driving a local backend endpoint with it — the canonical real-user flow for portal/admin/billing routes. Not what each endpoint should return (→ systems/<domain>/), not how Clerk events get mirrored (→ webhook-event-mirroring).

Mocks prove nothing about the guard stack; a bare 401-without-token proves only that the route exists. This recipe asserts the real response body and the real DB side effects.

Source-checked against optolink-backend @ 29f8589, 2026-10-06 (RolesGuard reads org_metadata.role from the claim; org-scoped guard 403s; plan reads go through EntitlementsService.resolvePlan → Organization.plan; 428 app-config gate runs before the quota check in LinkController.create). The clerk CLI commands and the ~60s JWT lifetime are recorded 2026-08-16; re-verify live on next use — they depend on the CLI and Clerk instance, not this repo.

The recipe​

cd optolink-backend && set -a && . ./.env && set +a
S=$(date +%s)

# 1. real Clerk user + org + owner membership
U=$(clerk users create --email "rt-$S@test.com" \
--password "Xq7-$(openssl rand -hex 6)!Aa" --json | jq -r .id) # random pw — "TestPass123!" is pwned
O=$(clerk api /organizations -d "{\"name\":\"RT $S\",\"created_by\":\"$U\"}" --yes | jq -r .id)
clerk api "/organizations/$O/memberships/$U/metadata" -X PATCH \
-d '{"public_metadata":{"role":"owner"}}' --yes >/dev/null # role the JWT emits as org_metadata.role

# 2. mirror into the local DB — see webhook-event-mirroring.md (live relay, or
# signed replay when the relay is down)

# 3. mint an org-active token (~60s lifetime — mint per request, don't cache)
SID=$(clerk api /sessions -d "{\"user_id\":\"$U\"}" --yes | jq -r .id)
TOK() { clerk api "/sessions/$SID/tokens" -d "{\"organization_id\":\"$O\"}" --yes | jq -r .jwt; }

# 4. hit the endpoint
curl -s -w '\n--- HTTP %{http_code} ---\n' localhost:3000/portal/links \
-H "Authorization: Bearer $(TOK)" -H 'Content-Type: application/json' -d '{"path":"/test"}'

# 5. ALWAYS clean up — see cleanup.md

Why each step matters:

  • created_by auto-adds the user as an org member (fires organizationMembership.created).
  • The membership's public_metadata.role (app role: owner/admin/…) is what RolesGuard reads via the org_metadata claim — not Clerk's built-in org:admin/org:member (see auth-clerk, "Roles live in claims").
  • The token must be minted with organization_id, else it carries no o.id/org_metadata → every @Roles/org-scoped call 403s.
  • ~60s JWT lifetime. Mint fresh per request; don't cache across a script.

Common preconditions​

Apply after mirroring, before minting/curling — the entitlements read path keys off Organization.plan (via EntitlementsService.resolvePlan), so a direct set is a valid local fixture even though the billing engine is the only production writer of the column:

  • Set the plan (a fresh org has plan=NULL → every quota-gated route 402s PLAN_NOT_SELECTED; ladder keys are starter|solo|growth|scale|enterprise — there is no free value): psql "$DBURL" -tAc "UPDATE \"Organization\" SET plan='starter' WHERE \"clerkOrgId\"='$O';"
  • Add an AppConfig row — required before POST /portal/links passes the app-config gate (428 APP_CONFIG_REQUIRED fires before the quota check; see the ordering trap in test-stack):
ORG_DBID=$(psql "$DBURL" -tAc "SELECT id FROM \"Organization\" WHERE \"clerkOrgId\"='$O';")
psql "$DBURL" -tAc "INSERT INTO \"AppConfig\" (id, \"organizationId\", platform, \"bundleId\", \"storeUrl\", \"createdAt\", \"updatedAt\") VALUES (gen_random_uuid(), '$ORG_DBID', 'IOS', 'com.test.app', 'https://apps.apple.com/app/id1', NOW(), NOW());"
  • Put the org on a PAID plan to unlock seat/feature-gated flows. The real path is the checkout flow (POST /portal/billing/checkout-session); for a local fixture the fast way is the direct plan='growth' set above.

Worked example: full E2E — fresh signup → gate check​

Combines live event delivery + a real token to prove a user-facing flow end-to-end: fresh signup lands plan=NULL → a quota-gated route 402s.

cd optolink-backend && set -a && . ./.env && set +a && DBURL="${DATABASE_URL%%\?*}"
npx nest start & # wait for "listening on port 3000"
clerk webhooks listen --token c_Nvj2aA97D0 --forward-to http://localhost:3000/webhooks/clerk --json &

U=$(clerk users create --email "e2e-$(date +%s)@test.com" --password "Xq7-$(openssl rand -hex 6)!Aa" --json | jq -r .id)
O=$(clerk api /organizations -d "{\"name\":\"E2E $(date +%s)\",\"created_by\":\"$U\"}" --yes | jq -r .id)
clerk api "/organizations/$O/memberships/$U/metadata" -X PATCH -d '{"public_metadata":{"role":"owner"}}' --yes >/dev/null
sleep 3 # async delivery — allow ~1–5s (see mirroring page)
psql "$DBURL" -tAc "SELECT plan FROM \"Organization\" WHERE \"clerkOrgId\"='$O';" # expect blank = NULL

SID=$(clerk api /sessions -d "{\"user_id\":\"$U\"}" --yes | jq -r .id)
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/portal/links \
-H "Authorization: Bearer $(clerk api /sessions/$SID/tokens -d "{\"organization_id\":\"$O\"}" --yes | jq -r .jwt)" \
-H 'Content-Type: application/json' -d '{"path":"/test"}'
# expect: 402 {…"code":"PLAN_NOT_SELECTED"…} (control: UPDATE org SET plan='starter' → 201)

Prerequisites for the relay half (pinned token, Svix endpoint) are in webhook-relay; the org_metadata session-token claim is in clerk-dev-instance.