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( +{profileError}
+ +{saveError}
} +{saveError}
+ )}