Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesUsers AuthUsers & Auth — QA Plan

Users & Auth — QA Plan

Pre-requisites

  • Clerk test instance with webhook configured against the API’s POST /webhooks/clerk endpoint (Svix secret set).
  • TEST_AUTH_BYPASS=true in test env so Playwright can stub auth with x-test-user-id headers.
  • Personas: owner, admin, coach, member.
  • A pre-imported user row (clerk_id=NULL, email matches a fresh Clerk identity to be created).
  • A pre-imported user with at least one pending_invitation membership.

Golden paths

G1 — Brand-new sign-up

StepActionExpected
1Open /{lang}/(auth)/sign-up. Complete Clerk sign-up with new email.Clerk session established.
2Webhook lands at POST /webhooks/clerk with user.created.200 {received: true}. New users row created.
3Frontend GET /users/me.Returns user + empty memberships (or auto-accepted ones if invites exist).

G2 — Imported user first sign-in (linking)

StepActionExpected
1DB seeded: users row with {email: alice@x.com, clerk_id: null, first_name: 'Alice', last_name: 'Doe'}. Pending invitation for the same email.
2Alice clicks invite email, completes Clerk sign-up.Clerk identity created.
3Webhook user.created.findOrCreateFromClerk updates the existing row: clerk_id set, image_url set from Clerk, name fields not overwritten (already non-null).
4acceptPendingInvitations runs.memberships.status='pending_invitation' → 'active'. MEMBERSHIP_ACTIVATED event emitted.

G3 — Returning sign-in

StepActionExpected
1Bearer JWT presented to any protected endpoint.AuthGuard verifies; request.auth.user populated.
2GET /users/me cache header Cache-Control: private, max-age=15, stale-while-revalidate=60.Verified via response headers.

G4 — Profile update

StepActionExpected
1PATCH /users/me with {firstName: 'New', lastName: 'Name', phone: '054-1234567'}.200. Response includes normalized phone (+972541234567). Clerk user firstName/lastName updated.
2PATCH again with {nationalId: '123456782'} (valid Israeli ID).200. Response shows nationalIdMasked: '***6782'. DB row has national_id_encrypted populated; plaintext not in DB.
3PATCH with {nationalId: '111111111'} (invalid checksum).500 or 400 from validator. (TODO: confirm exact status mapping.)

G5 — Account self-deletion

StepActionExpected
1DELETE /users/me.200 { id }.
2DB check: users.deleted_at set. All memberships → cancelled + deleted_at. Active subs → cancelled + cancel_at_period_end=true + cancelled_at. Device tokens → deleted_at.
3Clerk: user identity deleted.Subsequent sign-in attempts fail at Clerk.
4DELETE /users/me again with the same (now-stale) bearer.401 from Clerk verify. (Bearer no longer valid; account gone.)
5Webhook user.deleted fires (from step 3’s Clerk delete).deleteSelf({skipClerk:true}) — idempotent, no-op since user already deleted.

G6 — Re-signup with same email after deletion

StepActionExpected
1After G5, try to sign up again with the same email.Clerk allows it (the identity was deleted).
2Webhook user.created.findOrCreateFromClerk finds the soft-deleted row by email (isNull(users.clerkId) filter — but the deleted row has clerk_id populated until soft-delete didn’t unset it). TODO: verify whether users.clerk_id is cleared on deleteSelf — looking at the code, it is not, so a new email-based link can’t happen. Confirm behavior: fresh row created with a new id.

G7 — Read other user

StepActionExpected
1GET /users/{other-user-id} as authenticated user.404 ‘User not found’.
2GET /users/{my-user-id}.200 with profile.

Edge cases

E1 — Bad webhook signature

StepActionExpected
1POST /webhooks/clerk with tampered signature.400 ‘Invalid webhook signature’.
2POST without svix headers.400 ‘Missing svix headers’.

E2 — Missing webhook secret

StepActionExpected
1Unset CLERK_WEBHOOK_SECRET. POST /webhooks/clerk.400 ‘Webhook secret not configured’.

E3 — Test bypass in prod

StepActionExpected
1Set NODE_ENV=production, TEST_AUTH_BYPASS=true, send x-test-user-id.401. Test bypass guard requires non-prod.

E4 — Phone normalization fallback

StepActionExpected
1PATCH with phone: '+1 555 0100' (US format).normalizeIsraeliPhone returns null; stored as raw +1 555 0100.

E5 — Webhook idempotency on duplicate delivery

StepActionExpected
1Same user.created event delivered twice (Svix retry).Second call finds the row, returns it. No duplicate insert.
2Same user.deleted event delivered twice.First soft-deletes; second logs warn but returns 200.

E6 — deleteSelf Clerk failure

StepActionExpected
1Mock Clerk to throw on users.deleteUser.DB cascade still commits (transaction already closed). Error logged. Response still 200 with {id}.

E7 — Imported user with mismatched email

StepActionExpected
1DB has {email: alice@old.com, clerk_id: null}. User signs up at Clerk with alice@new.com.No link. Fresh users row inserted with the new email. Old row remains orphaned.

E8 — Self-delete during active session

StepActionExpected
1Member with active subscription deletes account.Sub status='cancelled' + cancel_at_period_end=true. Existing bookings (status=‘confirmed’) untouched. (Member can no longer sign in to attend.)

E9 — hasOutstandingConsents gating

StepActionExpected
1New legal document published. User has no legal_consents row for it.GET /users/me returns pendingLegalConsents:true. Frontend redirects to /{lang}/(protected)/accept-terms.
2User accepts.Subsequent /users/me returns pendingLegalConsents:false.

Cross-persona

  • Owner deletes own account: tier limits recompute the next sign-in to a remaining owner; no auto-transfer of ownership (TODO: verify what happens to org with no active owner — Memberships service has a guard against suspending/cancelling the last owner via update, but doesn’t intervene on self-delete).

i18n

LangStrings to verify
enauth.signIn.*, auth.signUp.*, complete-profile.*, accept-terms.*, member.membershipInactive.
heHebrew translations present for all auth + profile UI.
ruRussian translations present.

Expected vs actual checks

  • After delete: users.deleted_at non-null. memberships.deleted_at and memberships.status='cancelled' for all rows. device_tokens.deleted_at non-null for all rows.
  • After Clerk delete failure (forced via mock): DB cascade completed; Sentry/log captured the error.
  • After Israeli ID set: users.national_id_encrypted non-null and longer than the plaintext (encryption adds nonce + auth tag).
  • findByClerkId cache: after a profile update, the cache entry must be invalidated. Verify by reading the user via another path right after.