Skip to content

Test plan: the invite gate is enforced server-side (#887) โ€‹

Change under test: account creation validates and consumes a single-use invite inside the same transaction that writes the user doc, and /auth/user/invite/consume checks expiresAt.

Surfaces: services/api/auth/src/routes/phoneCreateUser.js, services/api/auth/src/routes/userInvite.js, services/api/auth/src/lib/inviteGate.js, apps/web/src/screens/auth/PhonePinSignup.jsx.

The trap this plan is built around โ€‹

Signup refuses an anonymous caller for four reasons that predate the invite gate: App Check, the phone-verification HMAC the server mints only after a real OTP round trip, ban enforcement, and IP rate limiting. A test that watched an uninvited request fail could be passing on any of those. It would stay green if the invite check were deleted tomorrow.

Every scenario below therefore satisfies all four older defences first, then varies only the invite. The integration suite calls createUserHandler directly with an authenticated req and a genuinely valid signed proof bound to a live server-side marker, so the invite is the only thing left that can refuse.

Scenarios โ€‹

#ScenarioExpectedResult
1Create an account with a valid proof and NO invite403 INVITE_REQUIRED, no user docPass
2Create with a live invite200, user doc written, token shows usedBy = callerPass
3Reuse a burned token for a second account403 INVITE_INVALID reason already_used, no second docPass
4Use a token past expiresAt403, reason expired, no user docPass
5Use a token whose expiresAt is missing or unparseable403, reason expired (fails closed)Pass
6Two concurrent creations racing on ONE tokenexactly one 200 and one 403, exactly one accountPass
7Admin-provisioned caller (admin custom claim) creates with no invite200, pre-existing doc fields preservedPass
8Caller whose users doc says role: admin but whose Auth claim does not403 INVITE_REQUIRED, never provisionedPass
9/consume on an expired token403, token not burnedPass
10/consume by the uid that already burned the token, now expired200 (idempotent)Pass

Run: npm run test:invite-gate:emulator (Admin SDK against a real Firestore emulator; skips without FIRESTORE_EMULATOR_HOST). 10 passed, 2026-08-17.

Scenario 8 is the one that keeps the exemption honest. firestore.rules lets a client create its own users doc, so role in that doc is caller-controlled. The exemption reads the Firebase Auth custom claim instead, which only the Admin SDK can set.

Proof that these tests can fail โ€‹

Passing tests are not evidence until the thing they guard has been broken on purpose. Two mutations were applied to phoneCreateUser.js, each run against the same emulator, source restored after each.

MutationWent redStayed green
A: delete the INVITE_REQUIRED branchscenarios 1, 8the other 8
B: delete the in-transaction inviteFailure checks (both paths)scenarios 3, 4, 5, 6the other 6

The two red sets are disjoint and neither mutation took the suite with it, which is what distinguishes a real A/B from a mutation that simply crashed everything. Scenario 6 going red under B is the specific evidence that the transaction is what serializes two callers on one token, rather than test timing.

Reproduce: tooling/e2e has no harness for this; the mutations are the two anchors quoted in the table, applied by hand.

Not covered here โ€‹

  • A live browser signup on deployed dev. Needs a fresh invite from the admin portal and burns a phone number from the test pool. Deferred to the post-merge deployed pass.
  • The other ways into the users collection. Closed separately in the same PR under #901, which found four client routes rather than one. See ../901-server-only-account-creation/README.md. This plan covers the creation endpoint, which is what #887 scoped.
  • Rollout window. A client on a cached bundle that predates this change sends no inviteToken and will be refused with INVITE_REQUIRED. The signup screen renders "This invite link is no longer valid. Open a fresh invite link to sign up." Dev only at present.

Built with VitePress