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 (
RolesGuardreadsorg_metadata.rolefrom the claim; org-scoped guard 403s; plan reads go throughEntitlementsService.resolvePlan→Organization.plan; 428 app-config gate runs before the quota check inLinkController.create). TheclerkCLI 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 "[email protected]" \
--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_byauto-adds the user as an org member (firesorganizationMembership.created).- The membership's
public_metadata.role(app role:owner/admin/…) is whatRolesGuardreads via theorg_metadataclaim — not Clerk's built-inorg:admin/org:member(see auth-clerk, "Roles live in claims"). - The token must be minted with
organization_id, else it carries noo.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 402sPLAN_NOT_SELECTED; ladder keys arestarter|solo|growth|scale|enterprise— there is nofreevalue):psql "$DBURL" -tAc "UPDATE \"Organization\" SET plan='starter' WHERE \"clerkOrgId\"='$O';" - Add an
AppConfigrow — required beforePOST /portal/linkspasses the app-config gate (428APP_CONFIG_REQUIREDfires 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 directplan='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.