Users & Auth — QA Plan
Pre-requisites
- Clerk test instance with webhook configured against the API’s
POST /webhooks/clerkendpoint (Svix secret set). TEST_AUTH_BYPASS=truein test env so Playwright can stub auth withx-test-user-idheaders.- 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_invitationmembership.
Golden paths
G1 — Brand-new sign-up
| Step | Action | Expected |
|---|---|---|
| 1 | Open /{lang}/(auth)/sign-up. Complete Clerk sign-up with new email. | Clerk session established. |
| 2 | Webhook lands at POST /webhooks/clerk with user.created. | 200 {received: true}. New users row created. |
| 3 | Frontend GET /users/me. | Returns user + empty memberships (or auto-accepted ones if invites exist). |
G2 — Imported user first sign-in (linking)
| Step | Action | Expected |
|---|---|---|
| 1 | DB seeded: users row with {email: alice@x.com, clerk_id: null, first_name: 'Alice', last_name: 'Doe'}. Pending invitation for the same email. | |
| 2 | Alice clicks invite email, completes Clerk sign-up. | Clerk identity created. |
| 3 | Webhook user.created. | findOrCreateFromClerk updates the existing row: clerk_id set, image_url set from Clerk, name fields not overwritten (already non-null). |
| 4 | acceptPendingInvitations runs. | memberships.status='pending_invitation' → 'active'. MEMBERSHIP_ACTIVATED event emitted. |
G3 — Returning sign-in
| Step | Action | Expected |
|---|---|---|
| 1 | Bearer JWT presented to any protected endpoint. | AuthGuard verifies; request.auth.user populated. |
| 2 | GET /users/me cache header Cache-Control: private, max-age=15, stale-while-revalidate=60. | Verified via response headers. |
G4 — Profile update
| Step | Action | Expected |
|---|---|---|
| 1 | PATCH /users/me with {firstName: 'New', lastName: 'Name', phone: '054-1234567'}. | 200. Response includes normalized phone (+972541234567). Clerk user firstName/lastName updated. |
| 2 | PATCH again with {nationalId: '123456782'} (valid Israeli ID). | 200. Response shows nationalIdMasked: '***6782'. DB row has national_id_encrypted populated; plaintext not in DB. |
| 3 | PATCH with {nationalId: '111111111'} (invalid checksum). | 500 or 400 from validator. (TODO: confirm exact status mapping.) |
G5 — Account self-deletion
| Step | Action | Expected |
|---|---|---|
| 1 | DELETE /users/me. | 200 { id }. |
| 2 | DB check: users.deleted_at set. All memberships → cancelled + deleted_at. Active subs → cancelled + cancel_at_period_end=true + cancelled_at. Device tokens → deleted_at. | |
| 3 | Clerk: user identity deleted. | Subsequent sign-in attempts fail at Clerk. |
| 4 | DELETE /users/me again with the same (now-stale) bearer. | 401 from Clerk verify. (Bearer no longer valid; account gone.) |
| 5 | Webhook 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
| Step | Action | Expected |
|---|---|---|
| 1 | After G5, try to sign up again with the same email. | Clerk allows it (the identity was deleted). |
| 2 | Webhook 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
| Step | Action | Expected |
|---|---|---|
| 1 | GET /users/{other-user-id} as authenticated user. | 404 ‘User not found’. |
| 2 | GET /users/{my-user-id}. | 200 with profile. |
Edge cases
E1 — Bad webhook signature
| Step | Action | Expected |
|---|---|---|
| 1 | POST /webhooks/clerk with tampered signature. | 400 ‘Invalid webhook signature’. |
| 2 | POST without svix headers. | 400 ‘Missing svix headers’. |
E2 — Missing webhook secret
| Step | Action | Expected |
|---|---|---|
| 1 | Unset CLERK_WEBHOOK_SECRET. POST /webhooks/clerk. | 400 ‘Webhook secret not configured’. |
E3 — Test bypass in prod
| Step | Action | Expected |
|---|---|---|
| 1 | Set NODE_ENV=production, TEST_AUTH_BYPASS=true, send x-test-user-id. | 401. Test bypass guard requires non-prod. |
E4 — Phone normalization fallback
| Step | Action | Expected |
|---|---|---|
| 1 | PATCH with phone: '+1 555 0100' (US format). | normalizeIsraeliPhone returns null; stored as raw +1 555 0100. |
E5 — Webhook idempotency on duplicate delivery
| Step | Action | Expected |
|---|---|---|
| 1 | Same user.created event delivered twice (Svix retry). | Second call finds the row, returns it. No duplicate insert. |
| 2 | Same user.deleted event delivered twice. | First soft-deletes; second logs warn but returns 200. |
E6 — deleteSelf Clerk failure
| Step | Action | Expected |
|---|---|---|
| 1 | Mock 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
| Step | Action | Expected |
|---|---|---|
| 1 | DB 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
| Step | Action | Expected |
|---|---|---|
| 1 | Member 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
| Step | Action | Expected |
|---|---|---|
| 1 | New legal document published. User has no legal_consents row for it. | GET /users/me returns pendingLegalConsents:true. Frontend redirects to /{lang}/(protected)/accept-terms. |
| 2 | User 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
| Lang | Strings to verify |
|---|---|
| en | auth.signIn.*, auth.signUp.*, complete-profile.*, accept-terms.*, member.membershipInactive. |
| he | Hebrew translations present for all auth + profile UI. |
| ru | Russian translations present. |
Expected vs actual checks
- After delete:
users.deleted_atnon-null.memberships.deleted_atandmemberships.status='cancelled'for all rows.device_tokens.deleted_atnon-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_encryptednon-null and longer than the plaintext (encryption adds nonce + auth tag). findByClerkIdcache: after a profile update, the cache entry must be invalidated. Verify by reading the user via another path right after.