diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index b0dbd998..43f2cbbb 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -233,7 +233,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 | Create-once signup/recovery Functions; self-service name/phone allowlist; server role operations | Confidential | +| `members/{uid}` | Profile and role mirror | Create-once signup/recovery Functions; self-service name-only allowlist while #178 pauses phone collection; 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 | @@ -328,7 +328,7 @@ sequenceDiagram W->>D: Read caller profile through Firestore Rules alt read succeeds D-->>W: Profile - W-->>M: Show profile and name/phone Edit + W-->>M: Show profile and name-only Edit else read fails D-->>W: Generic failure W-->>M: Hide Edit; show retry/sign-out path @@ -338,6 +338,8 @@ sequenceDiagram 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. +DATA-001C1 [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) pauses optional phone display and collection in My Account. The owner-profile projection omits `phoneNumber`, the client validates and writes only `fullName`, and the Rules source rejects every browser phone mutation while allowing an existing phone value to remain unchanged during a name edit. Firestore still authorizes and transports the owner's complete document at its document-level boundary; #116 retains the future server-projection work for broader administrative reads. There is no migration, deletion, export, Function/Auth change, Google Forms change, or provider action in this slice. The source is not live protection until the exact Rules are deployed and read back before the dependent website revision is published and verified. + 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 diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index f33dbdda..582c0b66 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -168,11 +168,11 @@ flowchart LR C -- "No" --> E["Create one pending profile"] D --> F["Read through normal permissions"] E --> F - F -- "Success" --> G["Edit name and phone"] + F -- "Success" --> G["Edit name only; phone entry paused"] 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. +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, and the temporary privacy pause permits name editing only. Safe officer steps: @@ -195,6 +195,47 @@ Safe officer steps: **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. +## My Account phone collection pause — SOURCE ONLY, NOT LIVE + +**Purpose:** stop My Account from accepting another phone number while the club reviews why it collects phone data, who can access it, how long it is kept, and whether the live Firebase boundary matches the reviewed source. + +**Approver:** membership lead plus privacy/platform owner. + +**Prerequisites:** claimed issue [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178), a private redacted incident record under #112, the authorized service inventory under #113, made-up test data, and the protected backend-first release path. Do not put a member's number, spam message, screenshot, or provider record in GitHub, email, or AI. + +```mermaid +flowchart LR + Account["Member opens My Account"] --> Read["Read this member's profile"] + Read --> Name["Display and edit name"] + Read --> Pause["Do not display or accept phone"] + Name --> Rules["Firebase Rules allow name-only update"] + PhoneAttempt["Browser tries a phone change"] --> Deny["Firebase Rules deny"] + Pause --> Existing["Existing stored value stays unchanged"] +``` + +In words: My Account shows and edits the member's name, does not display or accept a phone number, and leaves any existing stored value unchanged; the reviewed Rules deny a browser phone change. + +Officer steps after every prerequisite has proof: + +1. Tell members not to add a phone number in My Account. +2. Do not ask whether a member already has a number stored. +3. Do not copy a number or spam message into an issue, email, screenshot, or AI tool. +4. Keep the Google membership form, event registration, shop, and provider review separate; this source change does not alter them. +5. Ask the platform owner to identify the exact merged website and Rules revisions. +6. Require the reviewed Rules to deploy and pass readback before the website is published. +7. Use one made-up staged profile to prove name-only editing and phone-write denial. +8. Check the exact website revision on `runmprc.com` without opening a real member profile. + +**Expected result:** My Account contains no phone value, phone input, or phone browser autocomplete. A name-only change succeeds. A direct non-empty phone change is denied. Existing stored data, membership, payment status, and external forms/providers remain unchanged. + +**Stop conditions:** a real member profile, a provider Console change, a database export, a request to inspect or delete stored numbers, skipped/partial Rules deployment, website publication before Rules proof, or a proposal to treat spam timing as proof of a breach. + +**Success proof:** exact #178 pull request and merge commit; green synthetic frontend and Rules tests; exact Rules deployment/readback; later website publication record; separate `runmprc.com` revision check; and a dated made-up name-only/phone-denial check. Google, Firebase, Sentry, Stripe, and other provider evidence remains separate and private. + +**Undo:** use one reviewed revert or safe roll-forward through the same backend-first gate. Do not restore phone collection until #110 approves its purpose, notice, access, and retention, and #113/#133/#136 prove the intended live boundary. + +**Escalation:** membership lead plus privacy/platform owner; use the private incident path under #112 if exposure is suspected. + ## 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 282bd7fd..d9523624 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -92,14 +92,15 @@ flowchart TD 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 -- "Read succeeds" --> Edit["Member may edit name only"] + Read -- "Read succeeds" --> Phone["Phone display and entry paused\nexisting value unchanged"] 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. +In words: a signed-in member gets their existing profile unchanged or one new pending profile; the website permits only name editing after the normal permission check succeeds, while phone display and entry stay paused without changing an existing stored value. 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. +Issue [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) owns this repair, and [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) owns the temporary phone-collection pause. Source code and local tests do not make either live. The website, server Function, database permissions, and made-up live check must each be proven separately. ## Emergency decision diff --git a/firestore.rules b/firestore.rules index 7fc2ce07..d035cd37 100644 --- a/firestore.rules +++ b/firestore.rules @@ -265,7 +265,10 @@ service cloud.firestore { allow read: if request.auth != null && (request.auth.uid == uid || isAdmin()); - // Self-service edits are restricted to an explicit field allowlist. + // Self-service edits are restricted to a name-only field allowlist while + // optional phone collection is paused for privacy review. Existing phone + // values remain untouched; browser clients cannot add, replace, or clear + // them through this rule. // An allowlist (rather than pinning each protected field individually) // blocks BOTH tampering with managed fields — role, email, createdAt, // emailVerified, provider — AND injection of arbitrary new fields (e.g. @@ -274,14 +277,12 @@ service cloud.firestore { allow update: if request.auth != null && request.auth.uid == uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['fullName', 'phoneNumber', 'updatedAt']) + .hasOnly(['fullName', 'updatedAt']) && request.resource.data.diff(resource.data).affectedKeys() .hasAll(['updatedAt']) && (request.resource.data.fullName == null || (request.resource.data.fullName is string && request.resource.data.fullName.size() <= 200)) - && request.resource.data.phoneNumber is string - && request.resource.data.phoneNumber.size() <= 40 && request.resource.data.updatedAt is timestamp; // Third-party connections (Strava, etc). Users can read *whether* diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index 13d5d3b0..408b2ab8 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -50,7 +50,6 @@ const PROFILE = { email: USER.email, fullName: 'Synthetic Member', role: 'unverified' as const, - phoneNumber: '555-0100', emailVerified: false, provider: 'password', createdAt: null, @@ -153,20 +152,27 @@ describe('Account profile recovery', () => { expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); }); - test('shows the same name and phone limits as the Firestore rules', async () => { + test('pauses phone display and collection while keeping name editing available', async () => { + const legacyProfile = { + ...PROFILE, + phoneNumber: 'synthetic-phone-canary', + }; + (getMyProfile as jest.Mock).mockResolvedValue(legacyProfile); renderAccount(); + expect(await screen.findByText('Synthetic Member')).toBeInTheDocument(); + expect(screen.queryByText(legacyProfile.phoneNumber)).not.toBeInTheDocument(); + expect(screen.getByText(/phone collection is temporarily paused/i)) + .toBeInTheDocument(); + 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'); + expect(screen.queryByLabelText('Phone')).not.toBeInTheDocument(); + expect(document.querySelector('input[autocomplete="tel"]')).toBeNull(); }); test('uses a safe state when the profile read fails after setup', async () => { @@ -219,7 +225,7 @@ describe('Account profile recovery', () => { await waitFor(() => expect(updateMyProfile).toHaveBeenCalledWith( firestore, USER.uid, - { fullName: 'Updated Synthetic Member', phoneNumber: '555-0100' }, + { fullName: 'Updated Synthetic Member' }, )); }); diff --git a/src/pages/account/Account.tsx b/src/pages/account/Account.tsx index 2faea21b..c3b66863 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -6,7 +6,6 @@ import { Timestamp } from 'firebase/firestore'; import SEO from '../../components/SEO'; import { useServiceLocator } from '../../services/ServiceLocatorContext'; import { useAuth } from '../../services/hooks/useAuth'; -import { Member } from '../../types/member'; import { ensureMyProfile, getMyProfile, @@ -15,6 +14,7 @@ import { MEMBER_PROFILE_LIMITS, validateMemberProfileFields, MyRegistrationsResponse, + MyMemberProfile, } from '../../services/account/accountService'; import { formatEventDate, formatPrice } from '../../services/events/eventsService'; import StravaSection from './StravaSection'; @@ -230,7 +230,7 @@ export function AccountContent({ user: NonNullable['user']>; }) { const { services } = useServiceLocator(); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(null); const [profileState, setProfileState] = useState<'loading' | 'ready' | 'unavailable'>( 'loading', ); @@ -238,7 +238,6 @@ export function AccountContent({ const [profileError, setProfileError] = useState(null); const [editing, setEditing] = useState(false); const [fullName, setFullName] = useState(''); - const [phoneNumber, setPhoneNumber] = useState(''); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -268,7 +267,6 @@ export function AccountContent({ if (!active) return; setProfile(nextProfile); setFullName(nextProfile.fullName || ''); - setPhoneNumber(nextProfile.phoneNumber || ''); setProfileState('ready'); } catch { if (!active) return; @@ -303,7 +301,7 @@ export function AccountContent({ async function handleSave() { if (!services) return; - const validation = validateMemberProfileFields({ fullName, phoneNumber }); + const validation = validateMemberProfileFields({ fullName }); if (!validation.valid) { setSaveError(validation.message); return; @@ -320,7 +318,6 @@ export function AccountContent({ if (!fresh) throw new Error('Profile unavailable after save.'); setProfile(fresh); setFullName(fresh.fullName || ''); - setPhoneNumber(fresh.phoneNumber || ''); setProfileError(null); setProfileState('ready'); setEditing(false); @@ -414,10 +411,6 @@ export function AccountContent({ {profile.emailVerified ? '' : ' (unverified)'} -
-
Phone
-
{profile.phoneNumber || not set}
-
Membership
{roleLabel(profile.role)}
@@ -432,6 +425,13 @@ export function AccountContent({
)} + {profileState === 'ready' && profile && ( +

+ Phone collection is temporarily paused while we review how contact + information is handled. This update does not change existing stored + information. +

+ )} {editing && (
@@ -457,29 +457,6 @@ export function AccountContent({ characters.
-
- - - Up to - {' '} - {MEMBER_PROFILE_LIMITS.phoneNumber} - {' '} - characters. - -
{saveError && (

{saveError}

)} @@ -497,7 +474,6 @@ export function AccountContent({ onClick={() => { setEditing(false); setFullName(profile?.fullName || ''); - setPhoneNumber(profile?.phoneNumber || ''); setSaveError(null); }} disabled={saving} diff --git a/src/services/account/accountService.test.ts b/src/services/account/accountService.test.ts index 2d9b6d46..d184566b 100644 --- a/src/services/account/accountService.test.ts +++ b/src/services/account/accountService.test.ts @@ -1,11 +1,12 @@ /* eslint-env jest */ import { - doc, serverTimestamp, updateDoc, + doc, getDoc, serverTimestamp, updateDoc, } from 'firebase/firestore'; import { getFunctions, httpsCallable } from 'firebase/functions'; import { ensureMyProfile, + getMyProfile, updateMyProfile, validateMemberProfileFields, } from './accountService'; @@ -48,48 +49,82 @@ describe('account profile service', () => { 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 ', + test('omits the stored phone value from the returned My Account projection', async () => { + (getDoc as jest.Mock).mockResolvedValue({ + exists: () => true, + data: () => ({ + email: 'member@example.test', + fullName: 'Synthetic Member', + role: 'unverified', + phoneNumber: 'synthetic-phone-canary', + emailVerified: true, + provider: 'password', + createdAt: null, + lastLogin: null, + updatedAt: null, + }), + }); + + const profile = await getMyProfile(firestore, 'synthetic-user'); + + expect(profile).toEqual({ + uid: 'synthetic-user', + email: 'member@example.test', + fullName: 'Synthetic Member', + role: 'unverified', + emailVerified: true, + provider: 'password', + createdAt: null, + lastLogin: null, + updatedAt: null, }); + expect(profile).not.toHaveProperty('phoneNumber'); + expect(JSON.stringify(profile)).not.toContain('synthetic-phone-canary'); + }); + + test('writes only normalized name and a server timestamp even for a legacy caller', async () => { + const legacyFields = { + fullName: ' Synthetic Member ', + phoneNumber: 'synthetic-phone-canary', + } as any; + + await updateMyProfile(firestore, 'synthetic-user', legacyFields); 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 () => { + test('accepts the exact 200-unit name boundary without validating phone data', async () => { const fullName = '🏃'.repeat(100); - const phoneNumber = '📞'.repeat(20); - expect(validateMemberProfileFields({ fullName, phoneNumber })).toEqual({ + expect(validateMemberProfileFields({ + fullName, + phoneNumber: 'synthetic-phone-canary', + } as any)).toEqual({ valid: true, - fields: { fullName, phoneNumber }, + fields: { fullName }, }); await expect(updateMyProfile( firestore, 'synthetic-user', - { fullName, phoneNumber }, + { fullName, phoneNumber: 'synthetic-phone-canary' } as any, )).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) => { + ['an ASCII name', { fullName: 'n'.repeat(201) }], + ['an emoji name', { fullName: '🏃'.repeat(101) }], + ])('rejects %s over the Rules limit before Firestore', async (_label, fields) => { await expect(updateMyProfile(firestore, 'synthetic-user', fields)) - .rejects.toThrow(`${field} must be`); + .rejects.toThrow('Full name 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 40f12134..005ff356 100644 --- a/src/services/account/accountService.ts +++ b/src/services/account/accountService.ts @@ -5,14 +5,14 @@ import { FirebaseApp } from 'firebase/app'; import { getFunctions, httpsCallable } from 'firebase/functions'; import { Member, MemberEditableFields } from '../../types/member'; +export type MyMemberProfile = Omit; + export const MEMBER_PROFILE_LIMITS = { fullName: 200, - phoneNumber: 40, } as const; export interface ValidatedMemberProfileFields { fullName: string; - phoneNumber: string; } export type MemberProfileValidation = @@ -23,14 +23,10 @@ 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 } }; + return { valid: true, fields: { fullName } }; } export async function ensureMyProfile( @@ -45,7 +41,10 @@ export async function ensureMyProfile( return result.data; } -export async function getMyProfile(db: Firestore, uid: string): Promise { +export async function getMyProfile( + db: Firestore, + uid: string, +): Promise { const ref = doc(db, 'members', uid); const snap = await getDoc(ref); if (!snap.exists()) return null; @@ -55,7 +54,6 @@ export async function getMyProfile(db: Firestore, uid: string): Promise { const validation = validateMemberProfileFields(fields); if (!validation.valid) throw new Error(validation.message); - const { fullName, phoneNumber } = validation.fields; + const { fullName } = validation.fields; const ref = doc(db, 'members', uid); await updateDoc(ref, { fullName: fullName || null, - phoneNumber, updatedAt: serverTimestamp(), }); } diff --git a/src/types/member.ts b/src/types/member.ts index 515aab55..efe78808 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -17,7 +17,6 @@ export interface Member { export interface MemberEditableFields { fullName: string; - phoneNumber: string; } export interface PersonalRecord { diff --git a/tests/firestore-rules/members.test.js b/tests/firestore-rules/members.test.js index 465571dd..8fa56641 100644 --- a/tests/firestore-rules/members.test.js +++ b/tests/firestore-rules/members.test.js @@ -66,7 +66,6 @@ describe('members collection', () => { const me = await db({ uid: 'u1', role: 'unverified' }); await assertFails(me.doc('members/u1').update({ fullName: 'New Name', - phoneNumber: '555-1234', })); }); @@ -82,22 +81,20 @@ describe('members collection', () => { const me = await db(auth); await assertSucceeds(me.doc('members/u1').update({ fullName: 'New Name', - phoneNumber: '555-1234', updatedAt: new Date(), })); }); - test('profile length boundaries are accepted', async () => { + test('name length boundary is accepted', async () => { await seed('members/u1', SAMPLE_MEMBER); const me = await db({ uid: 'u1', role: 'unverified' }); await assertSucceeds(me.doc('members/u1').update({ fullName: 'n'.repeat(200), - phoneNumber: '1'.repeat(40), updatedAt: new Date(), })); }); - test('profile Unicode boundaries match the browser validator', async () => { + test('name Unicode boundary matches the browser validator', async () => { await seed('members/u1', SAMPLE_MEMBER); const me = await db({ uid: 'u1', role: 'unverified' }); @@ -106,7 +103,6 @@ describe('members collection', () => { // 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(), })); }); @@ -114,11 +110,6 @@ describe('members collection', () => { 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); @@ -133,7 +124,6 @@ describe('members collection', () => { const me = await db({ uid: 'u1', role: 'unverified' }); await assertFails(me.doc('members/u1').update({ fullName: 'New Name', - phoneNumber: '555-1234', updatedAt: new Date(), })); }); @@ -179,6 +169,47 @@ describe('members collection', () => { })); }); + test('user CANNOT add a non-empty phone during the privacy pause', async () => { + const memberWithoutPhone = { ...SAMPLE_MEMBER }; + delete memberWithoutPhone.phoneNumber; + await seed('members/u1', memberWithoutPhone); + const me = await db({ uid: 'u1', role: 'unverified' }); + await assertFails(me.doc('members/u1').update({ + phoneNumber: 'synthetic-phone-canary', + updatedAt: new Date(), + })); + }); + + test('user CANNOT replace a non-empty phone during the privacy pause', async () => { + await seed('members/u1', { ...SAMPLE_MEMBER, phoneNumber: 'existing-value' }); + const me = await db({ uid: 'u1', role: 'unverified' }); + await assertFails(me.doc('members/u1').update({ + phoneNumber: 'synthetic-phone-canary', + updatedAt: new Date(), + })); + }); + + test('user CANNOT clear an existing phone outside an approved deletion flow', async () => { + await seed('members/u1', { ...SAMPLE_MEMBER, phoneNumber: 'existing-value' }); + const me = await db({ uid: 'u1', role: 'unverified' }); + await assertFails(me.doc('members/u1').update({ + phoneNumber: '', + updatedAt: new Date(), + })); + }); + + test('a name-only edit preserves an existing phone value byte-for-byte', async () => { + await seed('members/u1', { ...SAMPLE_MEMBER, phoneNumber: 'existing-value' }); + const me = await db({ uid: 'u1', role: 'unverified' }); + await assertSucceeds(me.doc('members/u1').update({ + fullName: 'New Name', + updatedAt: new Date(), + })); + + const updated = await me.doc('members/u1').get(); + expect(updated.data().phoneNumber).toBe('existing-value'); + }); + test.each([ ['a non-string phone number', 12345], ['an oversized phone number', '1'.repeat(41)], @@ -196,7 +227,6 @@ describe('members collection', () => { const me = await db({ uid: 'u1', role: 'unverified' }); await assertFails(me.doc('members/u1').update({ fullName: 'New Name', - phoneNumber: '555-1234', updatedAt: 'now', })); });