diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 8eedd6e8..4c6efd87 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -484,6 +484,28 @@ For database/payment changes, roll-forward is often safer than reverting code be Never use destructive Git or Firestore reset operations on production as a rollback. +### Missing member profile repair — NOT AVAILABLE YET + +Issue [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) replaces manual repair with create-once signup/recovery Functions and a fail-closed account page. Do not use it as a production procedure until #105 supplies a protected staging and deployment path. + +Required release order: + +1. Confirm the exact merged #100 Rules revision is the reviewed dependency. +2. Deploy those Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` to isolated staging. +3. Record the deployed App Check policy. When enforcement is enabled, prove missing or invalid App Check is rejected. When it is not enabled, record that open gate and do not call the boundary enforced. +4. Use a made-up signed-in account with no profile. +5. Prove one pending profile is created and retries preserve it byte-for-byte. +6. Prove existing member/admin profiles and custom claims remain unchanged. +7. Prove anonymous, injected-field, backend-down, missing-profile, Rules-denial, and cross-user paths fail safely. Prove App Check behavior matches the policy recorded in step 3. +8. Record and rehearse either the last-compatible Rules/Functions/website rollback or a reviewed safe roll-forward in staging. +9. Deploy and verify the exact #100 Rules plus both revised Functions in the approved target Firebase project. +10. If that backend deployment is partial or verification fails, do not deploy the website. Restore the reviewed backend set or safely roll forward, then repeat verification. +11. Deploy the dependent website only after target-backend proof. +12. Check the exact website revision and the plain retry state. +13. Repeat only the approved synthetic live smoke; never use a real member record. + +If the website arrives before `ensureMemberProfile` or the matching Rules, the intended safe result is a temporary-unavailable message with Edit hidden. Roll back the website or safely roll forward the reviewed backend. Never release the callable while the old signup trigger remains: that trigger can overwrite a recovered profile or replace claims. Deploy the two revised Functions together, or stop and roll back the partial backend release. Do not create, delete, overwrite, or re-role a production account in Firebase Console. + ## 17. Verification evidence boundaries The earlier combined pre-extraction working tree reported 7 frontend tests, 4 SPA callback tests, 45 Functions tests, and 185 Firestore Rules tests. Those numbers combine queued #99, #100, #101, and #103 work. They are historical planning evidence only and are **not reproducible proof for #104 or current `main`**. diff --git a/SECURITY.md b/SECURITY.md index 78a3969a..bdb5b8a7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -86,6 +86,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-036 | #135 adds a manual exact-commit source gate, fixed profile-recovery targets, backend-first order, missing-config failure, and Git-triggered Netlify production containment. Protected environments/OIDC, isolated staging, live-Netlify publication, and rollback evidence are still absent. | Complete #133 and #136, provision isolated staging, protect the Netlify release path under WEB-001, and rehearse rollback/safe roll-forward before production. | | RISK-037 | Account/registration deletion, export, retention, backup, and restore procedures are incomplete. | Approve retention matrix, automate minimization, support access/deletion requests, and test backup restoration. | | RISK-038 | Source-controlled secret scan is ad hoc; no continuous secret scanner, dependency update bot, SBOM, provenance, or branch protection is documented. | Add secret/dependency/code scanning, reviewed lockfile updates, protected environments/branches, and artifact provenance appropriate to project scale. | +| RISK-039 | Some authenticated accounts can lack `members/{uid}` after the Firebase cutover; the account screen hid the read failure and exposed an update that could only fail. Manual database/account repair could corrupt roles or private data. | Use an authenticated create-once server bootstrap, keep browser creation denied, fail the UI closed, and prove backend-first deployment with synthetic accounts. | ### Source remediation ledger @@ -103,7 +104,8 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | | Part of RISK-027 | #99 keeps App Check, Firebase Analytics, and Sentry from initializing in local/test even when public provider configuration is present. Initial capability callback routes also fail closed before App Check, Firebase Analytics, or Sentry startup. | #111 still owns general hosted URL/event/log redaction, already-initialized SPA transitions, replay/consent/retention policy, payload inspection, and provider evidence. ABUSE-001A must defer the three callback callables; DATA-001A and OAUTH-001C within canonical tracker [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) own their safe protected handoffs. | | Part of RISK-035 | The Jest environment is repaired, the stale frontend smoke test is replaced, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite as its own named blocking step. | Keep exact hosted evidence on #124/#126; make lint non-mutating/fail-closed, prove required branch checks, and add the broader TEST-001 suite. | -| RISK-026, RISK-036 | #135 replaces automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. It rechecks newest CI and the retained artifact after approval, pins release Actions, uses the lockfile Firebase CLI, validates protected configuration before backend install/authentication, idempotently verifies fixed Rules/Function revisions before Pages publication, removes cloud credentials from website preparation/publication, stops Git-triggered Netlify production builds, and removes the Pages CNAME from future artifacts. | Before #135 closes, its merged source must pass the safe missing-authority dispatch with zero Firebase/Pages/Netlify publication. #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | +| RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | +| RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. | ## 5. Threat model diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index c1f3f0f1..8817370a 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -227,7 +227,7 @@ The first release may continue using `admin` in the UI, but server endpoints and | `events/{eventId}/registrations/{registrationId}` | Participant identity, waiver evidence, payment references, lifecycle | Cloud Functions and admins today; target Cloud Functions only | Restricted PII and financial metadata | | `products/{productId}` | Merchandise catalog | Admin client today | Public plus internal configuration | | `orders/{orderId}` | Buyer, shipping, payment, and fulfillment data | Cloud Functions and admins today; target Cloud Functions only | Restricted PII and financial metadata | -| `members/{uid}` | Profile and role mirror | Signup function, self-service allowlist, admins | Confidential | +| `members/{uid}` | Profile and role mirror | Create-once signup/recovery Functions; self-service name/phone allowlist; server role operations | Confidential | | `members/{uid}/connections/{provider}` | Non-secret connection metadata | Cloud Functions | Confidential | | `members/{uid}/secrets/{provider}` | OAuth tokens | Cloud Functions | Restricted secret | | `promoCodes/{id}` | Intended promotion configuration | Admin only | Confidential; currently not integrated into checkout validation | @@ -270,6 +270,48 @@ The following are correctness rules, not UI preferences: ## 8. Core workflows +### 8.0 Account profile setup and recovery + +Issue [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) adds a create-once recovery path for accounts whose `members/{uid}` record is missing after the Firebase cutover. It is a source design until the exact Function, current Rules, and dependent website are deployed backend-first and proven with a synthetic account. + +```mermaid +sequenceDiagram + actor M as Signed-in member + participant W as Account page + participant F as ensureMemberProfile Function + participant A as Firebase Auth Admin + participant D as Firestore + + M->>W: Open My Account + W->>F: Empty request + verified Firebase sign-in + alt setup fails + F-->>W: Generic unavailable result + W-->>M: Hide Edit; show retry/sign-out path + else setup continues + F->>D: Read only members/{caller UID} + alt profile exists + D-->>F: Exists + F-->>W: ready=true; no write or claim change + else profile missing + F->>A: Load caller's authoritative Auth record + F->>D: Transactionally recheck and create pending profile once + F-->>W: ready=true + end + W->>D: Read caller profile through Firestore Rules + alt read succeeds + D-->>W: Profile + W-->>M: Show profile and name/phone Edit + else read fails + D-->>W: Generic failure + W-->>M: Hide Edit; show retry/sign-out path + end + end +``` + +The callable accepts no UID or profile fields. A missing record receives identity fields from Firebase Auth and `role: unverified`; it never copies a member/admin claim, dues, payment, or discount state. Existing records and claims remain unchanged. This may expose a pre-existing claim/profile mismatch instead of guessing how to repair it; the identity/membership workflow must resolve that mismatch explicitly. App Check is required by the shared boundary only when deployed enforcement is configured, so release evidence must prove that setting before calling App Check live. + +The server chooses initial timestamps. The current self-edit path sends a Firestore server timestamp, but the Rules source type-checks rather than independently proves that edit timestamp. Do not describe arbitrary profile edit timestamps as server-authoritative until a coordinated Rules/API issue closes that residual. + ### 8.1 Paid race registration ```mermaid diff --git a/docs/officers/EMERGENCY_AND_RECOVERY.md b/docs/officers/EMERGENCY_AND_RECOVERY.md index 1ef71cc6..98f66acf 100644 --- a/docs/officers/EMERGENCY_AND_RECOVERY.md +++ b/docs/officers/EMERGENCY_AND_RECOVERY.md @@ -28,12 +28,40 @@ | Wrong public text, image, or link | Ask the platform owner for one reviewed revert pull request. | | Site unavailable or old after a merge | Ask the platform owner to check Netlify and GitHub Pages separately; do not change DNS. | | Login or member access is wrong | Stop role requests and contact the identity/platform owners. | +| Profile Save says permission is missing | Ask the member to stop, sign out, and wait. Open a new redacted incident through [Request a change](./REQUEST_A_CHANGE.md). Do not edit the database, delete the login, recreate the account, or grant a role. | | Private member information is visible | Contact platform and privacy owners immediately; they choose containment through an approved service procedure. Do not change permissions yourself. | | Unexpected payment, refund, order, or signup | Contact treasurer and platform owners. There is no proven no-code payment kill switch. Do not test with real money. | | Password, secret, or recovery code exposed | Contact the owning service's two approved owners. They revoke/rotate through the service-specific procedure and record evidence. Do not rotate it from this generic guide. | Live commerce is not approved as of 2026-07-12. If the public site appears to accept a real payment, do not test it with a real card. +### Missing profile or profile permission error + +**Status:** automatic repair is **NOT AVAILABLE YET**. These are current stop-and-escalate steps only. + +**Purpose:** prevent a failed account repair from changing access or private data. + +**Approver:** membership lead plus platform/security owner. + +**Prerequisites:** the time, the public `/account` address, and a redacted description with no member details. + +1. Ask the member to stop using Save. +2. Ask the member to sign out. +3. Open a new redacted incident by following [Request a change](./REQUEST_A_CHANGE.md). +4. Use [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) only as engineering context. Do not add member incidents or private details to it. +5. Share only the time and the plain symptom. +6. Wait for a made-up account test and separate website, server Function, and database-permission proof. + +**Expected result:** no manual account or database change occurs. The member retries only after the automated repair is proven live. + +**Stop conditions:** stop if a step needs a real profile, private screenshot, direct database change, account deletion, account recreation, or role change. + +**Success proof:** use the checklist in [Events, shop, members, and money](./EVENTS_SHOP_MEMBERS.md#profile-permission-error). + +**Undo:** ask the platform owner to prepare, approve, publish, and verify a reviewed revert or safe roll-forward. Do not delete the account or profile. + +**Escalation:** membership lead plus identity/security owner; add the privacy owner if private details were exposed. + ## Safe AI message > Investigate this MPRC incident without changing production: **[symptom]** at **[URL]** starting **[time]**. Preserve evidence, redact private data, identify the last known-good version, and propose the smallest revert or safe roll-forward. Do not force-push, delete records, refund money, rotate secrets, change DNS, or deploy until the named owners approve. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 61d15b5c..31baea4e 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -55,6 +55,53 @@ There is currently no proven no-code switch that safely stops all new Stripe pay If any proof is missing, report the change as **not live**. +## Profile permission error + +**Status: AUTOMATIC REPAIR NOT LIVE YET** + +**Purpose:** help a signed-in member whose profile is missing or cannot be read. + +**Approver:** membership lead plus platform/security owner. + +**Prerequisites:** a new redacted incident from [Request a change](./REQUEST_A_CHANGE.md), made-up test accounts, an isolated Firebase test project, and an approved release plan. Issues [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) and [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105) are engineering references, not places to add member details. + +The planned safe flow is automatic. An officer does not create or edit the member record. + +```mermaid +flowchart LR + A["Member opens My Account"] --> B["Server checks only this member's profile"] + B --> C{"Profile exists?"} + C -- "Yes" --> D["Keep it unchanged"] + C -- "No" --> E["Create one pending profile"] + D --> F["Read through normal permissions"] + E --> F + F -- "Success" --> G["Edit name and phone"] + F -- "Failure" --> H["Hide Edit and show Try again"] +``` + +In words: the server preserves an existing profile or creates one pending profile for the signed-in person; editing stays hidden unless the normal read succeeds. + +Safe officer steps: + +1. Ask the member to stop retrying Save. +2. Record the time and the public `/account` page address. +3. Do not record their name, email, phone, login code, or screenshot of profile details. +4. Ask them to sign out. +5. Open a new redacted incident through [Request a change](./REQUEST_A_CHANGE.md). +6. Use #118 only as engineering context. Do not add member incidents or private details to it. +7. Wait for the platform owner to test with a made-up account. +8. Tell the member to retry only after the website, server Function, database permissions, and live behavior are each proven. + +**Expected result:** after all release proof is complete, the member sees a profile or a plain temporary-unavailable message. A missing profile is displayed as pending or unverified. The repair does not grant, remove, or change actual access. If displayed profile status and actual access disagree, stop and escalate. + +**Stop conditions:** stop if anyone proposes a direct database change, login-account deletion, account recreation, role grant, real-member test, or website-only release before the server Function is live. + +**Success proof:** name the merged pull request, website commit, Function deployment, database-permission deployment, made-up staged account test, `runmprc.com` check, and separate live-state check. A green workflow with “skipping Firebase deploy” is not proof. + +**Undo:** ask the platform owner to prepare, approve, publish, and verify a reviewed revert or safe roll-forward. Never undo by deleting a member profile or login account. + +**Escalation:** membership lead plus identity/security owner. Add the privacy owner if private information appeared. Email landing in spam is a separate delivery problem; do not treat it as proof of this permission failure. + ## Admin screens — NOT AVAILABLE YET Admin event and product editors exist in source, but their live permissions, backup, preview, and rollback behavior have not been approved. Saving can write directly to production Firestore. Officers must not use these screens as a continuity procedure yet. diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index b1e36a83..282bd7fd 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -82,6 +82,25 @@ flowchart TD In words: people use their own accounts, at least two officers cover every service, and recovery information stays in the approved password manager rather than the repository or AI. +## Member profile setup and recovery — NOT LIVE YET + +```mermaid +flowchart TD + SignIn["Member signs in"] --> Ensure["Server checks this member's profile"] + Ensure --> Existing{"Profile already exists?"} + Existing -- "Yes" --> Preserve["Keep every field and role unchanged"] + Existing -- "No" --> Pending["Create one pending profile"] + Preserve --> Read["Website reads through normal Firebase permissions"] + Pending --> Read + Read -- "Read succeeds" --> Edit["Member may edit name and phone only"] + Read -- "Setup or read fails" --> Safe["Edit stays hidden; Try again or sign out"] + Ensure -. "does not grant, remove, or change" .-> Access["Membership, payment, discount, or admin access"] +``` + +In words: a signed-in member gets their existing profile unchanged or one new pending profile; the website permits only name and phone editing after the normal permission check succeeds. The repair does not change actual access. If displayed profile status and actual access disagree, stop and escalate. + +Issue [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) owns this repair. Source code and local tests do not make it live. The website, server Function, database permissions, and made-up live check must each be proven separately. + ## Emergency decision ```mermaid diff --git a/firestore.rules b/firestore.rules index d4c256d3..8991eecb 100644 --- a/firestore.rules +++ b/firestore.rules @@ -256,8 +256,8 @@ service cloud.firestore { } // A signed-in user can read and update their own member doc. - // Member docs are CREATED only by the onSignUp Cloud Function (Admin SDK); - // there is intentionally no client `create` rule. + // Member docs are created only by the create-once signup or recovery + // Cloud Functions (Admin SDK); there is intentionally no client `create` rule. match /members/{uid} { // Admin screens may enumerate profiles, but role and profile creation // remain server-managed. In particular, an admin claim in a browser is diff --git a/functions/ensureMemberProfile.js b/functions/ensureMemberProfile.js new file mode 100644 index 00000000..77d8d655 --- /dev/null +++ b/functions/ensureMemberProfile.js @@ -0,0 +1,59 @@ +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const { requireAppCheck } = require('./stripeHelpers'); +const { + ensureMemberProfileDocument, + memberProfileExists, +} = require('./memberProfile'); + +function isEmptyRequest(data) { + if (data == null) return true; + return typeof data === 'object' + && !Array.isArray(data) + && Object.keys(data).length === 0; +} + +exports.ensureMemberProfile = functions.https.onCall(async (data, context) => { + requireAppCheck(context); + + if (!context.auth) { + throw new functions.https.HttpsError( + 'unauthenticated', + 'Sign-in required.', + ); + } + if (!isEmptyRequest(data)) { + throw new functions.https.HttpsError( + 'invalid-argument', + 'Profile setup request is invalid.', + ); + } + + try { + // A valid existing profile stays usable during a temporary Auth Admin + // outage. The transaction below still rechecks existence after this read. + if (await memberProfileExists(context.auth.uid)) return { ready: true }; + + // The callable runtime verifies the ID token. Reading the Auth record here + // also keeps email, phone, provider, and verification state out of caller + // controlled request data. + const user = await admin.auth().getUser(context.auth.uid); + await ensureMemberProfileDocument(user); + return { ready: true }; + } catch { + // A concurrent trigger/callable may have created the record while the + // Auth lookup failed. Recognize that safe final state before failing. + try { + if (await memberProfileExists(context.auth.uid)) return { ready: true }; + } catch { + // Use the same generic error for Auth and Firestore unavailability. + } + // Do not return or log Auth/Firestore details, profile data, or identifiers. + throw new functions.https.HttpsError( + 'unavailable', + 'Profile setup is temporarily unavailable.', + ); + } +}); + +exports.isEmptyRequest = isEmptyRequest; diff --git a/functions/ensureMemberProfile.test.js b/functions/ensureMemberProfile.test.js new file mode 100644 index 00000000..67380576 --- /dev/null +++ b/functions/ensureMemberProfile.test.js @@ -0,0 +1,299 @@ +jest.mock('firebase-admin', () => { + const documents = new Map(); + let transactionTail = Promise.resolve(); + + const doc = (uid) => { + const ref = { + path: `members/${uid}`, + get: jest.fn(async () => ({ + exists: documents.has(ref.path), + data: () => documents.get(ref.path), + })), + }; + return ref; + }; + const firestoreApi = { + collection: jest.fn((name) => { + if (name !== 'members') throw new Error(`Unexpected collection: ${name}`); + return { doc }; + }), + runTransaction: jest.fn((handler) => { + const run = transactionTail.then(() => handler({ + get: jest.fn(async (ref) => ({ + exists: documents.has(ref.path), + data: () => documents.get(ref.path), + })), + create: jest.fn((ref, data) => { + if (documents.has(ref.path)) throw new Error('already exists'); + documents.set(ref.path, data); + }), + })); + transactionTail = run.catch(() => undefined); + return run; + }), + }; + + const getUser = jest.fn(); + const setCustomUserClaims = jest.fn().mockResolvedValue(undefined); + const timestamp = { _seconds: 1_800_000_000 }; + + return { + auth: jest.fn(() => ({ getUser, setCustomUserClaims })), + firestore: jest.fn(() => firestoreApi), + __reset: () => { + documents.clear(); + transactionTail = Promise.resolve(); + getUser.mockReset(); + setCustomUserClaims.mockClear(); + firestoreApi.collection.mockClear(); + firestoreApi.runTransaction.mockClear(); + }, + __seed: (uid, data) => documents.set(`members/${uid}`, data), + __read: (uid) => documents.get(`members/${uid}`), + __mocks: { getUser, setCustomUserClaims, firestoreApi }, + __timestamp: timestamp, + }; +}); + +jest.mock('firebase-admin/firestore', () => { + const admin = require('firebase-admin'); + return { Timestamp: { now: jest.fn(() => admin.__timestamp) } }; +}); + +jest.mock('firebase-functions', () => { + class HttpsError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + } + + return { + auth: { user: () => ({ onCreate: (handler) => handler }) }, + https: { + onCall: (handler) => handler, + HttpsError, + }, + }; +}); + +jest.mock('./stripeHelpers', () => ({ + requireAppCheck: jest.fn(), +})); + +const admin = require('firebase-admin'); +const { requireAppCheck } = require('./stripeHelpers'); + +const AUTH_USER = { + uid: 'caller-uid', + email: 'member@example.com', + emailVerified: true, + displayName: 'Synthetic Member', + phoneNumber: '+16505550123', + providerData: [{ providerId: 'password' }], +}; + +const CONTEXT = { + auth: { uid: AUTH_USER.uid, token: { email: AUTH_USER.email } }, + app: { appId: 'synthetic-app' }, +}; + +describe('ensureMemberProfile callable', () => { + beforeEach(() => { + admin.__reset(); + requireAppCheck.mockReset(); + admin.__mocks.getUser.mockResolvedValue(AUTH_USER); + }); + + test('creates one exact pending profile from the authenticated Firebase user', async () => { + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + await expect(ensureMemberProfile({}, CONTEXT)).resolves.toEqual({ ready: true }); + + expect(requireAppCheck).toHaveBeenCalledWith(CONTEXT); + expect(admin.__mocks.getUser).toHaveBeenCalledWith(AUTH_USER.uid); + expect(admin.__read(AUTH_USER.uid)).toEqual({ + fullName: 'Synthetic Member', + email: 'member@example.com', + createdAt: admin.__timestamp, + lastLogin: admin.__timestamp, + phoneNumber: '+16505550123', + role: 'unverified', + emailVerified: true, + provider: 'password', + }); + expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, {}])('accepts an empty request shape: %p', async (data) => { + const { ensureMemberProfile } = require('./ensureMemberProfile'); + await expect(ensureMemberProfile(data, CONTEXT)).resolves.toEqual({ ready: true }); + }); + + test('retries and concurrent calls leave one unchanged profile', async () => { + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + const results = await Promise.all([ + ensureMemberProfile({}, CONTEXT), + ensureMemberProfile({}, CONTEXT), + ensureMemberProfile({}, CONTEXT), + ]); + + expect(results).toEqual([ + { ready: true }, + { ready: true }, + { ready: true }, + ]); + const original = admin.__read(AUTH_USER.uid); + await expect(ensureMemberProfile({}, CONTEXT)).resolves.toEqual({ ready: true }); + expect(admin.__read(AUTH_USER.uid)).toBe(original); + }); + + test('recognizes an existing privileged profile without changing any field', async () => { + const existing = { + email: AUTH_USER.email, + fullName: 'Existing Name', + phoneNumber: 'existing-phone', + role: 'admin', + customField: 'preserve-me', + }; + admin.__seed(AUTH_USER.uid, existing); + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + admin.__mocks.getUser.mockRejectedValue(new Error('Auth temporarily unavailable')); + + await expect(ensureMemberProfile({}, CONTEXT)).resolves.toEqual({ ready: true }); + expect(admin.__read(AUTH_USER.uid)).toBe(existing); + expect(admin.__mocks.getUser).not.toHaveBeenCalled(); + expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled(); + }); + + test('bounds Auth-derived editable fields without truncating identity data', async () => { + admin.__mocks.getUser.mockResolvedValue({ + ...AUTH_USER, + email: ' Member@Example.COM ', + displayName: '🏃'.repeat(101), + phoneNumber: '📞'.repeat(21), + }); + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + await ensureMemberProfile({}, CONTEXT); + + expect(admin.__read(AUTH_USER.uid)).toMatchObject({ + email: 'member@example.com', + fullName: null, + phoneNumber: '', + role: 'unverified', + }); + expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled(); + }); + + test('preserves Auth-derived fields at the exact Unicode boundaries', async () => { + const displayName = '🏃'.repeat(100); + const phoneNumber = '📞'.repeat(20); + admin.__mocks.getUser.mockResolvedValue({ + ...AUTH_USER, + displayName, + phoneNumber, + }); + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + await ensureMemberProfile({}, CONTEXT); + + expect(admin.__read(AUTH_USER.uid)).toMatchObject({ + fullName: displayName, + phoneNumber, + role: 'unverified', + }); + }); + + test.each([ + [{ uid: 'another-user' }], + [{ email: 'injected@example.com' }], + [{ role: 'admin' }], + [{ membershipPaid: true }], + [[]], + ['unexpected'], + ])('rejects caller-supplied profile or authority data: %p', async (data) => { + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + await expect(ensureMemberProfile(data, CONTEXT)).rejects.toMatchObject({ + code: 'invalid-argument', + message: 'Profile setup request is invalid.', + }); + expect(admin.__mocks.getUser).not.toHaveBeenCalled(); + }); + + test('rejects anonymous callers before looking up an account', async () => { + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + await expect(ensureMemberProfile({}, { auth: null })).rejects.toMatchObject({ + code: 'unauthenticated', + message: 'Sign-in required.', + }); + expect(admin.__mocks.getUser).not.toHaveBeenCalled(); + }); + + test('preserves the shared App Check rejection before any profile work', async () => { + const error = Object.assign(new Error('app check rejected'), { + code: 'failed-precondition', + }); + requireAppCheck.mockImplementation(() => { throw error; }); + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + await expect(ensureMemberProfile({}, CONTEXT)).rejects.toBe(error); + expect(admin.__mocks.getUser).not.toHaveBeenCalled(); + }); + + test('returns a generic unavailable error without leaking provider details', async () => { + admin.__mocks.getUser.mockRejectedValue( + new Error('auth/user-not-found for member@example.com'), + ); + const { ensureMemberProfile } = require('./ensureMemberProfile'); + + let caught; + try { + await ensureMemberProfile({}, CONTEXT); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ + code: 'unavailable', + message: 'Profile setup is temporarily unavailable.', + }); + expect(caught.message).not.toContain('member@example.com'); + expect(caught.message).not.toContain('auth/user-not-found'); + }); +}); + +describe('signup profile compatibility', () => { + beforeEach(() => { + admin.__reset(); + }); + + test('the signup trigger does not overwrite a profile created by recovery', async () => { + const existing = { + email: AUTH_USER.email, + fullName: 'Member-entered name', + phoneNumber: 'member-entered phone', + role: 'member', + }; + admin.__seed(AUTH_USER.uid, existing); + const { onSignUp } = require('./signup'); + + await onSignUp(AUTH_USER); + + expect(admin.__read(AUTH_USER.uid)).toBe(existing); + expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled(); + }); + + test('the signup trigger never changes claims when it creates the profile', async () => { + const { onSignUp } = require('./signup'); + + await onSignUp(AUTH_USER); + + expect(admin.__read(AUTH_USER.uid)).toMatchObject({ role: 'unverified' }); + expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/index.js b/functions/index.js index 1bace654..f39db06b 100644 --- a/functions/index.js +++ b/functions/index.js @@ -3,6 +3,7 @@ const admin = require('firebase-admin'); admin.initializeApp(); const { onSignUp } = require('./signup'); +const { ensureMemberProfile } = require('./ensureMemberProfile'); const { updateMemberRole } = require('./updatemembers'); const { createCheckoutSession } = require('./createCheckoutSession'); const { stripeWebhook } = require('./stripeWebhook'); @@ -27,6 +28,7 @@ const { // Auth triggers exports.createMemberOnSignUp = onSignUp; +exports.ensureMemberProfile = ensureMemberProfile; // HTTP endpoints exports.updateMemberRole = updateMemberRole; diff --git a/functions/memberProfile.emulator.test.js b/functions/memberProfile.emulator.test.js new file mode 100644 index 00000000..7bf5fc63 --- /dev/null +++ b/functions/memberProfile.emulator.test.js @@ -0,0 +1,58 @@ +const admin = require('firebase-admin'); +const { ensureMemberProfileDocument } = require('./memberProfile'); + +const describeWithEmulator = process.env.FIRESTORE_EMULATOR_HOST + ? describe + : describe.skip; + +describeWithEmulator('member profile Firestore transaction', () => { + const uid = 'synthetic-concurrency-user'; + let db; + + beforeAll(() => { + if (!admin.apps.length) { + admin.initializeApp({ projectId: 'demo-functions-test' }); + } + db = admin.firestore(); + }); + + beforeEach(async () => { + await db.collection('members').doc(uid).delete(); + }); + + afterAll(async () => { + await db.collection('members').doc(uid).delete(); + await Promise.all(admin.apps.map((app) => app.delete())); + }); + + test('parallel retries create once and never overwrite the final record', async () => { + const syntheticUser = { + uid, + email: 'synthetic@example.test', + emailVerified: true, + displayName: 'Synthetic Runner', + phoneNumber: '+16505550123', + providerData: [{ providerId: 'password' }], + }; + + const results = await Promise.all( + Array.from({ length: 12 }, () => ensureMemberProfileDocument(syntheticUser)), + ); + expect(results.filter((result) => result.created)).toHaveLength(1); + + const ref = db.collection('members').doc(uid); + await ref.update({ + fullName: 'Member-edited name', + phoneNumber: 'Member-edited phone', + role: 'member', + }); + const beforeRetry = (await ref.get()).data(); + + await expect(ensureMemberProfileDocument({ + ...syntheticUser, + displayName: 'Must not replace member data', + })).resolves.toEqual({ created: false }); + + expect((await ref.get()).data()).toEqual(beforeRetry); + }); +}); diff --git a/functions/memberProfile.js b/functions/memberProfile.js new file mode 100644 index 00000000..65a79cfb --- /dev/null +++ b/functions/memberProfile.js @@ -0,0 +1,73 @@ +const admin = require('firebase-admin'); +const { Timestamp } = require('firebase-admin/firestore'); + +const DEFAULT_ROLE = 'unverified'; +const FULL_NAME_MAX_CHARACTERS = 200; +const PHONE_NUMBER_MAX_CHARACTERS = 40; + +function boundedIdentityString(value, maxCharacters, fallback) { + if (typeof value !== 'string') return fallback; + const normalized = value.trim(); + if (!normalized || normalized.length > maxCharacters) return fallback; + return normalized; +} + +function buildPendingMemberProfile(user, now = Timestamp.now()) { + return { + fullName: boundedIdentityString( + user.displayName, + FULL_NAME_MAX_CHARACTERS, + null, + ), + email: typeof user.email === 'string' ? user.email.trim().toLowerCase() : '', + createdAt: now, + lastLogin: now, + phoneNumber: boundedIdentityString( + user.phoneNumber, + PHONE_NUMBER_MAX_CHARACTERS, + '', + ), + role: DEFAULT_ROLE, + emailVerified: user.emailVerified === true, + provider: user.providerData?.[0]?.providerId || 'unknown', + }; +} + +function memberProfileRef(uid) { + return admin.firestore().collection('members').doc(uid); +} + +async function memberProfileExists(uid) { + const existing = await memberProfileRef(uid).get(); + return existing.exists; +} + +async function ensureMemberProfileDocument(user) { + if (!user || typeof user.uid !== 'string' || !user.uid) { + throw new Error('Authenticated Firebase user is unavailable.'); + } + + const db = admin.firestore(); + const ref = memberProfileRef(user.uid); + + const created = await db.runTransaction(async (transaction) => { + const existing = await transaction.get(ref); + if (existing.exists) return false; + + // `create` and the preceding transactional read make this safe when the + // Auth trigger, account page, or a retry all arrive at the same time. + transaction.create(ref, buildPendingMemberProfile(user)); + return true; + }); + + return { created }; +} + +module.exports = { + DEFAULT_ROLE, + FULL_NAME_MAX_CHARACTERS, + PHONE_NUMBER_MAX_CHARACTERS, + buildPendingMemberProfile, + memberProfileExists, + ensureMemberProfileDocument, +}; diff --git a/functions/signup.js b/functions/signup.js index 236e1e74..468630da 100644 --- a/functions/signup.js +++ b/functions/signup.js @@ -1,53 +1,20 @@ const functions = require('firebase-functions'); -const admin = require('firebase-admin'); -const { Timestamp } = require('firebase-admin/firestore'); - -const DEFAULT_ROLE = 'unverified'; +const { ensureMemberProfileDocument } = require('./memberProfile'); /** * Creates a new member document in Firestore when a user signs up */ async function createMemberDocument(user) { - const membersRef = admin.firestore().collection('members'); - - const memberData = { - fullName: user.displayName || null, - email: user.email || '', - createdAt: Timestamp.now(), - lastLogin: Timestamp.now(), - phoneNumber: user.phoneNumber || '', - role: DEFAULT_ROLE, - emailVerified: user.emailVerified || false, - provider: user.providerData?.[0]?.providerId || 'unknown', - }; - - await membersRef.doc(user.uid).set(memberData); - - return memberData; -} - -/** - * Sets the default role claim for a new user - */ -async function setDefaultRoleClaim(uid) { - await admin.auth().setCustomUserClaims(uid, { role: DEFAULT_ROLE }); + return ensureMemberProfileDocument(user); } exports.onSignUp = functions.auth.user().onCreate(async (user) => { try { - const [memberData] = await Promise.all([ - createMemberDocument(user), - setDefaultRoleClaim(user.uid), - ]); - - console.log(`Created member for user ${user.uid}:`, { - email: user.email, - role: DEFAULT_ROLE, - }); - - return memberData; - } catch (error) { - console.error(`Failed to create member for user ${user.uid}:`, error); + // Missing claims already fail closed. Only the reviewed role-grant path may + // change them; a delayed signup trigger must never replace a claim map. + return await createMemberDocument(user); + } catch { + console.error('Member profile setup failed during account creation.'); throw new functions.https.HttpsError( 'internal', 'Failed to create member record', diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx new file mode 100644 index 00000000..60e813d1 --- /dev/null +++ b/src/pages/account/Account.test.tsx @@ -0,0 +1,227 @@ +/* eslint-env jest */ + +import React from 'react'; +import { + fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { useServiceLocator } from '../../services/ServiceLocatorContext'; +import { + ensureMyProfile, + getMyProfile, + listMyRegistrations, + updateMyProfile, +} from '../../services/account/accountService'; +import { AccountContent } from './Account'; + +jest.mock('../../services/ServiceLocatorContext', () => ({ + useServiceLocator: jest.fn(), +})); + +jest.mock('../../services/account/accountService', () => { + const actual = jest.requireActual('../../services/account/accountService'); + return { + ...actual, + ensureMyProfile: jest.fn(), + getMyProfile: jest.fn(), + listMyRegistrations: jest.fn(), + updateMyProfile: jest.fn(), + }; +}); + +jest.mock('../../components/SEO', () => function SEO() { + return null; +}); + +jest.mock('./StravaSection', () => function StravaSection() { + return
; +}); + +const USER = { + uid: 'synthetic-user', + email: 'member@example.com', + role: 'unverified' as const, +}; + +const PROFILE = { + uid: USER.uid, + email: USER.email, + fullName: 'Synthetic Member', + role: 'unverified' as const, + phoneNumber: '555-0100', + emailVerified: false, + provider: 'password', + createdAt: null, + lastLogin: null, + updatedAt: null, +}; + +const app = { name: 'synthetic-app' }; +const firestore = { name: 'synthetic-firestore' }; +const signOut = jest.fn(); + +function renderAccount() { + return render( + + + , + ); +} + +describe('Account profile recovery', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useServiceLocator as jest.Mock).mockReturnValue({ + services: { + firebaseResources: { app, firestore }, + identityService: { signOut, resendVerificationEmail: jest.fn() }, + }, + isReady: true, + }); + (ensureMyProfile as jest.Mock).mockResolvedValue({ ready: true }); + (getMyProfile as jest.Mock).mockResolvedValue(PROFILE); + (listMyRegistrations as jest.Mock).mockResolvedValue({ + registrations: [], + events: {}, + }); + (updateMyProfile as jest.Mock).mockResolvedValue(undefined); + }); + + test('ensures the caller profile before the first profile read', async () => { + const calls: string[] = []; + (ensureMyProfile as jest.Mock).mockImplementation(async () => { + calls.push('ensure'); + return { ready: true }; + }); + (getMyProfile as jest.Mock).mockImplementation(async () => { + calls.push('read'); + return PROFILE; + }); + + renderAccount(); + + expect(await screen.findByText('Synthetic Member')).toBeInTheDocument(); + expect(calls).toEqual(['ensure', 'read']); + expect(ensureMyProfile).toHaveBeenCalledWith(app); + expect(getMyProfile).toHaveBeenCalledWith(firestore, USER.uid); + }); + + test('shows a generic recovery state and disables editing when setup fails', async () => { + (ensureMyProfile as jest.Mock) + .mockRejectedValueOnce(new Error('Missing or insufficient permissions.')) + .mockResolvedValueOnce({ ready: true }); + + renderAccount(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your profile is temporarily unavailable.', + ); + expect(screen.queryByText('Missing or insufficient permissions.')) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + expect(getMyProfile).not.toHaveBeenCalled(); + expect(listMyRegistrations).not.toHaveBeenCalled(); + expect(screen.queryByTestId('strava-section')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Try profile again' })); + + expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(ensureMyProfile).toHaveBeenCalledTimes(2); + }); + + test('treats an absent record after setup as unavailable and keeps Edit hidden', async () => { + (getMyProfile as jest.Mock).mockResolvedValue(null); + + renderAccount(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your profile is temporarily unavailable.', + ); + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + }); + + test('shows the same name and phone limits as the Firestore rules', async () => { + renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + + expect(screen.getByLabelText('Full name')).toHaveAccessibleDescription( + 'Up to 200 characters.', + ); + expect(screen.getByLabelText('Phone')).toHaveAccessibleDescription( + 'Up to 40 characters.', + ); + expect(screen.getByLabelText('Full name')).toHaveAttribute('autocomplete', 'name'); + expect(screen.getByLabelText('Phone')).toHaveAttribute('autocomplete', 'tel'); + expect(screen.getByLabelText('Full name')).toHaveAttribute('maxLength', '200'); + expect(screen.getByLabelText('Phone')).toHaveAttribute('maxLength', '40'); + }); + + test('uses a safe state when the profile read fails after setup', async () => { + (getMyProfile as jest.Mock).mockRejectedValue( + new Error('FirebaseError: wrong project or rules mismatch'), + ); + + renderAccount(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Your profile is temporarily unavailable.', + ); + expect(screen.queryByText(/wrong project|rules mismatch/i)).not.toBeInTheDocument(); + expect(listMyRegistrations).not.toHaveBeenCalled(); + expect(screen.queryByTestId('strava-section')).not.toBeInTheDocument(); + }); + + test('keeps a validation error local and does not call Firestore', async () => { + renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Full name'), { + target: { value: '🏃'.repeat(101) }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Full name must be 200 characters or fewer.', + ); + expect(updateMyProfile).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + test('does not expose a raw save error or leave editing enabled after a failed write', async () => { + (updateMyProfile as jest.Mock).mockRejectedValue( + new Error('FirebaseError: Missing or insufficient permissions.'), + ); + renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Full name'), { + target: { value: 'Updated Synthetic Member' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'We could not confirm your profile change.', + ); + expect(screen.queryByText(/FirebaseError|Missing or insufficient permissions/i)) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledWith( + firestore, + USER.uid, + { fullName: 'Updated Synthetic Member', phoneNumber: '555-0100' }, + )); + }); + + test('fails closed when a save succeeds but the confirmation read is missing', async () => { + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockResolvedValueOnce(null); + renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'We could not confirm your profile change.', + ); + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('strava-section')).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/account/Account.tsx b/src/pages/account/Account.tsx index cde9795a..51cdaf5e 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -6,9 +6,12 @@ import { useServiceLocator } from '../../services/ServiceLocatorContext'; import { useAuth } from '../../services/hooks/useAuth'; import { Member } from '../../types/member'; import { + ensureMyProfile, getMyProfile, updateMyProfile, listMyRegistrations, + MEMBER_PROFILE_LIMITS, + validateMemberProfileFields, MyRegistrationsResponse, } from '../../services/account/accountService'; import { formatEventDate, formatPrice } from '../../services/events/eventsService'; @@ -112,10 +115,21 @@ function RegistrationRow({ ); } -function AccountContent({ user }: { user: NonNullable['user']> }) { +const PROFILE_UNAVAILABLE_MESSAGE = 'Your profile is temporarily unavailable. Sign out and try again later. No membership or payment status was changed.'; +const PROFILE_CHANGE_UNCONFIRMED_MESSAGE = 'We could not confirm your profile change. Try the profile again before making another change.'; + +export function AccountContent({ + user, +}: { + user: NonNullable['user']>; +}) { const { services } = useServiceLocator(); const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); + const [profileState, setProfileState] = useState<'loading' | 'ready' | 'unavailable'>( + 'loading', + ); + const [profileLoadAttempt, setProfileLoadAttempt] = useState(0); + const [profileError, setProfileError] = useState(null); const [editing, setEditing] = useState(false); const [fullName, setFullName] = useState(''); const [phoneNumber, setPhoneNumber] = useState(''); @@ -127,39 +141,89 @@ function AccountContent({ user }: { user: NonNullable const [regsError, setRegsError] = useState(null); useEffect(() => { - if (!services) return; - getMyProfile(services.firebaseResources.firestore, user.uid) - .then((p) => { - setProfile(p); - setFullName(p?.fullName || ''); - setPhoneNumber(p?.phoneNumber || ''); - setLoading(false); - }) - .catch(() => setLoading(false)); - }, [services, user.uid]); + if (!services) return undefined; + const activeServices = services; + let active = true; + + async function loadProfile() { + setProfileState('loading'); + setProfile(null); + setProfileError(null); + setEditing(false); + setSaveError(null); + + try { + await ensureMyProfile(activeServices.firebaseResources.app); + const nextProfile = await getMyProfile( + activeServices.firebaseResources.firestore, + user.uid, + ); + if (!nextProfile) throw new Error('Profile unavailable after setup.'); + if (!active) return; + setProfile(nextProfile); + setFullName(nextProfile.fullName || ''); + setPhoneNumber(nextProfile.phoneNumber || ''); + setProfileState('ready'); + } catch { + if (!active) return; + setProfileError(PROFILE_UNAVAILABLE_MESSAGE); + setProfileState('unavailable'); + } + } + + loadProfile(); + return () => { active = false; }; + }, [services, user.uid, profileLoadAttempt]); useEffect(() => { - if (!services) return; + if (!services || profileState !== 'ready') return undefined; + let active = true; + setRegsData(null); + setRegsError(null); + setRegsLoading(true); listMyRegistrations(services.firebaseResources.app) - .then((r) => { setRegsData(r); setRegsLoading(false); }) - .catch((err) => { setRegsError(err.message); setRegsLoading(false); }); - }, [services]); + .then((result) => { + if (!active) return; + setRegsData(result); + setRegsLoading(false); + }) + .catch(() => { + if (!active) return; + setRegsError('We could not load your registrations right now.'); + setRegsLoading(false); + }); + return () => { active = false; }; + }, [services, profileState, user.uid]); async function handleSave() { if (!services) return; + const validation = validateMemberProfileFields({ fullName, phoneNumber }); + if (!validation.valid) { + setSaveError(validation.message); + return; + } setSaving(true); setSaveError(null); try { await updateMyProfile( services.firebaseResources.firestore, user.uid, - { fullName, phoneNumber }, + validation.fields, ); const fresh = await getMyProfile(services.firebaseResources.firestore, user.uid); + if (!fresh) throw new Error('Profile unavailable after save.'); setProfile(fresh); + setFullName(fresh.fullName || ''); + setPhoneNumber(fresh.phoneNumber || ''); + setProfileError(null); + setProfileState('ready'); setEditing(false); - } catch (err: any) { - setSaveError(err?.message || 'Save failed'); + } catch { + setProfile(null); + setEditing(false); + setSaveError(null); + setProfileError(PROFILE_CHANGE_UNCONFIRMED_MESSAGE); + setProfileState('unavailable'); } finally { setSaving(false); } @@ -170,7 +234,13 @@ function AccountContent({ user }: { user: NonNullable await services.identityService.signOut(); } - if (loading) return
Loading profile...
; + if (profileState === 'loading') { + return ( +
+ Loading profile... +
+ ); + } const upcoming = regsData?.registrations.filter((r) => { const ev = regsData.events[r.eventId]; @@ -200,7 +270,7 @@ function AccountContent({ user }: { user: NonNullable

Profile

- {!editing && ( + {!editing && profileState === 'ready' && profile && ( )}
- {!editing && profile && ( + {profileError && ( +
+

{profileError}

+ +
+ )} + {!editing && profileState === 'ready' && profile && (
Name
@@ -243,25 +328,55 @@ function AccountContent({ user }: { user: NonNullable )} {editing && (
- - - {saveError &&

{saveError}

} +
+ + + Up to + {' '} + {MEMBER_PROFILE_LIMITS.fullName} + {' '} + characters. + +
+
+ + + Up to + {' '} + {MEMBER_PROFILE_LIMITS.phoneNumber} + {' '} + characters. + +
+ {saveError && ( +

{saveError}

+ )}
-
-

Upcoming events

- {regsLoading &&

Loading...

} - {regsError &&

{regsError}

} - {!regsLoading && upcoming.length === 0 && ( -

- You haven't registered for any upcoming events. - {' '} - - Browse events - - . -

- )} - {upcoming.map((r) => ( - - ))} -
+ {profileState === 'ready' && ( +
+

Upcoming events

+ {regsLoading &&

Loading...

} + {regsError &&

{regsError}

} + {!regsLoading && upcoming.length === 0 && ( +

+ You haven't registered for any upcoming events. + {' '} + + Browse events + + . +

+ )} + {upcoming.map((r) => ( + + ))} +
+ )} - {past.length > 0 && ( + {profileState === 'ready' && past.length > 0 && (

Past events

{past.map((r) => ( @@ -336,7 +454,7 @@ function AccountContent({ user }: { user: NonNullable
)} - + {profileState === 'ready' && }
); diff --git a/src/services/account/accountService.test.ts b/src/services/account/accountService.test.ts new file mode 100644 index 00000000..2d9b6d46 --- /dev/null +++ b/src/services/account/accountService.test.ts @@ -0,0 +1,96 @@ +/* eslint-env jest */ + +import { + doc, serverTimestamp, updateDoc, +} from 'firebase/firestore'; +import { getFunctions, httpsCallable } from 'firebase/functions'; +import { + ensureMyProfile, + updateMyProfile, + validateMemberProfileFields, +} from './accountService'; + +jest.mock('firebase/firestore', () => ({ + doc: jest.fn(() => ({ path: 'members/synthetic-user' })), + getDoc: jest.fn(), + updateDoc: jest.fn(), + serverTimestamp: jest.fn(() => ({ __serverTimestamp: true })), +})); + +jest.mock('firebase/functions', () => ({ + getFunctions: jest.fn(() => ({ name: 'synthetic-functions' })), + httpsCallable: jest.fn(), +})); + +const firestore = { name: 'synthetic-firestore' } as any; +const app = { name: 'synthetic-app' } as any; + +describe('account profile service', () => { + beforeEach(() => { + jest.clearAllMocks(); + (doc as jest.Mock).mockReturnValue({ path: 'members/synthetic-user' }); + (serverTimestamp as jest.Mock).mockReturnValue({ __serverTimestamp: true }); + (getFunctions as jest.Mock).mockReturnValue({ name: 'synthetic-functions' }); + (updateDoc as jest.Mock).mockResolvedValue(undefined); + }); + + test('calls the profile bootstrap with an empty request and returns uniform readiness', async () => { + const callable = jest.fn().mockResolvedValue({ data: { ready: true } }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ensureMyProfile(app)).resolves.toEqual({ ready: true }); + + expect(getFunctions).toHaveBeenCalledWith(app); + expect(httpsCallable).toHaveBeenCalledWith( + { name: 'synthetic-functions' }, + 'ensureMemberProfile', + ); + expect(callable).toHaveBeenCalledWith({}); + }); + + test('writes only normalized name, phone, and a server timestamp', async () => { + await updateMyProfile(firestore, 'synthetic-user', { + fullName: ' Synthetic Member ', + phoneNumber: ' 555-0100 ', + }); + + expect(doc).toHaveBeenCalledWith(firestore, 'members', 'synthetic-user'); + expect(updateDoc).toHaveBeenCalledWith( + { path: 'members/synthetic-user' }, + { + fullName: 'Synthetic Member', + phoneNumber: '555-0100', + updatedAt: { __serverTimestamp: true }, + }, + ); + expect(serverTimestamp).toHaveBeenCalledTimes(1); + }); + + test('accepts the exact 200 and 40 unit boundaries, including emoji', async () => { + const fullName = '🏃'.repeat(100); + const phoneNumber = '📞'.repeat(20); + + expect(validateMemberProfileFields({ fullName, phoneNumber })).toEqual({ + valid: true, + fields: { fullName, phoneNumber }, + }); + await expect(updateMyProfile( + firestore, + 'synthetic-user', + { fullName, phoneNumber }, + )).resolves.toBeUndefined(); + expect(updateDoc).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['an ASCII name', { fullName: 'n'.repeat(201), phoneNumber: '' }, 'Full name'], + ['an emoji name', { fullName: '🏃'.repeat(101), phoneNumber: '' }, 'Full name'], + ['an ASCII phone', { fullName: '', phoneNumber: '1'.repeat(41) }, 'Phone'], + ['an emoji phone', { fullName: '', phoneNumber: '📞'.repeat(21) }, 'Phone'], + ])('rejects %s over the Rules limit before Firestore', async (_label, fields, field) => { + await expect(updateMyProfile(firestore, 'synthetic-user', fields)) + .rejects.toThrow(`${field} must be`); + expect(updateDoc).not.toHaveBeenCalled(); + expect(serverTimestamp).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/account/accountService.ts b/src/services/account/accountService.ts index 07ba6723..40f12134 100644 --- a/src/services/account/accountService.ts +++ b/src/services/account/accountService.ts @@ -5,6 +5,46 @@ import { FirebaseApp } from 'firebase/app'; import { getFunctions, httpsCallable } from 'firebase/functions'; import { Member, MemberEditableFields } from '../../types/member'; +export const MEMBER_PROFILE_LIMITS = { + fullName: 200, + phoneNumber: 40, +} as const; + +export interface ValidatedMemberProfileFields { + fullName: string; + phoneNumber: string; +} + +export type MemberProfileValidation = + | { valid: true; fields: ValidatedMemberProfileFields } + | { valid: false; message: string }; + +export function validateMemberProfileFields( + fields: MemberEditableFields, +): MemberProfileValidation { + const fullName = fields.fullName.trim(); + const phoneNumber = fields.phoneNumber.trim(); + if (fullName.length > MEMBER_PROFILE_LIMITS.fullName) { + return { valid: false, message: 'Full name must be 200 characters or fewer.' }; + } + if (phoneNumber.length > MEMBER_PROFILE_LIMITS.phoneNumber) { + return { valid: false, message: 'Phone must be 40 characters or fewer.' }; + } + return { valid: true, fields: { fullName, phoneNumber } }; +} + +export async function ensureMyProfile( + app: FirebaseApp, +): Promise<{ ready: true }> { + const functions = getFunctions(app); + const callable = httpsCallable, { ready: true }>( + functions, + 'ensureMemberProfile', + ); + const result = await callable({}); + return result.data; +} + export async function getMyProfile(db: Firestore, uid: string): Promise { const ref = doc(db, 'members', uid); const snap = await getDoc(ref); @@ -29,10 +69,14 @@ export async function updateMyProfile( uid: string, fields: MemberEditableFields, ): Promise { + const validation = validateMemberProfileFields(fields); + if (!validation.valid) throw new Error(validation.message); + const { fullName, phoneNumber } = validation.fields; + const ref = doc(db, 'members', uid); await updateDoc(ref, { - fullName: fields.fullName.trim() || null, - phoneNumber: fields.phoneNumber.trim() || '', + fullName: fullName || null, + phoneNumber, updatedAt: serverTimestamp(), }); } diff --git a/tests/firestore-rules/members.test.js b/tests/firestore-rules/members.test.js index 3c407971..465571dd 100644 --- a/tests/firestore-rules/members.test.js +++ b/tests/firestore-rules/members.test.js @@ -97,6 +97,38 @@ describe('members collection', () => { })); }); + test('profile Unicode boundaries match the browser validator', async () => { + await seed('members/u1', SAMPLE_MEMBER); + const me = await db({ uid: 'u1', role: 'unverified' }); + + await assertSucceeds(me.doc('members/u1').update({ + // Firestore Rules and browser maxlength both count these astral + // symbols as two UTF-16 units. Keep the client validation aligned + // with the behavior proven by this emulator suite. + fullName: '🏃'.repeat(100), + phoneNumber: '📞'.repeat(20), + updatedAt: new Date(), + })); + }); + + test.each([ + ['a Unicode name over the 200-unit limit', { + fullName: '🏃'.repeat(101), + phoneNumber: '', + }], + ['a Unicode phone over the 40-unit limit', { + fullName: '', + phoneNumber: '📞'.repeat(21), + }], + ])('user CANNOT set %s', async (_label, fields) => { + await seed('members/u1', SAMPLE_MEMBER); + const me = await db({ uid: 'u1', role: 'unverified' }); + await assertFails(me.doc('members/u1').update({ + ...fields, + updatedAt: new Date(), + })); + }); + test('user CANNOT update a missing profile document', async () => { const me = await db({ uid: 'u1', role: 'unverified' }); await assertFails(me.doc('members/u1').update({