Test plan: account creation is server-only (#901) โ
Change under test: a client can no longer create a users doc, or complete one into existence. #/admin-signup goes through the creation endpoint, new passphrase signups are retired, and firestore.rules sets allow create: if false with the identity fields blocklisted on self-update.
Surfaces: firestore.rules, apps/web/src/screens/auth/AdminSignup.jsx, apps/web/src/App.jsx, apps/web/src/lib/auth.js.
The routes this closes โ
Five. The survey found three; a failing test found the fourth; a /code-review max pass found the fifth.
| Route | What it skipped | |
|---|---|---|
| A | Self-create a phone+PIN doc | invite gate, ban check, phone sealing |
| B | Self-create a legacy passphrase doc (#/signup/passphrase) | the same three, and needed no phone at all |
| C | Self-create legacy, then convert via the migration branch | the same, and survives locking create alone |
| D | Complete an admin-created doc through the STANDARD update branch | the same |
| E | TWO writes: manufacture a salt, then convert it to phone+PIN | the same |
E was not in the survey either, and was found by the /code-review max pass on PR #902 after D had been fixed. The setup-completion branch is guarded on !('salt' in resource.data) and grants salt; the migration branch is guarded on ('salt' in resource.data) && !('phoneSalt' in ...) and grants the phone+PIN material. Write one manufactures exactly the state write two requires, so two updates rebuilt the branch removed for D.
The single-write version was denied, and the suite asserted exactly that, which is why it read as closed. Worse, the test named for the conversion route tested the create instead, above a comment reasoning the conversion needed no test. See "Naming is a claim" below.
D was not in the survey. It surfaced because a test written to prove the specialized admin-setup branch was closed failed: that branch's payload was still accepted through the standard branch, whose blocklist never mentioned the identity fields. Removing the specialized branch on its own would have changed nothing while reading like a fix.
Scenarios โ
| # | Scenario | Expected | Result |
|---|---|---|---|
| 1 | Route A, authenticated owner, well-formed payload | refused | Pass |
| 2 | Route B, authenticated owner, well-formed payload | refused | Pass |
| 3 | Route C, at its root (the create) | refused | Pass |
| 4 | Route D, admin-shaped doc gains phone+PIN fields via self-update | refused | Pass |
| 5 | A real legacy account migrates to phone+PIN | succeeds | Pass |
| 6 | An ordinary profile update (interests) | succeeds | Pass |
| 7 | An old #/signup/passphrase link on first load | lands on #/signup | Pass |
| 8 | Same, arriving via hashchange | lands on #/signup | Pass |
| 9 | #/login/passphrase | still renders | Pass |
| 10 | Route E write 1 alone (manufacture a salt) | refused | Pass |
| 11 | Route E both writes, asserting on RESULTING STATE | no phoneSalt, no phone, no salt | Pass |
| 12 | A client writing its own email | refused | Pass |
| 13 | A caller with a MERCHANT claim and no invite | 403 INVITE_REQUIRED | Pass |
| 14 | A caller with a merchant claim AND an invite | 200, account created | Pass |
Scenario 11 asserts on the resulting document rather than on which write refused, because what matters is that the account cannot come into existence. Scenario 14 exists so that scenario 13 cannot pass merely because merchants are broken.
Rules scenarios: npm run test:rules (215 passed). Merchant-exemption scenarios (13, 14): npm run test:invite-gate:emulator (12 passed). Route scenarios: npx vitest run --root apps/web src/__tests__/appRouteTransitions.test.jsx.
Scenarios 5, 6, 9 and 14 are the half that matters as much as the refusals. A rules change that broke account creation outright, or took passphrase login down with the retirement, would satisfy every refusal above.
That server-side creation still works is proven where it happens, in the createUser endpoint's own emulator suite, not by a rules test: an assertion wrapped in withSecurityRulesDisabled() passes against any ruleset.
Why the refusals are attributable โ
Each payload was run against the PREVIOUS rules first and asserted to succeed; that run passed 3 of 3. The payloads are unchanged in the committed tests. So each one satisfies every shape, type and length check the old rule imposed, and the only thing that moved is the guard.
This matters because the old rule refused plenty of writes on shape alone. A refusal of a malformed payload would have proven nothing.
Proof that these tests can fail โ
| Mutation | Went red | Stayed green |
|---|---|---|
allow create back to permissive | scenarios 1, 2, 3 | 209 others |
| identity keys out of the self-update blocklist | scenario 4 | 211 others |
| both route normalizations deleted | scenarios 7, 8 | scenario 9 |
| setup-completion branch restored | scenarios 10, 11, 12 | 212 others, incl. the single-write control |
Naming is a claim โ
The route-E hole survived a suite that was otherwise carefully built. The test that should have caught it was titled C: the legacy-then-convert route is closed at its root, not just at the update and its body was assertFails(setDoc(..., LEGACY_DOC)), byte-identical to the test above it. It tested the create. Above it sat a comment reasoning that an attacker could not manufacture a legacy doc, so the conversion needed no test; the manufacture step is what the setup-completion branch provided.
A missing test is a visible gap. A mis-titled test answers "is this covered" with a green tick, and every later reader stops there. That test is now named for what it asserts, and the conversion route has its own.
A second instance in the same suite: an assertion that the server can still create, wrapped in withSecurityRulesDisabled(). It passed against any ruleset including an empty one. Removed, with a pointer to where server-side creation is actually exercised.
Disjoint red sets each time, and the bulk staying green is the control that separates this from a mutation that simply broke the suite.
Review findings A to E (#902 max review) โ
| Finding | Fix | Covered by | |
|---|---|---|---|
| A | Account deletion deleted the doc, then failed on requires-recent-login, leaving it unrecoverable | Auth user deleted FIRST, so a failure leaves everything intact. Residual (Auth gone, doc retained) tracked as #905 | Not test-covered. See below |
| B | Admin signup treated 409 as success while caching entropy from a new PIN, a silent lockout | Refuses on alreadyExisted, and maps the previously-unmapped refusals | Not test-covered |
| C | /consume was unauthenticated with a caller-supplied uid | Route removed; consumption is the creation transaction's job | Its removal is covered by the openapi-sync gate and the invite suite |
| D | createUser had no App Check and no IP rate limit, while the docs claimed both | Both added at the mount point | Not test-covered |
| E | The CI paths filter enumerated ~4 files per suite, so a dependency change SKIPPED the emulator job | Directory globs, plus a runner that reports which suites executed | npm run test:emulators:ci prints "3 of 3 suites executed" |
Findings F to K โ
| Finding | Fix | Covered by | |
|---|---|---|---|
| F | Both racers used the SAME phone, so phoneHash uniqueness already forced one account and the invite check could be deleted with only the status code moving. phoneHash was in no payload, so the blocklist mutation proved "one of four keys" | Racers use two different phones, and the loser must be refused with INVITE_INVALID. Each blocklisted key gets its own case, plus a control that ordinary self-update still works | Invite suite (11), rules suite (220) |
| G | An invite token containing a slash threw inside the Admin SDK, answering 500 instead of a clean refusal, and 5xx is the class the client retries | Schema constrained to the UUID shape the route mints | Fixtures are now real UUIDs; a non-UUID is a 400 |
| H | getUser().catch(() => null) could not tell "no claim" from "lookup failed", turning a transient blip into a non-retryable 403 | The failure only BLOCKS when the answer was load-bearing: a caller holding an invite proceeds, a caller holding none gets a retryable 503 | Not test-covered |
| I | The exemption was decided on the ABSENCE of a token, so an exempt caller arriving with an admin-namespace token was refused while the same caller with none was admitted | Exemption resolved before the token is examined; a supplied token is logged and ignored, not consumed | Invite suite |
| J | lanternName's 40-char bound lived on the deleted create rule | Bounded in the Zod schema, the only place left that can | Existing schema tests |
| K | The redirect assigned location.hash, pushing history and trapping Back, and dropped the query string carrying ?invite= | replaceState and the query preserved | Route suite (8) |
F is the one that mattered most, because it was evidence rather than a bug: the PR's headline concurrency proof could have passed with the invite check deleted. Two phones is also the real scenario an invite gate prevents, which is two people and one forwarded link.
A, B and D are stated as untested rather than implied to be covered. A and B live inside long interactive flows with no seam to drive them from a unit test, and D is middleware wiring whose behaviour is a deployed-environment property (App Check tokens are not mintable in the emulator). Naming that is the point: today a test titled for a route it did not exercise is exactly how the two-write hole survived, so an honest gap beats an implied tick.
They are the top of the post-merge deployed pass, alongside the admin-signup run already listed below.
Post-deploy run, 2026-08-18, build msyto7tz โ
The pre-merge suites prove the logic. This is the deployed build, with real App Check, a real OTP round trip and the merged rules.
| Field | Value |
|---|---|
| Commit | eaca831c (PR #902) |
| Deployed artifact | buildId msyto7tz, buildTime 2026-08-18T15:33:24Z |
| Baseline before | buildId msxsov4c, buildTime 2026-08-17T22:18:08Z |
| Harness | node tooling/e2e/invite-gate.e2e.mjs |
The artifact was confirmed to have changed BEFORE anything ran. A pass against the previous bundle would have been indistinguishable from a pass against this one, and on 2026-08-17 the merge landed while the deploy never fired, so this is not hypothetical.
| # | Scenario | Result | Evidence |
|---|---|---|---|
| D1 | An invited signup completes on the deployed build | pass | invite d5f84688... consumed, users/098ad312... written |
| D2 | The server, not the client, wrote the account | pass | phoneSalt set, lanternName assigned by the server |
| D3 | No plaintext phone is stored | pass | users.phone absent; sealed auth_lookup row holds the encrypted blob |
| D4 | A burned invite is refused BY THE INVITE | pass | 403 INVITE_INVALID, reason=already_used off the wire |
| D5 | The refused caller gets no account | pass | users count unchanged at 35 across the attempt |
Two things the harness got wrong first, both caught by running it โ
It asserted phoneHash on the users doc, which would have been a false FAIL. Dev signs up on the sealed Stage B path, where phoneHash is deliberately omitted from the users doc and the join lives in auth_lookup keyed by it. The assertion pinned one path's value; it now pins the invariant, which is that no plaintext phone is stored and the account is reachable by exactly one join mechanism. That version survives a Stage B flip in either direction.
D4's first two attempts did not test what they claimed. A token handed over already burned never reaches the server, because #/signup validates on mount and the CLIENT refuses first, which is the same masking this PR exists to undo, one layer out. Burning it on a 12-second timer then failed the other way: the flow finished in under 12s, so createUser ran against a live invite, correctly returned 200, and the late write overwrote usedBy so the follow-up assertion passed on my own write. Measured, not guessed: account created 15:42:58.079Z, burn landed 15:43:04.724Z.
D4 now holds the createUser request in a route intercept, burns the invite while it is suspended, and releases it, so ordering is enforced rather than timed. The burn is conditional so it can never overwrite a real consume, and the run reports INCONCLUSIVE if the intercept did not fire.
Audit records this run touched, and one repair โ
The gate pass minted three invites as createdBy=e2e-invite-gate. Two of them carry a usedBy written by the harness rather than by createUser, which a later account census correctly flagged. Recorded here so the next census does not re-open it.
| Invite | State | Disposition |
|---|---|---|
d5f84688... | consumed by 098ad312... (the verified signup) | clean, untouched |
1a979d61... | stamp OVERWRITTEN by the harness | repaired |
a6ea706a... | usedBy="someone-else-e2e" written on purpose | left as-is, test data by design |
What went wrong on 1a979d61. An early version of check 2 burned the invite on a 12-second timer with an unconditional .update(). The signup finished in about six seconds, so createUser legitimately consumed the invite and stamped usedBy=77db9006... at 15:42:58.079Z, and the harness then overwrote that stamp at 15:43:04.724Z. A consume cannot postdate the account it gated, which is how the true order was recovered.
The account was correctly gated the whole time. What the census saw was an account with no invite trail, because the trail had been erased by the test watching it.
Repair, 2026-08-18T20:12:13Z. usedBy restored to 77db9006-2ac1-4659-ac7a-24371556a064, with stampCorrectedAt and stampCorrectedReason written onto the row so it declares its own correction rather than depending on this document.
- Before:
usedBy="someone-else-e2e",usedAt=2026-08-18T15:43:04.724Z - After:
usedBy="77db9006-2ac1-4659-ac7a-24371556a064",usedAtunchanged
a6ea706a is deliberately NOT repaired. Its stamp is a faithful record of what the test did: burned pre-request, creation then refused 403 INVITE_INVALID, no account created. Repairing it would falsify history in the other direction.
Two things worth keeping. The harness reproduced, through the Admin SDK, the exact write pattern #902 removed from the public API: an unconditional stamp of usedAt/usedBy with a caller-supplied identity. The API can no longer do it and the test rig still could, because Admin-SDK test code sits outside every gate we build.
And: a test that writes to the system it observes can destroy the evidence that would have exonerated the system. The field it destroyed was the one a security audit reads first. The fix is the burn now running inside a transaction guarded on !usedAt, proven by a6ea706a behaving correctly.
Not covered here โ
- A live signup on deployed dev, for all three of phone+PIN signup, admin signup and an existing passphrase user logging in. That is the post-merge deployed pass, and it is the one that exercises App Check and real Auth.
- Existing accounts. Nothing here migrates or revokes anything already in Firestore. Docs created before this change are untouched, including any that were created through the routes it closes.