import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; import { assertFails, assertSucceeds, initializeTestEnvironment, } from '@firebase/rules-unit-testing'; import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, runTransaction, setDoc, updateDoc, writeBatch, query, where, orderBy, limit } from 'firebase/firestore'; import fs from 'node:fs'; import path from 'node:path'; const projectId = 'motorsignal-emulator'; let testEnv; beforeAll(async () => { testEnv = await initializeTestEnvironment({ projectId, firestore: { rules: fs.readFileSync( fs.existsSync(path.resolve(process.cwd(), 'firestore.rules')) ? path.resolve(process.cwd(), 'firestore.rules') : path.resolve(process.cwd(), '../../firestore.rules'), 'utf8', ), }, }); }); afterAll(async () => { await testEnv?.cleanup(); }); afterEach(async () => { await testEnv?.clearFirestore(); }); const cleanupCollections = [ { name: 'rides/{rideId}/events', collectionPath: 'users/alice/rides/ride_cleanup/events', documentPath: 'users/alice/rides/ride_cleanup/events/event_cleanup', }, { name: 'rides/{rideId}/performance', collectionPath: 'users/alice/rides/ride_cleanup/performance', documentPath: 'users/alice/rides/ride_cleanup/performance/performance_cleanup', }, { name: 'rides', collectionPath: 'users/alice/rides', documentPath: 'users/alice/rides/ride_cleanup', }, { name: 'motorcycles', collectionPath: 'users/alice/motorcycles', documentPath: 'users/alice/motorcycles/motorcycle_cleanup', }, { name: 'trips', collectionPath: 'users/alice/trips', documentPath: 'users/alice/trips/trip_cleanup', }, { name: 'events', collectionPath: 'users/alice/events', documentPath: 'users/alice/events/event_cleanup', }, { name: 'friends', collectionPath: 'users/alice/friends', documentPath: 'users/alice/friends/friend_cleanup', }, ]; async function seedCleanupTree() { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'users/alice'), { id: 'alice' }); await setDoc(doc(db, 'users/alice/rides/ride_cleanup'), { id: 'ride_cleanup', ownerId: 'alice', }); await setDoc(doc(db, 'users/alice/rides/ride_cleanup/events/event_cleanup'), { type: 'ride-event', }); await setDoc(doc(db, 'users/alice/rides/ride_cleanup/performance/performance_cleanup'), { type: 'performance-run', }); await setDoc(doc(db, 'users/alice/motorcycles/motorcycle_cleanup'), { ownerId: 'alice', }); await setDoc(doc(db, 'users/alice/trips/trip_cleanup'), { type: 'trip' }); await setDoc(doc(db, 'users/alice/events/event_cleanup'), { type: 'event' }); await setDoc(doc(db, 'users/alice/friends/friend_cleanup'), { type: 'friend' }); }); } describe('MotorCloud ride ownership rules', () => { test('a signed-in owner can create and read their own ride', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const ride = doc(alice, 'users/alice/rides/ride_test_alice'); await assertSucceeds(setDoc(ride, { id: 'ride_test_alice', ownerId: 'alice', startTime: '2026-09-01T00:00:00.000Z', })); await assertSucceeds(getDoc(ride)); }); test('User A cannot read or update User B ride data', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const bobRideForSetup = doc(bob, 'users/bob/rides/ride_bob'); const bobRideForAlice = doc(alice, 'users/bob/rides/ride_bob'); await assertSucceeds(setDoc(bobRideForSetup, { id: 'ride_bob', ownerId: 'bob', startTime: '2026-09-01T00:00:00.000Z', })); await assertFails(getDoc(bobRideForAlice)); await assertFails(updateDoc(bobRideForAlice, { distanceMiles: 999 })); }); test('a caller cannot create a ride with another ownerId', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const forged = doc(alice, 'users/alice/rides/ride_forged'); await assertFails(setDoc(forged, { id: 'ride_forged', ownerId: 'bob', startTime: '2026-09-01T00:00:00.000Z', })); }); test('a caller cannot change an existing ride ownerId', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const ride = doc(alice, 'users/alice/rides/ride_owner_lock'); await assertSucceeds(setDoc(ride, { id: 'ride_owner_lock', ownerId: 'alice', startTime: '2026-09-01T00:00:00.000Z', })); await assertFails(updateDoc(ride, { ownerId: 'bob' })); }); test('signed-out callers cannot access user-owned rides', async () => { const unauthenticated = testEnv.unauthenticatedContext().firestore(); const ride = doc(unauthenticated, 'users/alice/rides/ride_test_alice'); await assertFails(getDoc(ride)); }); test('an owner can list and delete every account-cleanup path', async () => { await seedCleanupTree(); const alice = testEnv.authenticatedContext('alice').firestore(); await assertSucceeds(getDoc(doc(alice, 'users/alice'))); for (const entry of cleanupCollections) { await assertSucceeds(getDocs(collection(alice, entry.collectionPath))); } for (const entry of cleanupCollections) { await assertSucceeds(deleteDoc(doc(alice, entry.documentPath))); } await assertSucceeds(deleteDoc(doc(alice, 'users/alice'))); }); test('another user cannot list or delete any account-cleanup path', async () => { await seedCleanupTree(); const bob = testEnv.authenticatedContext('bob').firestore(); await assertFails(getDoc(doc(bob, 'users/alice'))); await assertFails(deleteDoc(doc(bob, 'users/alice'))); for (const entry of cleanupCollections) { await assertFails(getDocs(collection(bob, entry.collectionPath))); await assertFails(deleteDoc(doc(bob, entry.documentPath))); } }); test('signed-out callers cannot list or delete account-cleanup paths', async () => { await seedCleanupTree(); const signedOut = testEnv.unauthenticatedContext().firestore(); await assertFails(getDoc(doc(signedOut, 'users/alice'))); await assertFails(deleteDoc(doc(signedOut, 'users/alice'))); for (const entry of cleanupCollections) { await assertFails(getDocs(collection(signedOut, entry.collectionPath))); await assertFails(deleteDoc(doc(signedOut, entry.documentPath))); } }); }); describe('Social Architecture Security Rules', () => { test('public profile creation, reading, and privacy protection', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // Alice creates public profile await assertSucceeds(setDoc(doc(alice, 'public_profiles/alice'), { id: 'alice', username: 'alice_apex', displayName: 'Alice Apex', avatarPreset: 'helmet_cyan', bio: 'Apex hunter', location: 'Atlanta', isSearchableByHandle: true, isProfilePublic: true, allowFriendRequests: true, })); // Bob can read Alice's public profile await assertSucceeds(getDoc(doc(bob, 'public_profiles/alice'))); // Bob CANNOT edit Alice's public profile await assertFails(updateDoc(doc(bob, 'public_profiles/alice'), { bio: 'Hacked' })); // Bob CANNOT list all public profiles await assertFails(getDocs(collection(bob, 'public_profiles'))); // Alice sets isSearchableByHandle = false await assertSucceeds(updateDoc(doc(alice, 'public_profiles/alice'), { isSearchableByHandle: false, })); // Bob can no longer read Alice's non-searchable profile since they are not friends await assertFails(getDoc(doc(bob, 'public_profiles/alice'))); }); test('handle reservation uniqueness and directory scraping prevention', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // Alice creates public profile first so handle lookup can check searchability await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'public_profiles/alice'), { id: 'alice', username: 'alice_apex', isSearchableByHandle: true, }); }); // Alice claims handle await assertSucceeds(setDoc(doc(alice, 'handles/alice_apex'), { uid: 'alice', handle: 'alice_apex', })); // Bob CANNOT overwrite Alice's handle await assertFails(setDoc(doc(bob, 'handles/alice_apex'), { uid: 'bob', handle: 'alice_apex', })); // Bob CANNOT delete Alice's handle await assertFails(deleteDoc(doc(bob, 'handles/alice_apex'))); // Bob CANNOT list all handles await assertFails(getDocs(collection(bob, 'handles'))); }); test('friend requests: legitimate vs forged vs blocked vs disabled', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // Seed Bob's public profile accepting requests await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'public_profiles/bob'), { id: 'bob', username: 'bob_rider', allowFriendRequests: true, }); }); // Alice sends legitimate friend request to Bob await assertSucceeds(setDoc(doc(alice, 'users/bob/friend_requests_incoming/alice'), { receiverUid: 'bob', senderUid: 'alice', senderHandle: 'alice_apex', })); // Alice CANNOT forge request pretending to be Charlie await assertFails(setDoc(doc(alice, 'users/bob/friend_requests_incoming/charlie'), { receiverUid: 'bob', senderUid: 'charlie', senderHandle: 'charlie', })); // Alice CANNOT send friend request to herself await assertFails(setDoc(doc(alice, 'users/alice/friend_requests_incoming/alice'), { receiverUid: 'alice', senderUid: 'alice', senderHandle: 'alice', })); }); test('friendship establishment requires incoming request; unilateral declaration fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // Unilateral friendship: Alice tries to add Bob directly as a friend without incoming request -> FAILS await assertFails(setDoc(doc(alice, 'users/alice/friends/bob'), { uid: 'bob', handle: 'bob_rider', })); // Seed incoming request from Alice to Bob await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'users/bob/friend_requests_incoming/alice'), { receiverUid: 'bob', senderUid: 'alice', }); }); // Bob accepts request -> SUCCEEDS because Bob has incoming request from Alice await assertSucceeds(setDoc(doc(bob, 'users/bob/friends/alice'), { uid: 'alice', handle: 'alice_apex', })); // Bob can also create Alice's corresponding friend doc in the mutual acceptance await assertSucceeds(setDoc(doc(bob, 'users/alice/friends/bob'), { uid: 'bob', handle: 'bob_rider', })); // Outsider Charlie CANNOT delete Alice and Bob's friendship const charlie = testEnv.authenticatedContext('charlie').firestore(); await assertFails(deleteDoc(doc(charlie, 'users/alice/friends/bob'))); // Either participant (Alice) can remove friend await assertSucceeds(deleteDoc(doc(alice, 'users/alice/friends/bob'))); }); test('blocking prevents requests, hides profiles, and blocks cannot be forged', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // Seed Alice's public profile await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'public_profiles/alice'), { id: 'alice', username: 'alice_apex', isSearchableByHandle: true, allowFriendRequests: true, }); await setDoc(doc(db, 'handles/alice_apex'), { uid: 'alice', handle: 'alice_apex', }); }); // Alice blocks Bob await assertSucceeds(setDoc(doc(alice, 'users/alice/blocks/bob'), { blockedUid: 'bob', })); // Bob CANNOT read or delete Alice's block doc await assertFails(getDoc(doc(bob, 'users/alice/blocks/bob'))); await assertFails(deleteDoc(doc(bob, 'users/alice/blocks/bob'))); // Bob CANNOT read Alice's public profile once blocked await assertFails(getDoc(doc(bob, 'public_profiles/alice'))); // Bob CANNOT get Alice's handle doc once blocked await assertFails(getDoc(doc(bob, 'handles/alice_apex'))); // Bob CANNOT send friend request to Alice once blocked await assertFails(setDoc(doc(bob, 'users/alice/friend_requests_incoming/bob'), { receiverUid: 'alice', senderUid: 'bob', })); }); describe('Group Rides Security Rules', () => { test('organizer can create group ride and initialize organizer participant', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const rideData = { id: 'ride_100', organizerUid: 'alice', organizerHandle: 'alice_apex', organizerDisplayName: 'Alice Apex', title: 'Blood Mountain Run', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice'], visibility: 'friendsOnly', joinPolicy: 'inviteOnly', meetingPoint: { latitude: 34.6, longitude: -83.9, displayName: 'Dahlonega Square', locationType: 'meetup', }, }; // Alice can create ride with herself as organizer await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_100'), rideData)); // Alice creates herself as accepted organizer participant await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_100/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', riderHandle: 'alice_apex', riderDisplayName: 'Alice Apex', })); // Alice can create ride AND organizer participant in an atomic WriteBatch (as done by client app) const batch = writeBatch(alice); batch.set(doc(alice, 'group_rides/ride_batch_101'), { ...rideData, id: 'ride_batch_101', }); batch.set(doc(alice, 'group_rides/ride_batch_101/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', riderHandle: 'alice_apex', riderDisplayName: 'Alice Apex', }); await assertSucceeds(batch.commit()); // Bob CANNOT create a ride claiming Alice is the organizer await assertFails(setDoc(doc(bob, 'group_rides/ride_fake'), { ...rideData, id: 'ride_fake', organizerUid: 'alice', })); }); test('ride visibility and mutual block rules', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); // Seed ride as private, plus Bob as Alice's friend, and Alice blocks Charlie await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_private'), { id: 'ride_private', organizerUid: 'alice', title: 'Secret Run', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice'], visibility: 'private', meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, }); await setDoc(doc(db, 'group_rides/ride_friends'), { id: 'ride_friends', organizerUid: 'alice', title: 'Friends Run', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice'], visibility: 'friendsOnly', meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, }); // Bob is Alice's friend await setDoc(doc(db, 'users/alice/friends/bob'), { uid: 'bob' }); // Alice blocks Charlie await setDoc(doc(db, 'users/alice/blocks/charlie'), { blockedUid: 'charlie' }); }); // Private ride: non-member Bob CANNOT read await assertFails(getDoc(doc(bob, 'group_rides/ride_private'))); // Friends ride: friend Bob CAN read await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_friends'))); // Blocked user Charlie CANNOT read friends ride await assertFails(getDoc(doc(charlie, 'group_rides/ride_friends'))); }); test('invitations, acceptance, and capacity modification', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_inv'), { id: 'ride_inv', organizerUid: 'alice', title: 'Invite Run', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice', 'bob'], visibility: 'friendsOnly', meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, }); }); // Alice invites Bob await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_inv/participants/bob'), { userId: 'bob', role: 'participant', status: 'invited', riderHandle: 'bob_rider', riderDisplayName: 'Bob Rider', invitedBy: 'alice', })); // Outsider Charlie CANNOT forge an invitation to himself await assertFails(setDoc(doc(charlie, 'group_rides/ride_inv/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'invited', invitedBy: 'alice', })); // Charlie CANNOT accept Bob's invite await assertFails(updateDoc(doc(charlie, 'group_rides/ride_inv/participants/bob'), { status: 'accepted', })); // Bob accepts his own invite via atomic batch const bobAcceptBatch = writeBatch(bob); bobAcceptBatch.update(doc(bob, 'group_rides/ride_inv/participants/bob'), { status: 'accepted', }); bobAcceptBatch.update(doc(bob, 'group_rides/ride_inv'), { participantCount: 2, updatedAt: '2026-09-10T09:05:00Z', }); await assertSucceeds(bobAcceptBatch.commit()); // Bob CANNOT edit authority fields like title or organizerUid await assertFails(updateDoc(doc(bob, 'group_rides/ride_inv'), { title: 'Hijacked Title', })); }); test('upcoming rides and my rides collection queries enforce accessUids and organizerUid', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const unauth = testEnv.unauthenticatedContext().firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); // Alice's ride with Bob invited await setDoc(doc(db, 'group_rides/ride_query_test'), { id: 'ride_query_test', organizerUid: 'alice', title: 'Query Verification Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice', 'bob'], visibility: 'friendsOnly', joinPolicy: 'inviteOnly', meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' }, }); }); // 1. Alice queries My Rides (organizerUid == 'alice') -> SUCCEEDS const myRidesQuery = query( collection(alice, 'group_rides'), where('organizerUid', '==', 'alice'), where('status', 'in', ['scheduled', 'assembling', 'underway']), ); const aliceSnap = await assertSucceeds(getDocs(myRidesQuery)); expect(aliceSnap.docs.length).toBe(1); // 2. Bob queries Upcoming Rides (accessUids array-contains 'bob') -> SUCCEEDS const bobUpcomingQuery = query( collection(bob, 'group_rides'), where('accessUids', 'array-contains', 'bob'), where('status', 'in', ['scheduled', 'assembling', 'underway']), ); const bobSnap = await assertSucceeds(getDocs(bobUpcomingQuery)); expect(bobSnap.docs.length).toBe(1); // 3. Charlie queries Upcoming Rides (accessUids array-contains 'charlie') -> SUCCEEDS (0 results) const charlieUpcomingQuery = query( collection(charlie, 'group_rides'), where('accessUids', 'array-contains', 'charlie'), where('status', 'in', ['scheduled', 'assembling', 'underway']), ); const charlieSnap = await assertSucceeds(getDocs(charlieUpcomingQuery)); expect(charlieSnap.docs.length).toBe(0); // 4. Broad query WITHOUT accessUids or organizerUid -> DENIED const broadQuery = query( collection(bob, 'group_rides'), where('status', '==', 'scheduled'), ); await assertFails(getDocs(broadQuery)); // 5. Unauthenticated user CANNOT query group_rides const unauthQuery = query( collection(unauth, 'group_rides'), where('organizerUid', '==', 'alice'), where('status', 'in', ['scheduled', 'assembling', 'underway']), ); await assertFails(getDocs(unauthQuery)); }); test('status-aware participant authorization and privilege escalation prevention', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); // Ride with requestToJoin policy await setDoc(doc(db, 'group_rides/ride_request_only'), { id: 'ride_request_only', organizerUid: 'alice', title: 'Request Only Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice'], visibility: 'public', joinPolicy: 'requestToJoin', meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point R' }, }); // Ride with open join policy await setDoc(doc(db, 'group_rides/ride_open'), { id: 'ride_open', organizerUid: 'alice', title: 'Open Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice'], visibility: 'public', joinPolicy: 'open', meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point O' }, }); // Ride with inviteOnly policy await setDoc(doc(db, 'group_rides/ride_invite_strict'), { id: 'ride_invite_strict', organizerUid: 'alice', title: 'Invite Only Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: ['alice'], visibility: 'public', joinPolicy: 'inviteOnly', meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point I' }, }); }); // 1. On requestToJoin ride, Charlie CANNOT self-join with 'accepted' await assertFails(setDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', })); // 2. Charlie CAN create 'requested' participant record await assertSucceeds(setDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', })); // 3. Charlie CANNOT self-promote from 'requested' to 'accepted' await assertFails(updateDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), { status: 'accepted', })); // 4. Organizer Alice CAN approve Charlie's request via atomic batch const aliceApproveBatch = writeBatch(alice); aliceApproveBatch.update(doc(alice, 'group_rides/ride_request_only/participants/charlie'), { status: 'accepted', }); aliceApproveBatch.update(doc(alice, 'group_rides/ride_request_only'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-09-10T09:05:00Z', }); await assertSucceeds(aliceApproveBatch.commit()); // 5. On inviteOnly ride, stranger Dave CANNOT request or join await assertFails(setDoc(doc(dave, 'group_rides/ride_invite_strict/participants/dave'), { userId: 'dave', role: 'participant', status: 'requested', })); await assertFails(setDoc(doc(dave, 'group_rides/ride_invite_strict/participants/dave'), { userId: 'dave', role: 'participant', status: 'accepted', })); // 6. On open ride, Dave CAN directly join with 'accepted' via atomic batch const daveOpenBatch = writeBatch(dave); daveOpenBatch.set(doc(dave, 'group_rides/ride_open/participants/dave'), { userId: 'dave', role: 'participant', status: 'accepted', }); daveOpenBatch.update(doc(dave, 'group_rides/ride_open'), { accessUids: ['alice', 'dave'], participantCount: 2, updatedAt: '2026-09-10T09:05:00Z', }); await assertSucceeds(daveOpenBatch.commit()); }); test('live group presence security rules', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); // Ride setup: Alice is organizer, Bob is accepted participant, Charlie is invited only, Dave is stranger await setDoc(doc(db, 'group_rides/ride_live'), { id: 'ride_live', organizerUid: 'alice', title: 'Live Tracking Run', status: 'underway', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, accessUids: ['alice', 'bob', 'charlie'], visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' }, }); await setDoc(doc(db, 'group_rides/ride_live/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', riderHandle: 'alice_rider', riderDisplayName: 'Alice Rider', }); await setDoc(doc(db, 'group_rides/ride_live/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob_rider', riderDisplayName: 'Bob Rider', }); await setDoc(doc(db, 'group_rides/ride_live/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'invited', riderHandle: 'charlie_rider', riderDisplayName: 'Charlie Rider', }); await setDoc(doc(db, 'users/alice'), { id: 'alice' }); await setDoc(doc(db, 'users/bob'), { id: 'bob' }); }); const validBobPresence = { userId: 'bob', groupRideId: 'ride_live', latitude: 37.7833, longitude: -122.4167, heading: 90.0, speedMph: 45.0, accuracyMeters: 5.0, updatedAt: '2026-09-10T09:40:00Z', expiresAt: '2026-09-10T09:42:00Z', sharingEnabled: true, status: 'riding', }; // 1. Accepted participant Bob publishes his own presence: SUCCEEDS await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), validBobPresence)); // 2. Stranger Dave CANNOT publish presence await assertFails(setDoc(doc(dave, 'group_rides/ride_live/live_presence/dave'), { ...validBobPresence, userId: 'dave', })); // 3. Invited-only Charlie CANNOT publish presence until accepted await assertFails(setDoc(doc(charlie, 'group_rides/ride_live/live_presence/charlie'), { ...validBobPresence, userId: 'charlie', })); // 4. Bob CANNOT publish presence for Alice (spoofing) await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/alice'), { ...validBobPresence, userId: 'alice', })); // 5. Organizer Alice CANNOT publish on Bob's presence document await assertFails(setDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'), { ...validBobPresence, userId: 'bob', })); // 6. Non-whitelisted keys rejected await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), { ...validBobPresence, leanAngle: 42.0, })); // 7. Invalid coordinates rejected await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), { ...validBobPresence, latitude: 95.0, })); // 8. Accepted participant Alice CAN read Bob's presence via point-get await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); // 8b. Collection listing is STRICTLY DENIED for everyone (including organizer and accepted participants) await assertFails(getDocs(collection(alice, 'group_rides/ride_live/live_presence'))); await assertFails(getDocs(collection(bob, 'group_rides/ride_live/live_presence'))); await assertFails(getDocs(collection(dave, 'group_rides/ride_live/live_presence'))); await assertFails(getDocs(collection(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_live/live_presence'))); // 8c. Signed-out point-get is DENIED await assertFails(getDoc(doc(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_live/live_presence/bob'))); // 8d. Invited Charlie CANNOT point-get Bob's presence await assertFails(getDoc(doc(charlie, 'group_rides/ride_live/live_presence/bob'))); // 9. Stranger Dave CANNOT read Bob's presence even on public ride await assertFails(getDoc(doc(dave, 'group_rides/ride_live/live_presence/bob'))); // 10. If Bob has sharingEnabled: false, Alice CANNOT read Bob's presence await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_live/live_presence/bob'), { ...validBobPresence, sharingEnabled: false, }); }); await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); // But Bob CAN still read his own presence await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'))); // 11. If Alice blocks Bob, Alice CANNOT read Bob's presence even if sharingEnabled: true await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_live/live_presence/bob'), validBobPresence); await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' }); }); await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); // 11b. Participant roster block privacy: Peer Charlie blocks Peer Bob await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_live/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', riderHandle: 'charlie_rider', riderDisplayName: 'Charlie Rider', }); await setDoc(doc(db, 'users/charlie/blocks/bob'), { blockedUid: 'bob' }); }); // Charlie CANNOT read Bob's participant document due to mutual block await assertFails(getDoc(doc(charlie, 'group_rides/ride_live/participants/bob'))); // Bob CANNOT read Charlie's participant document due to mutual block await assertFails(getDoc(doc(bob, 'group_rides/ride_live/participants/charlie'))); // But Organizer Alice CAN still read Bob's participant document await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/participants/bob'))); // And Bob CAN read his own participant document await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_live/participants/bob'))); // 12. If ride is canceled, publishing is denied await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await deleteDoc(doc(db, 'users/alice/blocks/bob')); await setDoc(doc(db, 'group_rides/ride_live'), { id: 'ride_live', organizerUid: 'alice', title: 'Live Tracking Run', status: 'canceled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, accessUids: ['alice', 'bob'], visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' }, }); }); await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), validBobPresence)); // 13. Bob can delete his own presence record await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'))); }); test('live presence lifecycle: removal, voluntary leave, completion, and mid-session block', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const validBobPresence = { userId: 'bob', groupRideId: 'ride_lifecycle', latitude: 37.7833, longitude: -122.4167, heading: 90.0, speedMph: 45.0, accuracyMeters: 5.0, updatedAt: '2026-09-10T09:40:00Z', expiresAt: '2026-09-10T09:42:00Z', sharingEnabled: true, status: 'riding', }; const validAlicePresence = { userId: 'alice', groupRideId: 'ride_lifecycle', latitude: 37.7830, longitude: -122.4160, heading: 90.0, speedMph: 45.0, accuracyMeters: 5.0, updatedAt: '2026-09-10T09:40:00Z', expiresAt: '2026-09-10T09:42:00Z', sharingEnabled: true, status: 'riding', }; const resetLiveState = async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_lifecycle'), { id: 'ride_lifecycle', organizerUid: 'alice', title: 'Lifecycle Test Run', status: 'underway', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, accessUids: ['alice', 'bob'], visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_lifecycle/live_presence/bob'), validBobPresence); await setDoc(doc(db, 'group_rides/ride_lifecycle/live_presence/alice'), validAlicePresence); await setDoc(doc(db, 'users/alice'), { id: 'alice' }); await setDoc(doc(db, 'users/bob'), { id: 'bob' }); await deleteDoc(doc(db, 'users/alice/blocks/bob')); await deleteDoc(doc(db, 'users/bob/blocks/alice')); }); }; // Scenario A: Hard delete of accepted participant and organizer is BLOCKED await resetLiveState(); // Alice reads Bob initially -> succeeds await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Organizer CANNOT delete Bob's accepted participant record (must use atomic removal) await assertFails(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'))); // Bob CANNOT delete his own accepted participant record (must use atomic leave) await assertFails(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'))); // Organizer CANNOT delete own organizer participant record await assertFails(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/alice'))); // Bob still has valid presence access while accepted await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { ...validBobPresence, speedMph: 55.0, })); // Scenario B2: Voluntary Leave via status transition (status: 'accepted' -> 'left', presence doc temporarily remains) await resetLiveState(); const leaveBatch = writeBatch(bob); leaveBatch.update(doc(bob, 'group_rides/ride_lifecycle/participants/bob'), { status: 'left', }); leaveBatch.update(doc(bob, 'group_rides/ride_lifecycle'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-09-10T09:35:00Z', }); await assertSucceeds(leaveBatch.commit()); // Stale presence document still exists in database, but Alice direct get is DENIED await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Bob self get for cleanup verification remains ALLOWED await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'))); // Scenario B3: Organizer removal via status transition (status: 'accepted' -> 'removed', presence doc temporarily remains) await resetLiveState(); const removeBatch = writeBatch(alice); removeBatch.update(doc(alice, 'group_rides/ride_lifecycle/participants/bob'), { status: 'removed', }); removeBatch.update(doc(alice, 'group_rides/ride_lifecycle'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-09-10T09:35:00Z', }); await assertSucceeds(removeBatch.commit()); // Stale presence document still exists in database, but Alice direct get is DENIED await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Scenario B4: Participant status transition to 'declined' (presence doc temporarily remains) await resetLiveState(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await updateDoc(doc(db, 'group_rides/ride_lifecycle/participants/bob'), { status: 'declined' }); }); // Stale presence document still exists in database, but Alice direct get is DENIED await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Scenario C: Completed Ride (Ride marked completed) await resetLiveState(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await updateDoc(doc(db, 'group_rides/ride_lifecycle'), { status: 'completed' }); }); // Bob CANNOT write to live presence await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { ...validBobPresence, speedMph: 0.0, })); // Alice CANNOT write to live presence await assertFails(setDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/alice'), { ...validAlicePresence, speedMph: 0.0, })); // Alice CANNOT read Bob's live presence await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Bob CANNOT read Alice's live presence await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); // Scenario C2: Canceled Ride (Ride marked canceled, presence doc temporarily remains) await resetLiveState(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await updateDoc(doc(db, 'group_rides/ride_lifecycle'), { status: 'canceled' }); }); // Alice CANNOT read Bob's live presence await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Bob CANNOT read Alice's live presence await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); // Scenario D: Mid-Session Block (Alice blocks Bob during active ride) await resetLiveState(); // Both can read initially await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); // Alice blocks Bob await assertSucceeds(setDoc(doc(alice, 'users/alice/blocks/bob'), { blockedUid: 'bob' })); // Alice CANNOT read Bob's presence await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Bob CANNOT read Alice's presence await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); // Scenario E: Participants Collection Listing is STRICTLY DENIED for everyone await resetLiveState(); await assertFails(getDocs(collection(alice, 'group_rides/ride_lifecycle/participants'))); await assertFails(getDocs(collection(bob, 'group_rides/ride_lifecycle/participants'))); await assertFails(getDocs(collection(testEnv.authenticatedContext('stranger').firestore(), 'group_rides/ride_lifecycle/participants'))); await assertFails(getDocs(collection(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_lifecycle/participants'))); // Scenario F: Participant single-doc get authorization const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'invited', }); }); // Charlie (invited) CAN read his own participant document await assertSucceeds(getDoc(doc(charlie, 'group_rides/ride_lifecycle/participants/charlie'))); // Charlie (invited) CANNOT read peer Bob's participant document await assertFails(getDoc(doc(charlie, 'group_rides/ride_lifecycle/participants/bob'))); // Organizer Alice CAN read invited Charlie's participant document await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/participants/charlie'))); }); test('quick status security: organizer cannot impersonate or modify another rider status', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_status_sec'), { id: 'ride_status_sec', organizerUid: 'alice', title: 'Status Security Ride', status: 'underway', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); await setDoc(doc(db, 'group_rides/ride_status_sec/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', riderHandle: 'alice', riderDisplayName: 'Alice', }); await setDoc(doc(db, 'group_rides/ride_status_sec/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', }); await setDoc(doc(db, 'group_rides/ride_status_sec/live_presence/bob'), { userId: 'bob', groupRideId: 'ride_status_sec', latitude: 37.78, longitude: -122.41, heading: 0.0, speedMph: 25.0, accuracyMeters: 5.0, updatedAt: '2026-09-10T09:40:00Z', expiresAt: '2026-09-10T09:42:00Z', sharingEnabled: true, status: 'riding', }); }); // 1. Bob can update his own quick status to 'needFuel' await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_status_sec/live_presence/bob'), { status: 'needFuel', updatedAt: '2026-09-10T09:41:00Z', })); // 2. Organizer Alice CANNOT update Bob's presence document or status await assertFails(updateDoc(doc(alice, 'group_rides/ride_status_sec/live_presence/bob'), { status: 'riding', })); // 3. Organizer Alice CANNOT create a presence document under Bob's UID await assertFails(setDoc(doc(alice, 'group_rides/ride_status_sec/live_presence/bob'), { userId: 'bob', groupRideId: 'ride_status_sec', latitude: 37.78, longitude: -122.41, heading: 0.0, speedMph: 25.0, accuracyMeters: 5.0, updatedAt: '2026-09-10T09:42:00Z', expiresAt: '2026-09-10T09:44:00Z', sharingEnabled: true, status: 'arrived', })); }); test('session authority: organizer transitions, participant denials, and terminal states', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_transition_test'), { id: 'ride_transition_test', organizerUid: 'alice', title: 'Session Authority Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); await setDoc(doc(db, 'group_rides/ride_transition_test/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', riderHandle: 'alice', riderDisplayName: 'Alice', }); await setDoc(doc(db, 'group_rides/ride_transition_test/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', }); }); // 1. Participant Bob CANNOT start the ride (scheduled -> underway) await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), { status: 'underway', })); // 2. Participant Bob CANNOT cancel the ride await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), { status: 'canceled', })); // 3. Organizer Alice transitions scheduled -> assembling: SUCCEEDS await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { status: 'assembling', })); // 4. Organizer Alice transitions assembling -> underway: SUCCEEDS await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { status: 'underway', })); // 5. Participant Bob CANNOT complete the ride await assertFails(updateDoc(doc(bob, 'group_rides/ride_transition_test'), { status: 'completed', })); // 6. Organizer Alice transitions underway -> completed: SUCCEEDS await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { status: 'completed', })); // 7. INVALID TRANSITION: completed -> underway MUST BE DENIED for organizer Alice await assertFails(updateDoc(doc(alice, 'group_rides/ride_transition_test'), { status: 'underway', })); // 8. Setup a canceled ride and verify canceled -> underway is denied await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_canceled_test'), { id: 'ride_canceled_test', organizerUid: 'alice', title: 'Canceled Authority Ride', status: 'canceled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); }); // INVALID TRANSITION: canceled -> underway MUST BE DENIED await assertFails(updateDoc(doc(alice, 'group_rides/ride_canceled_test'), { status: 'underway', })); }); }); describe('MotorSignal route planning and sharing rules', () => { test('owner can create, read, update, and delete their own route', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const routeRef = doc(alice, 'routes/route_alice_1'); // 1. Create valid route: SUCCEEDS await assertSucceeds(setDoc(routeRef, { id: 'route_alice_1', ownerUid: 'alice', title: 'Skyline Boulevard Loop', description: 'Twisties through the redwoods', visibility: 'private', stops: [ { id: 'stop-1', latitude: 37.4, longitude: -122.2, displayName: 'Alice\'s Restaurant', stopType: 'origin', orderIndex: 0, }, { id: 'stop-2', latitude: 37.3, longitude: -122.1, displayName: 'Page Mill Vista', stopType: 'destination', orderIndex: 1, }, ], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', })); // 2. Read own route: SUCCEEDS await assertSucceeds(getDoc(routeRef)); // 3. Update own route: SUCCEEDS await assertSucceeds(updateDoc(routeRef, { title: 'Skyline Boulevard Loop Extended', updatedAt: '2026-09-04T12:30:00Z', })); // 4. Delete own route: SUCCEEDS await assertSucceeds(deleteDoc(routeRef)); }); test('validates route constraints: title length and max stops', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); // Title too short (<3 chars): FAILS await assertFails(setDoc(doc(alice, 'routes/route_short_title'), { id: 'route_short_title', ownerUid: 'alice', title: 'AB', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', })); // Stops exceed 25 cap: FAILS const excessiveStops = []; for (let i = 0; i < 26; i++) { excessiveStops.push({ id: `stop-${i}`, latitude: 37.0 + i * 0.01, longitude: -122.0, displayName: `Stop ${i}`, stopType: 'waypoint', orderIndex: i, }); } await assertFails(setDoc(doc(alice, 'routes/route_too_many_stops'), { id: 'route_too_many_stops', ownerUid: 'alice', title: 'Way Too Many Stops Route', stops: excessiveStops, createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', })); }); test('private route cannot be read or modified by non-owner', async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'routes/route_private_alice'), { id: 'route_private_alice', ownerUid: 'alice', title: 'Secret Canyon Route', visibility: 'private', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); }); const bob = testEnv.authenticatedContext('bob').firestore(); const targetRoute = doc(bob, 'routes/route_private_alice'); // Bob cannot read private route await assertFails(getDoc(targetRoute)); // Bob cannot update private route await assertFails(updateDoc(targetRoute, { title: 'Hacked Title' })); // Bob cannot delete private route await assertFails(deleteDoc(targetRoute)); }); test('public route can be read by any signed-in user, but only modified by owner', async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'routes/route_public_alice'), { id: 'route_public_alice', ownerUid: 'alice', title: 'Public Coastal Highway 1', visibility: 'public', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); }); const bob = testEnv.authenticatedContext('bob').firestore(); const targetRoute = doc(bob, 'routes/route_public_alice'); // Bob can read public route await assertSucceeds(getDoc(targetRoute)); // Bob cannot update public route await assertFails(updateDoc(targetRoute, { title: 'Bob Was Here' })); }); test('groupOnly route can be read by accepted group members, but not by outsiders', async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_for_route'), { id: 'ride_for_route', organizerUid: 'alice', title: 'Sunday Group Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); await setDoc(doc(db, 'group_rides/ride_for_route/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', }); await setDoc(doc(db, 'routes/route_group_only'), { id: 'route_group_only', ownerUid: 'alice', title: 'Group Official Route', visibility: 'groupOnly', attachedGroupRideId: 'ride_for_route', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); }); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); // Accepted member Bob CAN read groupOnly route await assertSucceeds(getDoc(doc(bob, 'routes/route_group_only'))); // Non-member Charlie CANNOT read groupOnly route await assertFails(getDoc(doc(charlie, 'routes/route_group_only'))); }); test('group ride route attachment authority: only organizer can attach or detach route', async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_attach_authority'), { id: 'ride_attach_authority', organizerUid: 'alice', title: 'Route Authority Group Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); await setDoc(doc(db, 'group_rides/ride_attach_authority/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', }); await setDoc(doc(db, 'routes/route_official_alice'), { id: 'route_official_alice', ownerUid: 'alice', title: 'Alice Official Route', visibility: 'private', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); }); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const rideDocRef = (db) => doc(db, 'group_rides/ride_attach_authority'); // 1. Participant Bob attempts to attach a route: MUST FAIL await assertFails(updateDoc(rideDocRef(bob), { routeId: 'route_bob_custom', routeTitle: 'Bob\'s Route', routeDistanceMeters: 45000, routeStopCount: 4, })); // 2. Organizer Alice attaches a route: SUCCEEDS await assertSucceeds(updateDoc(rideDocRef(alice), { routeId: 'route_official_alice', routeTitle: 'Alice\'s Official Route', routeDistanceMeters: 52000, routeStopCount: 5, })); // 3. Participant Bob attempts to detach or change route: MUST FAIL await assertFails(updateDoc(rideDocRef(bob), { routeId: null, })); // 4. Organizer Alice detaches route: SUCCEEDS await assertSucceeds(updateDoc(rideDocRef(alice), { routeId: null, routeTitle: null, routeDistanceMeters: null, routeStopCount: null, })); }); test('group-only route read authorization enforces accepted status and mutual block privacy', async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_go_matrix'), { id: 'ride_go_matrix', organizerUid: 'alice', title: 'Matrix Group Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 6, visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); // 1. accepted participant await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', }); // 2. invited participant await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'invited', riderHandle: 'charlie', riderDisplayName: 'Charlie', }); // 3. requested participant await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/dave'), { userId: 'dave', role: 'participant', status: 'requested', riderHandle: 'dave', riderDisplayName: 'Dave', }); // 4. left participant await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/eve'), { userId: 'eve', role: 'participant', status: 'left', riderHandle: 'eve', riderDisplayName: 'Eve', }); // 5. removed participant await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/frank'), { userId: 'frank', role: 'participant', status: 'removed', riderHandle: 'frank', riderDisplayName: 'Frank', }); // 6. accepted rider but mutually blocked with owner Alice await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/mallory'), { userId: 'mallory', role: 'participant', status: 'accepted', riderHandle: 'mallory', riderDisplayName: 'Mallory', }); await setDoc(doc(db, 'users/alice/blocks/mallory'), { blocked: true }); // Route attached to this group ride await setDoc(doc(db, 'routes/route_go_matrix_valid'), { id: 'route_go_matrix_valid', ownerUid: 'alice', title: 'Matrix Group Route', visibility: 'groupOnly', attachedGroupRideId: 'ride_go_matrix', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); // Route marked groupOnly but with no attached group ride await setDoc(doc(db, 'routes/route_go_matrix_no_group'), { id: 'route_go_matrix_no_group', ownerUid: 'alice', title: 'Orphan Group Route', visibility: 'groupOnly', attachedGroupRideId: null, stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); }); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); const eve = testEnv.authenticatedContext('eve').firestore(); const frank = testEnv.authenticatedContext('frank').firestore(); const mallory = testEnv.authenticatedContext('mallory').firestore(); const outsider = testEnv.authenticatedContext('outsider').firestore(); const validRouteRef = (db) => doc(db, 'routes/route_go_matrix_valid'); const orphanRouteRef = (db) => doc(db, 'routes/route_go_matrix_no_group'); // accepted participant -> ALLOW await assertSucceeds(getDoc(validRouteRef(bob))); // invited participant -> DENY await assertFails(getDoc(validRouteRef(charlie))); // requested participant -> DENY await assertFails(getDoc(validRouteRef(dave))); // left participant -> DENY await assertFails(getDoc(validRouteRef(eve))); // removed participant -> DENY await assertFails(getDoc(validRouteRef(frank))); // blocked accepted rider -> DENY await assertFails(getDoc(validRouteRef(mallory))); // outsider -> DENY await assertFails(getDoc(validRouteRef(outsider))); // route with no attached group ride -> DENY (even for accepted rider Bob) await assertFails(getDoc(orphanRouteRef(bob))); }); test('group ride route attachment requires organizer to own the route', async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'group_rides/ride_attach_ownership'), { id: 'ride_attach_ownership', organizerUid: 'alice', title: 'Ownership Group Ride', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, }); await setDoc(doc(db, 'group_rides/ride_attach_ownership/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', }); await setDoc(doc(db, 'routes/route_alice_owned'), { id: 'route_alice_owned', ownerUid: 'alice', title: 'Alice Route', visibility: 'private', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); await setDoc(doc(db, 'routes/route_bob_owned'), { id: 'route_bob_owned', ownerUid: 'bob', title: 'Bob Route', visibility: 'private', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); }); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const rideRef = (db) => doc(db, 'group_rides/ride_attach_ownership'); // 1. Organizer Alice attaching route she owns -> ALLOW await assertSucceeds(updateDoc(rideRef(alice), { routeId: 'route_alice_owned', routeTitle: 'Alice Route', routeDistanceMeters: 10000, routeStopCount: 2, })); // 2. Organizer Alice attaching another user's route (Bob's route) -> DENY await assertFails(updateDoc(rideRef(alice), { routeId: 'route_bob_owned', routeTitle: 'Bob Route', routeDistanceMeters: 10000, routeStopCount: 2, })); // 3. Non-organizer participant Bob attaching his own route -> DENY await assertFails(updateDoc(rideRef(bob), { routeId: 'route_bob_owned', routeTitle: 'Bob Route', routeDistanceMeters: 10000, routeStopCount: 2, })); // 4. Organizer Alice attaching non-existent route -> DENY await assertFails(updateDoc(rideRef(alice), { routeId: 'route_non_existent', })); }); test('route schema immutability and visibility whitelisting', async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'routes/route_immutable_test'), { id: 'route_immutable_test', ownerUid: 'alice', title: 'Original Title', visibility: 'private', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); }); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const routeRef = doc(alice, 'routes/route_immutable_test'); // 1. Updating title: ALLOW await assertSucceeds(updateDoc(routeRef, { title: 'Updated Safe Title', updatedAt: '2026-09-04T13:00:00Z', })); // 2. Modifying immutable ownerUid: DENY await assertFails(updateDoc(routeRef, { ownerUid: 'bob', })); // 3. Modifying immutable id: DENY await assertFails(updateDoc(routeRef, { id: 'different_id', })); // 4. Modifying immutable createdAt: DENY await assertFails(updateDoc(routeRef, { createdAt: '2026-09-05T00:00:00Z', })); // 5. Forging ownerUid on create: DENY await assertFails(setDoc(doc(bob, 'routes/route_forged_bob'), { id: 'route_forged_bob', ownerUid: 'alice', title: 'Forged Owner Route', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', })); // 6. Unsupported visibility: DENY await assertFails(setDoc(doc(alice, 'routes/route_invalid_visibility'), { id: 'route_invalid_visibility', ownerUid: 'alice', title: 'Invalid Visibility Route', visibility: 'secret_unsupported', stops: [], createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', })); }); test('route stops validation enforces bounds, types, and required fields', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const baseRoute = (routeId, stops) => ({ id: routeId, ownerUid: 'alice', title: 'Stop Validation Route', visibility: 'private', stops, createdAt: '2026-09-04T12:00:00Z', updatedAt: '2026-09-04T12:00:00Z', }); const makeStop = (overrides = {}) => ({ id: 'stop-valid-1', order: 0, stopType: 'waypoint', displayName: 'Valid Stop', latitude: 37.77, longitude: -122.41, ...overrides, }); // 1. Valid stop passes await assertSucceeds(setDoc(doc(alice, 'routes/route_stop_valid'), baseRoute('route_stop_valid', [makeStop()]))); // 2. Latitude > 90 -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_lat_high'), baseRoute('route_stop_lat_high', [makeStop({ latitude: 90.001 })]))); // 3. Latitude < -90 -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_lat_low'), baseRoute('route_stop_lat_low', [makeStop({ latitude: -90.001 })]))); // 4. Longitude > 180 -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_lng_high'), baseRoute('route_stop_lng_high', [makeStop({ longitude: 180.001 })]))); // 5. Longitude < -180 -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_lng_low'), baseRoute('route_stop_lng_low', [makeStop({ longitude: -180.001 })]))); // 6. Unsupported stopType -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_bad_type'), baseRoute('route_stop_bad_type', [makeStop({ stopType: 'hyperloop' })]))); // 7. Empty stop ID -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_empty_id'), baseRoute('route_stop_empty_id', [makeStop({ id: '' })]))); // 8. Empty displayName -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_empty_name'), baseRoute('route_stop_empty_name', [makeStop({ displayName: '' })]))); // 9. Oversized displayName (> 80 chars) -> DENY await assertFails(setDoc(doc(alice, 'routes/route_stop_long_name'), baseRoute('route_stop_long_name', [makeStop({ displayName: 'A'.repeat(81) })]))); // 10. Malformed stop object (missing latitude) -> DENY const malformedStop = { id: 'stop-bad', order: 0, stopType: 'waypoint', displayName: 'Bad Stop', longitude: -122.41, }; await assertFails(setDoc(doc(alice, 'routes/route_stop_malformed'), baseRoute('route_stop_malformed', [malformedStop]))); // 11. 25 stops (technical cap) -> ALLOW const maxStops = []; for (let i = 0; i < 25; i++) { maxStops.push(makeStop({ id: `stop-${i}`, order: i, displayName: `Stop ${i}` })); } await assertSucceeds(setDoc(doc(alice, 'routes/route_25_stops'), baseRoute('route_25_stops', maxStops))); }); }); describe('MotorSignal Club Foundation and Membership Security Rules', () => { test('Authentication boundary: registered users allowed, anonymous and unauthenticated users rejected', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const anon = testEnv.authenticatedContext('anon', { firebase: { sign_in_provider: 'anonymous' } }).firestore(); const unauth = testEnv.unauthenticatedContext().firestore(); // Seed a public club await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_auth_test'), { id: 'club_auth_test', name: 'Auth Test Club', slug: 'auth-test', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); }); // 1. Registered user can point-read public club -> ALLOW await assertSucceeds(getDoc(doc(alice, 'clubs/club_auth_test'))); // 2. Anonymous user cannot point-read -> DENY await assertFails(getDoc(doc(anon, 'clubs/club_auth_test'))); // 3. Unauthenticated user cannot point-read -> DENY await assertFails(getDoc(doc(unauth, 'clubs/club_auth_test'))); // 4. Anonymous user cannot query discovery -> DENY const discoveryQuery = query( collection(anon, 'clubs'), where('visibility', '==', 'public'), where('archivedAt', '==', null) ); await assertFails(getDocs(discoveryQuery)); }); test('Club creation atomicity: atomic creation of club, owner member, and slug reservation', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // 1. Valid atomic creation batch -> ALLOW const validBatch = writeBatch(alice); validBatch.set(doc(alice, 'clubs/club_create_valid'), { id: 'club_create_valid', name: 'Bay Area Riders', slug: 'bay-area-riders', description: 'Local motorcycle club', visibility: 'public', admissionPolicy: 'request', ownerUid: 'alice', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); validBatch.set(doc(alice, 'clubs/club_create_valid/members/alice'), { uid: 'alice', clubId: 'club_create_valid', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); validBatch.set(doc(alice, 'club_handles/bay-area-riders'), { slug: 'bay-area-riders', clubId: 'club_create_valid', ownerUid: 'alice', createdAt: new Date(), }); await assertSucceeds(validBatch.commit()); // 2. Partial creation: missing slug handle -> DENY const missingHandleBatch = writeBatch(bob); missingHandleBatch.set(doc(bob, 'clubs/club_no_slug'), { id: 'club_no_slug', name: 'No Slug MC', slug: 'no-slug', visibility: 'public', admissionPolicy: 'open', ownerUid: 'bob', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); missingHandleBatch.set(doc(bob, 'clubs/club_no_slug/members/bob'), { uid: 'bob', clubId: 'club_no_slug', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); await assertFails(missingHandleBatch.commit()); // 3. Mismatched owner in slug handle -> DENY const mismatchedOwnerBatch = writeBatch(bob); mismatchedOwnerBatch.set(doc(bob, 'clubs/club_mismatched'), { id: 'club_mismatched', name: 'Mismatched MC', slug: 'mismatched-mc', visibility: 'public', admissionPolicy: 'open', ownerUid: 'bob', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); mismatchedOwnerBatch.set(doc(bob, 'clubs/club_mismatched/members/bob'), { uid: 'bob', clubId: 'club_mismatched', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); mismatchedOwnerBatch.set(doc(bob, 'club_handles/mismatched-mc'), { slug: 'mismatched-mc', clubId: 'club_mismatched', ownerUid: 'alice', // Falsified owner createdAt: new Date(), }); await assertFails(mismatchedOwnerBatch.commit()); }); test('Discovery query security: public discovery query allowed, unconstrained collection scan rejected', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); // Public active club await setDoc(doc(db, 'clubs/club_public'), { id: 'club_public', name: 'Public Club', slug: 'public-club', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 5, archivedAt: null, }); // Private club await setDoc(doc(db, 'clubs/club_private'), { id: 'club_private', name: 'Private Secret Club', slug: 'private-club', visibility: 'private', admissionPolicy: 'inviteOnly', ownerUid: 'alice', memberCount: 2, archivedAt: null, }); // Archived club await setDoc(doc(db, 'clubs/club_archived'), { id: 'club_archived', name: 'Archived Club', slug: 'archived-club', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 10, archivedAt: new Date(), }); }); // 1. Properly constrained discovery query -> ALLOW const validQuery = query( collection(bob, 'clubs'), where('visibility', '==', 'public'), where('archivedAt', '==', null) ); await assertSucceeds(getDocs(validQuery)); // 2. Unconstrained collection scan -> DENY await assertFails(getDocs(collection(bob, 'clubs'))); // 3. Bob queries without archivedAt filter -> DENY const missingArchiveFilter = query( collection(bob, 'clubs'), where('visibility', '==', 'public') ); await assertFails(getDocs(missingArchiveFilter)); // 4. Bob cannot read private club document directly -> DENY await assertFails(getDoc(doc(bob, 'clubs/club_private'))); // 5. Alice (owner) can read private club document -> ALLOW await assertSucceeds(getDoc(doc(alice, 'clubs/club_private'))); }); test('Membership lifecycle: open join, request to join, invitation response, and leave', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); // Club with 'open' admission await setDoc(doc(db, 'clubs/club_open'), { id: 'club_open', name: 'Open Riders', slug: 'open-riders', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 1, archivedAt: null, }); await setDoc(doc(db, 'clubs/club_open/members/alice'), { uid: 'alice', clubId: 'club_open', status: 'active', role: 'owner', }); // Club with 'request' admission await setDoc(doc(db, 'clubs/club_req'), { id: 'club_req', name: 'Vetted Riders', slug: 'vetted-riders', visibility: 'public', admissionPolicy: 'request', ownerUid: 'alice', memberCount: 1, archivedAt: null, }); await setDoc(doc(db, 'clubs/club_req/members/alice'), { uid: 'alice', clubId: 'club_req', status: 'active', role: 'owner', }); // Club with 'inviteOnly' admission await setDoc(doc(db, 'clubs/club_inv'), { id: 'club_inv', name: 'Invite Only MC', slug: 'invite-only', visibility: 'private', admissionPolicy: 'inviteOnly', ownerUid: 'alice', memberCount: 1, archivedAt: null, }); await setDoc(doc(db, 'clubs/club_inv/members/alice'), { uid: 'alice', clubId: 'club_inv', status: 'active', role: 'owner', }); }); // 1. Standalone self-join without atomic club memberCount increment -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_open/members/bob'), { uid: 'bob', clubId: 'club_open', status: 'active', role: 'member', })); // Valid atomic self-join (member active + club memberCount +1) -> ALLOW const joinBatch = writeBatch(bob); joinBatch.set(doc(bob, 'clubs/club_open/members/bob'), { uid: 'bob', clubId: 'club_open', status: 'active', role: 'member', }); joinBatch.update(doc(bob, 'clubs/club_open'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertSucceeds(joinBatch.commit()); // 2. Bob tries to directly self-join request-policy club -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_req/members/bob'), { uid: 'bob', clubId: 'club_req', status: 'active', role: 'member', })); // 3. Bob requests to join request-policy club (count does not change) -> ALLOW await assertSucceeds(setDoc(doc(bob, 'clubs/club_req/members/bob'), { uid: 'bob', clubId: 'club_req', status: 'requested', role: 'member', })); // 4. Bob tries to self-join invite-only club -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_inv/members/bob'), { uid: 'bob', clubId: 'club_inv', status: 'active', role: 'member', })); // 5. Alice (owner) invites Charlie to invite-only club -> ALLOW await assertSucceeds(setDoc(doc(alice, 'clubs/club_inv/members/charlie'), { uid: 'charlie', clubId: 'club_inv', status: 'invited', role: 'member', })); // 6. Standalone invitation accept without club memberCount increment -> DENY await assertFails(updateDoc(doc(charlie, 'clubs/club_inv/members/charlie'), { status: 'active', })); // Valid atomic invitation accept (member active + club count +1) -> ALLOW const acceptBatch = writeBatch(charlie); acceptBatch.update(doc(charlie, 'clubs/club_inv/members/charlie'), { status: 'active', }); acceptBatch.update(doc(charlie, 'clubs/club_inv'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'charlie', }); await assertSucceeds(acceptBatch.commit()); // 7. Standalone voluntary leave without club memberCount decrement -> DENY await assertFails(updateDoc(doc(charlie, 'clubs/club_inv/members/charlie'), { status: 'left', })); // Valid atomic leave (member left + club count -1) -> ALLOW const leaveBatch = writeBatch(charlie); leaveBatch.update(doc(charlie, 'clubs/club_inv/members/charlie'), { status: 'left', }); leaveBatch.update(doc(charlie, 'clubs/club_inv'), { memberCount: 1, updatedAt: new Date(), lastMembershipChangeUid: 'charlie', }); await assertSucceeds(leaveBatch.commit()); // 8. Alice (owner) tries to leave without transferring ownership -> DENY await assertFails(updateDoc(doc(alice, 'clubs/club_inv/members/alice'), { status: 'left', })); }); test('Role hierarchy & self-promotion prevention: members cannot promote self or alter roles', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_role_test'), { id: 'club_role_test', ownerUid: 'alice', slug: 'role-test', memberCount: 3, }); await setDoc(doc(db, 'clubs/club_role_test/members/alice'), { uid: 'alice', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_role_test/members/bob'), { uid: 'bob', role: 'admin', status: 'active', }); await setDoc(doc(db, 'clubs/club_role_test/members/charlie'), { uid: 'charlie', role: 'member', status: 'active', }); }); // 1. Charlie (member) attempts to promote himself to admin -> DENY await assertFails(updateDoc(doc(charlie, 'clubs/club_role_test/members/charlie'), { role: 'admin', })); // 2. Charlie attempts to promote himself to owner -> DENY await assertFails(updateDoc(doc(charlie, 'clubs/club_role_test/members/charlie'), { role: 'owner', })); // 3. Bob (admin) attempts to promote himself to owner directly -> DENY await assertFails(updateDoc(doc(bob, 'clubs/club_role_test/members/bob'), { role: 'owner', })); // 4. Bob (admin) attempts to remove the owner Alice -> DENY await assertFails(updateDoc(doc(bob, 'clubs/club_role_test/members/alice'), { status: 'removed', })); // 5. Standalone remove without atomic club memberCount decrement -> DENY await assertFails(updateDoc(doc(bob, 'clubs/club_role_test/members/charlie'), { status: 'removed', })); // Valid atomic remove of active member by admin -> ALLOW const removeBatch = writeBatch(bob); removeBatch.update(doc(bob, 'clubs/club_role_test/members/charlie'), { status: 'removed', }); removeBatch.update(doc(bob, 'clubs/club_role_test'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'charlie', }); await assertSucceeds(removeBatch.commit()); }); test('Atomic ownership transfer: valid transfer succeeds, partial/unauthorized transfer rejected', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_transfer_test'), { id: 'club_transfer_test', ownerUid: 'alice', slug: 'transfer-test', memberCount: 2, createdAt: new Date('2026-01-01T00:00:00Z'), }); await setDoc(doc(db, 'clubs/club_transfer_test/members/alice'), { uid: 'alice', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_transfer_test/members/bob'), { uid: 'bob', role: 'admin', status: 'active', }); await setDoc(doc(db, 'clubs/club_transfer_test/members/charlie'), { uid: 'charlie', role: 'member', status: 'declined', // Not active! }); }); // 1. Bob (non-owner) tries to initiate transfer -> DENY const nonOwnerBatch = writeBatch(bob); nonOwnerBatch.update(doc(bob, 'clubs/club_transfer_test'), { ownerUid: 'bob' }); nonOwnerBatch.update(doc(bob, 'clubs/club_transfer_test/members/alice'), { role: 'admin' }); nonOwnerBatch.update(doc(bob, 'clubs/club_transfer_test/members/bob'), { role: 'owner' }); await assertFails(nonOwnerBatch.commit()); // 2. Alice transfers to Charlie who is NOT an active member -> DENY const invalidTargetBatch = writeBatch(alice); invalidTargetBatch.update(doc(alice, 'clubs/club_transfer_test'), { ownerUid: 'charlie' }); invalidTargetBatch.update(doc(alice, 'clubs/club_transfer_test/members/alice'), { role: 'admin' }); invalidTargetBatch.update(doc(alice, 'clubs/club_transfer_test/members/charlie'), { role: 'owner' }); await assertFails(invalidTargetBatch.commit()); // 3. Partial transfer: Alice updates club root without updating member roles -> DENY await assertFails(updateDoc(doc(alice, 'clubs/club_transfer_test'), { ownerUid: 'bob' })); // 4. Valid complete atomic transfer to active member Bob -> ALLOW const validTransferBatch = writeBatch(alice); validTransferBatch.update(doc(alice, 'clubs/club_transfer_test'), { ownerUid: 'bob', updatedAt: new Date(), }); validTransferBatch.update(doc(alice, 'clubs/club_transfer_test/members/alice'), { role: 'admin' }); validTransferBatch.update(doc(alice, 'clubs/club_transfer_test/members/bob'), { role: 'owner' }); await assertSucceeds(validTransferBatch.commit()); }); test('Administrative bans: regular members cannot access bans, banned riders cannot join', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_ban_test'), { id: 'club_ban_test', ownerUid: 'alice', slug: 'ban-test', admissionPolicy: 'open', memberCount: 1, }); await setDoc(doc(db, 'clubs/club_ban_test/members/alice'), { uid: 'alice', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_ban_test/members/bob'), { uid: 'bob', role: 'member', status: 'active', }); // Charlie is banned await setDoc(doc(db, 'clubs/club_ban_test/bans/charlie'), { uid: 'charlie', bannedByUid: 'alice', reason: 'Reckless riding behavior', }); }); // 1. Bob (member) cannot read bans subcollection -> DENY await assertFails(getDocs(collection(bob, 'clubs/club_ban_test/bans'))); await assertFails(getDoc(doc(bob, 'clubs/club_ban_test/bans/charlie'))); // 2. Alice (owner/admin) can read bans -> ALLOW await assertSucceeds(getDoc(doc(alice, 'clubs/club_ban_test/bans/charlie'))); // 3. Charlie (banned) attempts to join open club -> DENY await assertFails(setDoc(doc(charlie, 'clubs/club_ban_test/members/charlie'), { uid: 'charlie', clubId: 'club_ban_test', status: 'active', role: 'member', })); }); test('Collection group query: caller can query own memberships/invites, cannot query others', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // 1. Alice queries her own memberships across all clubs -> ALLOW const aliceMyClubsQuery = query( collectionGroup(alice, 'members'), where('uid', '==', 'alice') ); await assertSucceeds(getDocs(aliceMyClubsQuery)); // 2. Bob attempts to query Alice memberships across clubs -> DENY const bobQueryAlice = query( collectionGroup(bob, 'members'), where('uid', '==', 'alice') ); await assertFails(getDocs(bobQueryAlice)); // 3. Unconstrained collection group scan -> DENY await assertFails(getDocs(collectionGroup(bob, 'members'))); }); test('Slug handle creation atomicity: handle reservation strictly requires consistent atomic club and owner creation (Cases A-J)', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // Seed an existing club for Bob with slug 'bob-mc' await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_bob_exist'), { id: 'club_bob_exist', name: 'Bob Existing MC', slug: 'bob-mc', visibility: 'public', admissionPolicy: 'open', ownerUid: 'bob', memberCount: 1, archivedAt: null, }); await setDoc(doc(db, 'clubs/club_bob_exist/members/bob'), { uid: 'bob', clubId: 'club_bob_exist', status: 'active', role: 'owner', }); await setDoc(doc(db, 'club_handles/bob-mc'), { slug: 'bob-mc', clubId: 'club_bob_exist', ownerUid: 'bob', createdAt: new Date(), }); }); // A. Standalone handle reservation with nonexistent club -> DENIED await assertFails(setDoc(doc(alice, 'club_handles/standalone-slug'), { slug: 'standalone-slug', clubId: 'club_nonexistent', ownerUid: 'alice', createdAt: new Date(), })); // B. Standalone handle reservation pointing at existing club with different slug -> DENIED await assertFails(setDoc(doc(bob, 'club_handles/hijack-slug'), { slug: 'hijack-slug', clubId: 'club_bob_exist', ownerUid: 'bob', createdAt: new Date(), })); // C. Standalone handle reservation pointing at another user's club -> DENIED await assertFails(setDoc(doc(alice, 'club_handles/bob-mc-stolen'), { slug: 'bob-mc-stolen', clubId: 'club_bob_exist', ownerUid: 'alice', createdAt: new Date(), })); // D. Batch containing handle + club but missing owner membership -> DENIED const missingOwnerMemberBatch = writeBatch(alice); missingOwnerMemberBatch.set(doc(alice, 'clubs/club_missing_owner'), { id: 'club_missing_owner', name: 'Missing Owner MC', slug: 'missing-owner-mc', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); missingOwnerMemberBatch.set(doc(alice, 'club_handles/missing-owner-mc'), { slug: 'missing-owner-mc', clubId: 'club_missing_owner', ownerUid: 'alice', createdAt: new Date(), }); await assertFails(missingOwnerMemberBatch.commit()); // E. Batch containing handle + owner membership but missing club -> DENIED const missingClubBatch = writeBatch(alice); missingClubBatch.set(doc(alice, 'clubs/club_ghost/members/alice'), { uid: 'alice', clubId: 'club_ghost', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); missingClubBatch.set(doc(alice, 'club_handles/ghost-mc'), { slug: 'ghost-mc', clubId: 'club_ghost', ownerUid: 'alice', createdAt: new Date(), }); await assertFails(missingClubBatch.commit()); // F. Batch containing club + owner membership but missing handle -> DENIED const missingHandleBatch = writeBatch(alice); missingHandleBatch.set(doc(alice, 'clubs/club_no_handle'), { id: 'club_no_handle', name: 'No Handle MC', slug: 'no-handle-mc', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); missingHandleBatch.set(doc(alice, 'clubs/club_no_handle/members/alice'), { uid: 'alice', clubId: 'club_no_handle', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); await assertFails(missingHandleBatch.commit()); // G. Handle ownerUid mismatch with club.ownerUid -> DENIED const ownerMismatchBatch = writeBatch(alice); ownerMismatchBatch.set(doc(alice, 'clubs/club_owner_mismatch'), { id: 'club_owner_mismatch', name: 'Mismatch Owner MC', slug: 'mismatch-owner', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); ownerMismatchBatch.set(doc(alice, 'clubs/club_owner_mismatch/members/alice'), { uid: 'alice', clubId: 'club_owner_mismatch', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); ownerMismatchBatch.set(doc(alice, 'club_handles/mismatch-owner'), { slug: 'mismatch-owner', clubId: 'club_owner_mismatch', ownerUid: 'bob', // Falsified owner createdAt: new Date(), }); await assertFails(ownerMismatchBatch.commit()); // H. Handle slug mismatch with club.slug -> DENIED const slugMismatchBatch = writeBatch(alice); slugMismatchBatch.set(doc(alice, 'clubs/club_slug_mismatch'), { id: 'club_slug_mismatch', name: 'Mismatch Slug MC', slug: 'slug-in-club', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); slugMismatchBatch.set(doc(alice, 'clubs/club_slug_mismatch/members/alice'), { uid: 'alice', clubId: 'club_slug_mismatch', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); slugMismatchBatch.set(doc(alice, 'club_handles/slug-in-handle'), { slug: 'slug-in-handle', clubId: 'club_slug_mismatch', ownerUid: 'alice', createdAt: new Date(), }); await assertFails(slugMismatchBatch.commit()); // I. Correct three-document atomic club creation -> ALLOWED const validThreeDocBatch = writeBatch(alice); validThreeDocBatch.set(doc(alice, 'clubs/club_atomic_ok'), { id: 'club_atomic_ok', name: 'Atomic OK MC', slug: 'atomic-ok', description: 'Perfect 3-doc creation', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); validThreeDocBatch.set(doc(alice, 'clubs/club_atomic_ok/members/alice'), { uid: 'alice', clubId: 'club_atomic_ok', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); validThreeDocBatch.set(doc(alice, 'club_handles/atomic-ok'), { slug: 'atomic-ok', clubId: 'club_atomic_ok', ownerUid: 'alice', createdAt: new Date(), }); await assertSucceeds(validThreeDocBatch.commit()); // J. Duplicate slug -> DENIED const dupSlugBatch = writeBatch(bob); dupSlugBatch.set(doc(bob, 'clubs/club_dup_slug'), { id: 'club_dup_slug', name: 'Duplicate Slug MC', slug: 'atomic-ok', // Duplicate! visibility: 'public', admissionPolicy: 'open', ownerUid: 'bob', memberCount: 1, createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); dupSlugBatch.set(doc(bob, 'clubs/club_dup_slug/members/bob'), { uid: 'bob', clubId: 'club_dup_slug', status: 'active', role: 'owner', joinedAt: new Date(), updatedAt: new Date(), }); dupSlugBatch.set(doc(bob, 'club_handles/atomic-ok'), { slug: 'atomic-ok', clubId: 'club_dup_slug', ownerUid: 'bob', createdAt: new Date(), }); await assertFails(dupSlugBatch.commit()); // Point read and collection list checks: // Point read existing handle -> ALLOW await assertSucceeds(getDoc(doc(bob, 'club_handles/atomic-ok'))); // List all handles -> DENIED await assertFails(getDocs(collection(bob, 'club_handles'))); }); test('Orphan owner membership creation attack: standalone or uncoupled owner creation rejected', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); // 1. Attacker (bob) attempts to create standalone owner member in nonexistent club -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_ghost_orphan/members/bob'), { uid: 'bob', clubId: 'club_ghost_orphan', status: 'active', role: 'owner', })); // 2. Attacker (bob) attempts to inject self as owner into existing club -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_atomic_ok/members/bob'), { uid: 'bob', clubId: 'club_atomic_ok', status: 'active', role: 'owner', })); }); test('Slug handle invariants: deletion and updates strictly prohibited (permanent tombstone)', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); // Seed handle await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'club_handles/tombstone-slug'), { slug: 'tombstone-slug', clubId: 'club_tombstone', ownerUid: 'alice', createdAt: new Date(), }); }); // 1. Owner Alice attempts to delete slug -> DENY await assertFails(deleteDoc(doc(alice, 'club_handles/tombstone-slug'))); // 2. Non-owner Bob attempts to delete slug -> DENY await assertFails(deleteDoc(doc(bob, 'club_handles/tombstone-slug'))); // 3. Owner Alice attempts to update slug -> DENY await assertFails(updateDoc(doc(alice, 'club_handles/tombstone-slug'), { clubId: 'club_reassigned', })); // 4. Non-owner Bob attempts to update slug -> DENY await assertFails(updateDoc(doc(bob, 'club_handles/tombstone-slug'), { ownerUid: 'bob', })); }); test('Club root slug immutability: slug cannot be modified by owner, admin, or member', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_slug_immutability'), { id: 'club_slug_immutability', name: 'Immutability MC', slug: 'immutability-mc', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 3, }); await setDoc(doc(db, 'clubs/club_slug_immutability/members/alice'), { uid: 'alice', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_slug_immutability/members/bob'), { uid: 'bob', role: 'admin', status: 'active', }); await setDoc(doc(db, 'clubs/club_slug_immutability/members/charlie'), { uid: 'charlie', role: 'member', status: 'active', }); }); // 1. Owner Alice attempts to change slug -> DENY await assertFails(updateDoc(doc(alice, 'clubs/club_slug_immutability'), { slug: 'new-slug', })); // 2. Admin Bob attempts to change slug -> DENY await assertFails(updateDoc(doc(bob, 'clubs/club_slug_immutability'), { slug: 'new-slug', })); // 3. Member Charlie attempts to change slug -> DENY await assertFails(updateDoc(doc(charlie, 'clubs/club_slug_immutability'), { slug: 'new-slug', })); }); test('Direct memberCount mutation attacks: standalone or uncoupled mutations strictly rejected', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_counter_attack'), { id: 'club_counter_attack', name: 'Counter Defense Club', slug: 'counter-defense', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 2, }); await setDoc(doc(db, 'clubs/club_counter_attack/members/alice'), { uid: 'alice', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_counter_attack/members/bob'), { uid: 'bob', role: 'admin', status: 'active', }); }); // 1. Standalone memberCount increment by owner without member doc -> DENY await assertFails(updateDoc(doc(alice, 'clubs/club_counter_attack'), { memberCount: 3, updatedAt: new Date(), lastMembershipChangeUid: 'alice', })); // 2. Standalone memberCount increment by admin -> DENY await assertFails(updateDoc(doc(bob, 'clubs/club_counter_attack'), { memberCount: 3, updatedAt: new Date(), lastMembershipChangeUid: 'bob', })); // 3. Standalone memberCount increment by regular member/user Charlie -> DENY await assertFails(updateDoc(doc(charlie, 'clubs/club_counter_attack'), { memberCount: 3, updatedAt: new Date(), lastMembershipChangeUid: 'charlie', })); // 4. Standalone memberCount decrement by owner -> DENY await assertFails(updateDoc(doc(alice, 'clubs/club_counter_attack'), { memberCount: 1, updatedAt: new Date(), lastMembershipChangeUid: 'alice', })); // 5. Arbitrary jump (+5) in a batch with a single member addition -> DENY const jumpBatch = writeBatch(charlie); jumpBatch.set(doc(charlie, 'clubs/club_counter_attack/members/charlie'), { uid: 'charlie', clubId: 'club_counter_attack', status: 'active', role: 'member', }); jumpBatch.update(doc(charlie, 'clubs/club_counter_attack'), { memberCount: 7, updatedAt: new Date(), lastMembershipChangeUid: 'charlie', }); await assertFails(jumpBatch.commit()); // 6. Arbitrary decrement (-5) or decrement below 1 -> DENY const dropBatch = writeBatch(bob); dropBatch.update(doc(bob, 'clubs/club_counter_attack/members/bob'), { status: 'left', }); dropBatch.update(doc(bob, 'clubs/club_counter_attack'), { memberCount: 0, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(dropBatch.commit()); }); test('Ownership transfer and archive invariants: memberCount and slug preserved', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_preserve_test'), { id: 'club_preserve_test', name: 'Preserve MC', slug: 'preserve-mc', visibility: 'public', admissionPolicy: 'open', ownerUid: 'alice', memberCount: 2, }); await setDoc(doc(db, 'clubs/club_preserve_test/members/alice'), { uid: 'alice', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_preserve_test/members/bob'), { uid: 'bob', role: 'admin', status: 'active', }); }); // 1. Ownership transfer attempting to alter memberCount -> DENY const transferCountAttack = writeBatch(alice); transferCountAttack.update(doc(alice, 'clubs/club_preserve_test'), { ownerUid: 'bob', memberCount: 99, updatedAt: new Date(), }); transferCountAttack.update(doc(alice, 'clubs/club_preserve_test/members/alice'), { role: 'admin' }); transferCountAttack.update(doc(alice, 'clubs/club_preserve_test/members/bob'), { role: 'owner' }); await assertFails(transferCountAttack.commit()); // 2. Ownership transfer attempting to mutate slug -> DENY const transferSlugAttack = writeBatch(alice); transferSlugAttack.update(doc(alice, 'clubs/club_preserve_test'), { ownerUid: 'bob', slug: 'altered-slug', updatedAt: new Date(), }); transferSlugAttack.update(doc(alice, 'clubs/club_preserve_test/members/alice'), { role: 'admin' }); transferSlugAttack.update(doc(alice, 'clubs/club_preserve_test/members/bob'), { role: 'owner' }); await assertFails(transferSlugAttack.commit()); // 3. Valid archive by owner -> ALLOW await assertSucceeds(updateDoc(doc(alice, 'clubs/club_preserve_test'), { archivedAt: new Date(), updatedAt: new Date(), })); // 4. Archive attempting to alter memberCount -> DENY await assertFails(updateDoc(doc(alice, 'clubs/club_preserve_test'), { archivedAt: new Date(), memberCount: 50, updatedAt: new Date(), })); // 5. Archive attempting to mutate slug -> DENY await assertFails(updateDoc(doc(alice, 'clubs/club_preserve_test'), { archivedAt: new Date(), slug: 'archived-preserve-mc', updatedAt: new Date(), })); // 6. Admin Bob attempts to archive -> DENY (only owner may archive) await assertFails(updateDoc(doc(bob, 'clubs/club_preserve_test'), { archivedAt: new Date(), updatedAt: new Date(), })); }); test('FP-14.2: Club Settings access authority and boundary isolation (Admin authority & attack matrix)', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); const eve = testEnv.authenticatedContext('eve').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_settings_auth'), { id: 'club_settings_auth', name: 'Settings Authority MC', slug: 'settings-authority-mc', visibility: 'public', admissionPolicy: 'request', ownerUid: 'alice', memberCount: 3, archivedAt: null, createdAt: new Date(), updatedAt: new Date(), }); // Owner Alice await setDoc(doc(db, 'clubs/club_settings_auth/members/alice'), { uid: 'alice', clubId: 'club_settings_auth', role: 'owner', status: 'active', }); // Active Admin Bob await setDoc(doc(db, 'clubs/club_settings_auth/members/bob'), { uid: 'bob', clubId: 'club_settings_auth', role: 'admin', status: 'active', }); // Active Member Charlie with displayRank 'President' await setDoc(doc(db, 'clubs/club_settings_auth/members/charlie'), { uid: 'charlie', clubId: 'club_settings_auth', role: 'member', status: 'active', displayRank: 'President', }); // Removed former admin Dave await setDoc(doc(db, 'clubs/club_settings_auth/members/dave'), { uid: 'dave', clubId: 'club_settings_auth', role: 'admin', status: 'removed', }); // Former admin Eve who left await setDoc(doc(db, 'clubs/club_settings_auth/members/eve'), { uid: 'eve', clubId: 'club_settings_auth', role: 'admin', status: 'left', }); }); const clubDocRef = (db) => doc(db, 'clubs/club_settings_auth'); // 1. Active Admin valid visibility update -> ALLOW await assertSucceeds(updateDoc(clubDocRef(bob), { visibility: 'unlisted', updatedAt: new Date(), })); await assertSucceeds(updateDoc(clubDocRef(bob), { visibility: 'private', updatedAt: new Date(), })); // 2. Active Admin valid admissionPolicy update -> ALLOW await assertSucceeds(updateDoc(clubDocRef(bob), { admissionPolicy: 'open', updatedAt: new Date(), })); await assertSucceeds(updateDoc(clubDocRef(bob), { admissionPolicy: 'inviteOnly', updatedAt: new Date(), })); // 3. Admin invalid visibility enum -> DENY await assertFails(updateDoc(clubDocRef(bob), { visibility: 'hidden_leak', updatedAt: new Date(), })); // 4. Admin invalid admissionPolicy enum -> DENY await assertFails(updateDoc(clubDocRef(bob), { admissionPolicy: 'free_for_all', updatedAt: new Date(), })); // 5. Admin visibility + ownerUid escalation -> DENY await assertFails(updateDoc(clubDocRef(bob), { visibility: 'private', ownerUid: 'bob', updatedAt: new Date(), })); // 6. Admin admission + archivedAt mutation -> DENY await assertFails(updateDoc(clubDocRef(bob), { admissionPolicy: 'inviteOnly', archivedAt: new Date(), updatedAt: new Date(), })); // 7. Admin visibility + slug mutation -> DENY await assertFails(updateDoc(clubDocRef(bob), { visibility: 'unlisted', slug: 'hacked-handle', updatedAt: new Date(), })); // 8. Admin visibility + memberCount mutation -> DENY await assertFails(updateDoc(clubDocRef(bob), { visibility: 'unlisted', memberCount: 100, updatedAt: new Date(), })); // 9. Regular Member visibility/admission mutation -> DENY await assertFails(updateDoc(clubDocRef(charlie), { visibility: 'public', updatedAt: new Date(), })); await assertFails(updateDoc(clubDocRef(charlie), { admissionPolicy: 'open', updatedAt: new Date(), })); // 10. Member with displayRank President mutation -> DENY (displayRank grants 0 authority) await assertFails(updateDoc(clubDocRef(charlie), { visibility: 'unlisted', updatedAt: new Date(), })); // 11. Inactive/former Admin mutation -> DENY await assertFails(updateDoc(clubDocRef(dave), { visibility: 'unlisted', updatedAt: new Date(), })); await assertFails(updateDoc(clubDocRef(eve), { admissionPolicy: 'open', updatedAt: new Date(), })); // 12. Owner valid updates and enum validation await assertSucceeds(updateDoc(clubDocRef(alice), { visibility: 'public', admissionPolicy: 'request', updatedAt: new Date(), })); await assertFails(updateDoc(clubDocRef(alice), { visibility: 'invalid_vis', updatedAt: new Date(), })); await assertFails(updateDoc(clubDocRef(alice), { admissionPolicy: 'invalid_policy', updatedAt: new Date(), })); }); test('FP-14.3: Club logo/banner HTTPS URL invariant enforced at the storage layer', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_image_url_sec'), { id: 'club_image_url_sec', name: 'Image URL Security MC', slug: 'image-url-security-mc', visibility: 'public', admissionPolicy: 'request', ownerUid: 'alice', memberCount: 3, archivedAt: null, createdAt: new Date(), updatedAt: new Date(), }); await setDoc(doc(db, 'clubs/club_image_url_sec/members/alice'), { uid: 'alice', clubId: 'club_image_url_sec', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_image_url_sec/members/bob'), { uid: 'bob', clubId: 'club_image_url_sec', role: 'admin', status: 'active', }); await setDoc(doc(db, 'clubs/club_image_url_sec/members/charlie'), { uid: 'charlie', clubId: 'club_image_url_sec', role: 'member', status: 'active', displayRank: 'President', }); }); const clubDocRef = (db) => doc(db, 'clubs/club_image_url_sec'); // 1. Owner sets valid https logo/banner -> ALLOW await assertSucceeds(updateDoc(clubDocRef(alice), { logoUrl: 'https://example.com/logo.png', bannerUrl: 'https://cdn.example.com/banner.jpg', updatedAt: new Date(), })); // 2. Owner clears logo with null and banner with empty string -> ALLOW await assertSucceeds(updateDoc(clubDocRef(alice), { logoUrl: null, updatedAt: new Date(), })); await assertSucceeds(updateDoc(clubDocRef(alice), { bannerUrl: '', updatedAt: new Date(), })); // 3. Admin sets valid https URLs -> ALLOW await assertSucceeds(updateDoc(clubDocRef(bob), { logoUrl: 'https://example.com/admin-logo.png', updatedAt: new Date(), })); // 4. Owner http:// URL -> DENY (insecure scheme) await assertFails(updateDoc(clubDocRef(alice), { logoUrl: 'http://evil.example.com/logo.png', updatedAt: new Date(), })); // 5. Owner data: URL -> DENY await assertFails(updateDoc(clubDocRef(alice), { logoUrl: 'data:image/png;base64,iVBORw0KGgo=', updatedAt: new Date(), })); // 6. Owner blob: URL -> DENY await assertFails(updateDoc(clubDocRef(alice), { bannerUrl: 'blob:https://example.com/fake-uuid', updatedAt: new Date(), })); // 7. Owner javascript: URL -> DENY await assertFails(updateDoc(clubDocRef(alice), { bannerUrl: 'javascript:alert(1)', updatedAt: new Date(), })); // 8. Owner malformed value -> DENY await assertFails(updateDoc(clubDocRef(alice), { logoUrl: 'not-a-url', updatedAt: new Date(), })); // 9. Owner non-string URL value -> DENY await assertFails(updateDoc(clubDocRef(alice), { logoUrl: 42, updatedAt: new Date(), })); // 10. Admin http:// URL -> DENY (same invariant for admins) await assertFails(updateDoc(clubDocRef(bob), { bannerUrl: 'http://evil.example.com/banner.jpg', updatedAt: new Date(), })); // 11. Regular member writes valid https URL -> DENY (no management authority at all) await assertFails(updateDoc(clubDocRef(charlie), { logoUrl: 'https://example.com/member-logo.png', updatedAt: new Date(), })); }); test('FP-15: Club Applications membership request approval and decline security matrix', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const unauth = testEnv.unauthenticatedContext().firestore(); await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs/club_apps_sec'), { id: 'club_apps_sec', name: 'Applications Security MC', slug: 'applications-security-mc', visibility: 'public', admissionPolicy: 'request', ownerUid: 'alice', memberCount: 2, archivedAt: null, createdAt: new Date(), updatedAt: new Date(), }); // Owner Alice await setDoc(doc(db, 'clubs/club_apps_sec/members/alice'), { uid: 'alice', clubId: 'club_apps_sec', role: 'owner', status: 'active', }); // Active Admin Bob await setDoc(doc(db, 'clubs/club_apps_sec/members/bob'), { uid: 'bob', clubId: 'club_apps_sec', role: 'admin', status: 'active', }); // Active Member Charlie with displayRank 'President' await setDoc(doc(db, 'clubs/club_apps_sec/members/charlie'), { uid: 'charlie', clubId: 'club_apps_sec', role: 'member', status: 'active', displayRank: 'President', }); // Applicant Dan (requested) await setDoc(doc(db, 'clubs/club_apps_sec/members/dan'), { uid: 'dan', clubId: 'club_apps_sec', role: 'member', status: 'requested', }); // Applicant Frank (requested) await setDoc(doc(db, 'clubs/club_apps_sec/members/frank'), { uid: 'frank', clubId: 'club_apps_sec', role: 'member', status: 'requested', }); // Applicant Gina (requested) await setDoc(doc(db, 'clubs/club_apps_sec/members/gina'), { uid: 'gina', clubId: 'club_apps_sec', role: 'member', status: 'requested', }); // Applicant Harry (requested) await setDoc(doc(db, 'clubs/club_apps_sec/members/harry'), { uid: 'harry', clubId: 'club_apps_sec', role: 'member', status: 'requested', }); // Member Jack (already active - stale request target) await setDoc(doc(db, 'clubs/club_apps_sec/members/jack'), { uid: 'jack', clubId: 'club_apps_sec', role: 'member', status: 'active', }); }); // 1. Owner requested -> active with atomic memberCount increment -> ALLOW const ownerApproveBatch = writeBatch(alice); ownerApproveBatch.update(doc(alice, 'clubs/club_apps_sec/members/dan'), { status: 'active', role: 'member', displayRank: 'Member', updatedAt: new Date(), }); ownerApproveBatch.update(doc(alice, 'clubs/club_apps_sec'), { memberCount: 3, updatedAt: new Date(), lastMembershipChangeUid: 'dan', }); await assertSucceeds(ownerApproveBatch.commit()); // 2. Admin requested -> active with atomic memberCount increment -> ALLOW const adminApproveBatch = writeBatch(bob); adminApproveBatch.update(doc(bob, 'clubs/club_apps_sec/members/frank'), { status: 'active', role: 'member', displayRank: 'Member', updatedAt: new Date(), }); adminApproveBatch.update(doc(bob, 'clubs/club_apps_sec'), { memberCount: 4, updatedAt: new Date(), lastMembershipChangeUid: 'frank', }); await assertSucceeds(adminApproveBatch.commit()); // 3. Owner requested -> declined (memberCount unchanged) -> ALLOW await assertSucceeds(updateDoc(doc(alice, 'clubs/club_apps_sec/members/gina'), { status: 'declined', updatedAt: new Date(), })); // 4. Admin requested -> declined (memberCount unchanged) -> ALLOW await assertSucceeds(updateDoc(doc(bob, 'clubs/club_apps_sec/members/harry'), { status: 'declined', updatedAt: new Date(), })); // 5. Member Charlie (displayRank 'President') approve -> DENY await testEnv.withSecurityRulesDisabled(async (context) => { await setDoc(doc(context.firestore(), 'clubs/club_apps_sec/members/kevin'), { uid: 'kevin', clubId: 'club_apps_sec', role: 'member', status: 'requested', }); }); const memberApproveBatch = writeBatch(charlie); memberApproveBatch.update(doc(charlie, 'clubs/club_apps_sec/members/kevin'), { status: 'active', role: 'member', updatedAt: new Date(), }); memberApproveBatch.update(doc(charlie, 'clubs/club_apps_sec'), { memberCount: 5, updatedAt: new Date(), lastMembershipChangeUid: 'kevin', }); await assertFails(memberApproveBatch.commit()); // 6. Member Charlie decline -> DENY await assertFails(updateDoc(doc(charlie, 'clubs/club_apps_sec/members/kevin'), { status: 'declined', updatedAt: new Date(), })); // 7. Unauthenticated approve -> DENY const unauthBatch = writeBatch(unauth); unauthBatch.update(doc(unauth, 'clubs/club_apps_sec/members/kevin'), { status: 'active', role: 'member', updatedAt: new Date(), }); unauthBatch.update(doc(unauth, 'clubs/club_apps_sec'), { memberCount: 5, updatedAt: new Date(), lastMembershipChangeUid: 'kevin', }); await assertFails(unauthBatch.commit()); // 8. Admin approve without atomic memberCount increment -> DENY await assertFails(updateDoc(doc(bob, 'clubs/club_apps_sec/members/kevin'), { status: 'active', role: 'member', updatedAt: new Date(), })); // 9. Admin approve stale (already active) target -> DENY const staleBatch = writeBatch(bob); staleBatch.update(doc(bob, 'clubs/club_apps_sec/members/jack'), { status: 'active', role: 'member', updatedAt: new Date(), }); staleBatch.update(doc(bob, 'clubs/club_apps_sec'), { memberCount: 5, updatedAt: new Date(), lastMembershipChangeUid: 'jack', }); await assertFails(staleBatch.commit()); }); describe('CLUB-2C.2: Membership Re-Entry Attack Matrix (Cases A - AF)', () => { async function setupClub(db, clubId, admissionPolicy, archived = false) { await setDoc(doc(db, `clubs/${clubId}`), { id: clubId, name: `${clubId} Name`, slug: `${clubId}-slug`, visibility: 'public', admissionPolicy, ownerUid: 'alice', memberCount: 1, archivedAt: archived ? new Date() : null, }); await setDoc(doc(db, `clubs/${clubId}/members/alice`), { uid: 'alice', clubId, status: 'active', role: 'owner', }); } async function seedTombstone(db, clubId, uid, status) { await setDoc(doc(db, `clubs/${clubId}/members/${uid}`), { uid, clubId, status, role: 'member', }); } async function seedBan(db, clubId, uid) { await setDoc(doc(db, `clubs/${clubId}/bans/${uid}`), { uid, bannedAt: new Date(), }); } // A. left -> active OPEN succeeds test('Case A: left -> active in OPEN club succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_a', 'open'); await seedTombstone(db, 'club_reentry_a', 'bob', 'left'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_a/members/bob'), { uid: 'bob', clubId: 'club_reentry_a', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_a'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertSucceeds(batch.commit()); }); // B. declined -> active OPEN succeeds test('Case B: declined -> active in OPEN club succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_b', 'open'); await seedTombstone(db, 'club_reentry_b', 'bob', 'declined'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_b/members/bob'), { uid: 'bob', clubId: 'club_reentry_b', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_b'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertSucceeds(batch.commit()); }); // C. removed -> active OPEN succeeds test('Case C: removed -> active in OPEN club succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_c', 'open'); await seedTombstone(db, 'club_reentry_c', 'bob', 'removed'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_c/members/bob'), { uid: 'bob', clubId: 'club_reentry_c', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_c'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertSucceeds(batch.commit()); }); // D. left -> requested REQUEST succeeds test('Case D: left -> requested in REQUEST club succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_d', 'request'); await seedTombstone(db, 'club_reentry_d', 'bob', 'left'); }); await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_d/members/bob'), { uid: 'bob', clubId: 'club_reentry_d', status: 'requested', role: 'member', })); }); // E. declined -> requested REQUEST succeeds test('Case E: declined -> requested in REQUEST club succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_e', 'request'); await seedTombstone(db, 'club_reentry_e', 'bob', 'declined'); }); await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_e/members/bob'), { uid: 'bob', clubId: 'club_reentry_e', status: 'requested', role: 'member', })); }); // F. removed -> requested REQUEST succeeds test('Case F: removed -> requested in REQUEST club succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_f', 'request'); await seedTombstone(db, 'club_reentry_f', 'bob', 'removed'); }); await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_f/members/bob'), { uid: 'bob', clubId: 'club_reentry_f', status: 'requested', role: 'member', })); }); // G. left -> active REQUEST denied test('Case G: left -> active in REQUEST club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_g', 'request'); await seedTombstone(db, 'club_reentry_g', 'bob', 'left'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_g/members/bob'), { uid: 'bob', clubId: 'club_reentry_g', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_g'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // H. declined -> active REQUEST denied test('Case H: declined -> active in REQUEST club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_h', 'request'); await seedTombstone(db, 'club_reentry_h', 'bob', 'declined'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_h/members/bob'), { uid: 'bob', clubId: 'club_reentry_h', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_h'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // I. removed -> active REQUEST denied test('Case I: removed -> active in REQUEST club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_i', 'request'); await seedTombstone(db, 'club_reentry_i', 'bob', 'removed'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_i/members/bob'), { uid: 'bob', clubId: 'club_reentry_i', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_i'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // J. left -> requested OPEN denied test('Case J: left -> requested in OPEN club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_j', 'open'); await seedTombstone(db, 'club_reentry_j', 'bob', 'left'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_j/members/bob'), { uid: 'bob', clubId: 'club_reentry_j', status: 'requested', role: 'member', })); }); // K. declined -> requested OPEN denied test('Case K: declined -> requested in OPEN club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_k', 'open'); await seedTombstone(db, 'club_reentry_k', 'bob', 'declined'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_k/members/bob'), { uid: 'bob', clubId: 'club_reentry_k', status: 'requested', role: 'member', })); }); // L. removed -> requested OPEN denied test('Case L: removed -> requested in OPEN club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_l', 'open'); await seedTombstone(db, 'club_reentry_l', 'bob', 'removed'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_l/members/bob'), { uid: 'bob', clubId: 'club_reentry_l', status: 'requested', role: 'member', })); }); // M. left -> active INVITE_ONLY denied test('Case M: left -> active in INVITE_ONLY club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_m', 'inviteOnly'); await seedTombstone(db, 'club_reentry_m', 'bob', 'left'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_m/members/bob'), { uid: 'bob', clubId: 'club_reentry_m', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_m'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // N. declined -> active INVITE_ONLY denied test('Case N: declined -> active in INVITE_ONLY club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_n', 'inviteOnly'); await seedTombstone(db, 'club_reentry_n', 'bob', 'declined'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_n/members/bob'), { uid: 'bob', clubId: 'club_reentry_n', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_n'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // O. removed -> active INVITE_ONLY denied test('Case O: removed -> active in INVITE_ONLY club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_o', 'inviteOnly'); await seedTombstone(db, 'club_reentry_o', 'bob', 'removed'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_o/members/bob'), { uid: 'bob', clubId: 'club_reentry_o', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_o'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // P. left -> requested INVITE_ONLY denied test('Case P: left -> requested in INVITE_ONLY club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_p', 'inviteOnly'); await seedTombstone(db, 'club_reentry_p', 'bob', 'left'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_p/members/bob'), { uid: 'bob', clubId: 'club_reentry_p', status: 'requested', role: 'member', })); }); // Q. declined -> requested INVITE_ONLY denied test('Case Q: declined -> requested in INVITE_ONLY club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_q', 'inviteOnly'); await seedTombstone(db, 'club_reentry_q', 'bob', 'declined'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_q/members/bob'), { uid: 'bob', clubId: 'club_reentry_q', status: 'requested', role: 'member', })); }); // R. removed -> requested INVITE_ONLY denied test('Case R: removed -> requested in INVITE_ONLY club denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_r', 'inviteOnly'); await seedTombstone(db, 'club_reentry_r', 'bob', 'removed'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_r/members/bob'), { uid: 'bob', clubId: 'club_reentry_r', status: 'requested', role: 'member', })); }); // S. banned left -> active denied test('Case S: banned left -> active denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_s', 'open'); await seedTombstone(db, 'club_reentry_s', 'bob', 'left'); await seedBan(db, 'club_reentry_s', 'bob'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_s/members/bob'), { uid: 'bob', clubId: 'club_reentry_s', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_s'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // T. banned declined -> active denied test('Case T: banned declined -> active denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_t', 'open'); await seedTombstone(db, 'club_reentry_t', 'bob', 'declined'); await seedBan(db, 'club_reentry_t', 'bob'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_t/members/bob'), { uid: 'bob', clubId: 'club_reentry_t', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_t'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // U. banned removed -> active denied test('Case U: banned removed -> active denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_u', 'open'); await seedTombstone(db, 'club_reentry_u', 'bob', 'removed'); await seedBan(db, 'club_reentry_u', 'bob'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_u/members/bob'), { uid: 'bob', clubId: 'club_reentry_u', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_u'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // V. banned left -> requested denied test('Case V: banned left -> requested denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_v', 'request'); await seedTombstone(db, 'club_reentry_v', 'bob', 'left'); await seedBan(db, 'club_reentry_v', 'bob'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_v/members/bob'), { uid: 'bob', clubId: 'club_reentry_v', status: 'requested', role: 'member', })); }); // W. banned declined -> requested denied test('Case W: banned declined -> requested denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_w', 'request'); await seedTombstone(db, 'club_reentry_w', 'bob', 'declined'); await seedBan(db, 'club_reentry_w', 'bob'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_w/members/bob'), { uid: 'bob', clubId: 'club_reentry_w', status: 'requested', role: 'member', })); }); // X. banned removed -> requested denied test('Case X: banned removed -> requested denied', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_x', 'request'); await seedTombstone(db, 'club_reentry_x', 'bob', 'removed'); await seedBan(db, 'club_reentry_x', 'bob'); }); await assertFails(setDoc(doc(bob, 'clubs/club_reentry_x/members/bob'), { uid: 'bob', clubId: 'club_reentry_x', status: 'requested', role: 'member', })); }); // Y. re-entry cannot self-promote role to admin test('Case Y: re-entry cannot self-promote role to admin', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_y', 'open'); await seedTombstone(db, 'club_reentry_y', 'bob', 'left'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_y/members/bob'), { uid: 'bob', clubId: 'club_reentry_y', status: 'active', role: 'admin', }); batch.update(doc(bob, 'clubs/club_reentry_y'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // Z. re-entry cannot self-promote role to owner test('Case Z: re-entry cannot self-promote role to owner', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_z', 'open'); await seedTombstone(db, 'club_reentry_z', 'bob', 'left'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_z/members/bob'), { uid: 'bob', clubId: 'club_reentry_z', status: 'active', role: 'owner', }); batch.update(doc(bob, 'clubs/club_reentry_z'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // AA. re-entry cannot inject rankId test('Case AA: re-entry cannot inject rankId, displayRank, or invitedByUid', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_aa', 'open'); await seedTombstone(db, 'club_reentry_aa', 'bob', 'left'); }); // 1. rankId injection const rankBatch = writeBatch(bob); rankBatch.set(doc(bob, 'clubs/club_reentry_aa/members/bob'), { uid: 'bob', clubId: 'club_reentry_aa', status: 'active', role: 'member', rankId: 'officer_rank', }); rankBatch.update(doc(bob, 'clubs/club_reentry_aa'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(rankBatch.commit()); // 2. displayRank injection const displayRankBatch = writeBatch(bob); displayRankBatch.set(doc(bob, 'clubs/club_reentry_aa/members/bob'), { uid: 'bob', clubId: 'club_reentry_aa', status: 'active', role: 'member', displayRank: 'Road Captain', }); displayRankBatch.update(doc(bob, 'clubs/club_reentry_aa'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(displayRankBatch.commit()); // 3. invitedByUid injection const inviteInjectBatch = writeBatch(bob); inviteInjectBatch.set(doc(bob, 'clubs/club_reentry_aa/members/bob'), { uid: 'bob', clubId: 'club_reentry_aa', status: 'active', role: 'member', invitedByUid: 'alice', }); inviteInjectBatch.update(doc(bob, 'clubs/club_reentry_aa'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(inviteInjectBatch.commit()); }); // AB. re-entry cannot alter ownerUid test('Case AB: re-entry cannot alter ownerUid', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ab', 'open'); await seedTombstone(db, 'club_reentry_ab', 'bob', 'left'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_ab/members/bob'), { uid: 'bob', clubId: 'club_reentry_ab', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_ab'), { memberCount: 2, ownerUid: 'bob', updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // AC. re-entry cannot manipulate memberCount test('Case AC: re-entry cannot manipulate memberCount (arbitrary jump or missing increment)', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ac', 'open'); await seedTombstone(db, 'club_reentry_ac', 'bob', 'left'); }); // Arbitrary jump (+5) const jumpBatch = writeBatch(bob); jumpBatch.set(doc(bob, 'clubs/club_reentry_ac/members/bob'), { uid: 'bob', clubId: 'club_reentry_ac', status: 'active', role: 'member', }); jumpBatch.update(doc(bob, 'clubs/club_reentry_ac'), { memberCount: 6, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(jumpBatch.commit()); // Zero increment (memberCount remains 1) const zeroBatch = writeBatch(bob); zeroBatch.set(doc(bob, 'clubs/club_reentry_ac/members/bob'), { uid: 'bob', clubId: 'club_reentry_ac', status: 'active', role: 'member', }); zeroBatch.update(doc(bob, 'clubs/club_reentry_ac'), { memberCount: 1, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(zeroBatch.commit()); }); // AD. re-entry cannot bypass archived Club test('Case AD: re-entry cannot bypass archived Club', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ad', 'open', true); await seedTombstone(db, 'club_reentry_ad', 'bob', 'left'); }); const batch = writeBatch(bob); batch.set(doc(bob, 'clubs/club_reentry_ad/members/bob'), { uid: 'bob', clubId: 'club_reentry_ad', status: 'active', role: 'member', }); batch.update(doc(bob, 'clubs/club_reentry_ad'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(batch.commit()); }); // AE. request -> withdraw -> request again succeeds test('Case AE: request -> withdraw -> request again succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ae', 'request'); }); // 1. Initial request (none -> requested) await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_ae/members/bob'), { uid: 'bob', clubId: 'club_reentry_ae', status: 'requested', role: 'member', })); // 2. Withdraw request (requested -> left) await assertSucceeds(updateDoc(doc(bob, 'clubs/club_reentry_ae/members/bob'), { status: 'left', updatedAt: new Date(), })); // 3. Request again (left -> requested) await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_ae/members/bob'), { uid: 'bob', clubId: 'club_reentry_ae', status: 'requested', role: 'member', })); }); // AF. join -> leave -> join again succeeds test('Case AF: join -> leave -> join again succeeds', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_af', 'open'); }); // 1. Initial join (none -> active) const joinBatch = writeBatch(bob); joinBatch.set(doc(bob, 'clubs/club_reentry_af/members/bob'), { uid: 'bob', clubId: 'club_reentry_af', status: 'active', role: 'member', }); joinBatch.update(doc(bob, 'clubs/club_reentry_af'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertSucceeds(joinBatch.commit()); // 2. Leave (active -> left) const leaveBatch = writeBatch(bob); leaveBatch.update(doc(bob, 'clubs/club_reentry_af/members/bob'), { status: 'left', }); leaveBatch.update(doc(bob, 'clubs/club_reentry_af'), { memberCount: 1, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertSucceeds(leaveBatch.commit()); // 3. Re-join (left -> active) const rejoinBatch = writeBatch(bob); rejoinBatch.set(doc(bob, 'clubs/club_reentry_af/members/bob'), { uid: 'bob', clubId: 'club_reentry_af', status: 'active', role: 'member', }); rejoinBatch.update(doc(bob, 'clubs/club_reentry_af'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertSucceeds(rejoinBatch.commit()); }); // AG. Arbitrary field injection rejected on re-entry test('Case AG: arbitrary field injection rejected on re-entry', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ag_open', 'open'); await seedTombstone(db, 'club_reentry_ag_open', 'bob', 'left'); await setupClub(db, 'club_reentry_ag_req', 'request'); await seedTombstone(db, 'club_reentry_ag_req', 'bob', 'left'); }); // 1. Open re-entry with arbitrary unknown field -> DENY const openBatch = writeBatch(bob); openBatch.set(doc(bob, 'clubs/club_reentry_ag_open/members/bob'), { uid: 'bob', clubId: 'club_reentry_ag_open', status: 'active', role: 'member', arbitraryUnknownField: 'injected_payload', }); openBatch.update(doc(bob, 'clubs/club_reentry_ag_open'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(openBatch.commit()); // 2. Request re-entry with arbitrary unknown field -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_reentry_ag_req/members/bob'), { uid: 'bob', clubId: 'club_reentry_ag_req', status: 'requested', role: 'member', arbitraryUnknownField: 'injected_payload', })); }); // AH. Non-timestamp type rejected on re-entry test('Case AH: non-timestamp type rejected on re-entry', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ah_open', 'open'); await seedTombstone(db, 'club_reentry_ah_open', 'bob', 'left'); await setupClub(db, 'club_reentry_ah_req', 'request'); await seedTombstone(db, 'club_reentry_ah_req', 'bob', 'left'); }); // 1. Open re-entry with string joinedAt -> DENY const openBatch = writeBatch(bob); openBatch.set(doc(bob, 'clubs/club_reentry_ah_open/members/bob'), { uid: 'bob', clubId: 'club_reentry_ah_open', status: 'active', role: 'member', joinedAt: '2026-09-06T12:00:00Z', }); openBatch.update(doc(bob, 'clubs/club_reentry_ah_open'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(openBatch.commit()); // 2. Request re-entry with integer requestedAt -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_reentry_ah_req/members/bob'), { uid: 'bob', clubId: 'club_reentry_ah_req', status: 'requested', role: 'member', requestedAt: 1725657600, })); }); // AI. Anonymous and unauthenticated callers cannot re-enter test('Case AI: anonymous and unauthenticated callers cannot re-enter', async () => { const anon = testEnv.authenticatedContext('anon', { firebase: { sign_in_provider: 'anonymous' } }).firestore(); const unauth = testEnv.unauthenticatedContext().firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ai_open', 'open'); await seedTombstone(db, 'club_reentry_ai_open', 'anon', 'left'); await seedTombstone(db, 'club_reentry_ai_open', 'unauth_user', 'left'); }); // 1. Anonymous caller attempting open re-entry -> DENY const anonBatch = writeBatch(anon); anonBatch.set(doc(anon, 'clubs/club_reentry_ai_open/members/anon'), { uid: 'anon', clubId: 'club_reentry_ai_open', status: 'active', role: 'member', }); anonBatch.update(doc(anon, 'clubs/club_reentry_ai_open'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'anon', }); await assertFails(anonBatch.commit()); // 2. Unauthenticated caller attempting open re-entry -> DENY const unauthBatch = writeBatch(unauth); unauthBatch.set(doc(unauth, 'clubs/club_reentry_ai_open/members/unauth_user'), { uid: 'unauth_user', clubId: 'club_reentry_ai_open', status: 'active', role: 'member', }); unauthBatch.update(doc(unauth, 'clubs/club_reentry_ai_open'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'unauth_user', }); await assertFails(unauthBatch.commit()); }); // AJ. Cross-user re-entry rejected (caller cannot re-enter for another rider) test('Case AJ: cross-user re-entry rejected', async () => { const charlie = testEnv.authenticatedContext('charlie').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_aj', 'open'); await seedTombstone(db, 'club_reentry_aj', 'bob', 'left'); }); // Charlie tries to re-enter Bob's tombstone doc const batch = writeBatch(charlie); batch.set(doc(charlie, 'clubs/club_reentry_aj/members/bob'), { uid: 'bob', clubId: 'club_reentry_aj', status: 'active', role: 'member', }); batch.update(doc(charlie, 'clubs/club_reentry_aj'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'charlie', }); await assertFails(batch.commit()); }); // AK. Root Club piggyback mutation rejected during re-entry test('Case AK: root Club piggyback mutation rejected during re-entry', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_ak', 'open'); await seedTombstone(db, 'club_reentry_ak', 'bob', 'left'); }); // 1. Bob attempts to piggyback name change with memberCount increment -> DENY const nameBatch = writeBatch(bob); nameBatch.set(doc(bob, 'clubs/club_reentry_ak/members/bob'), { uid: 'bob', clubId: 'club_reentry_ak', status: 'active', role: 'member', }); nameBatch.update(doc(bob, 'clubs/club_reentry_ak'), { name: 'Hijacked Club Name', memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(nameBatch.commit()); // 2. Bob attempts to piggyback visibility change with memberCount increment -> DENY const visBatch = writeBatch(bob); visBatch.set(doc(bob, 'clubs/club_reentry_ak/members/bob'), { uid: 'bob', clubId: 'club_reentry_ak', status: 'active', role: 'member', }); visBatch.update(doc(bob, 'clubs/club_reentry_ak'), { visibility: 'private', memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(visBatch.commit()); // 3. Bob attempts to piggyback admissionPolicy change with memberCount increment -> DENY const policyBatch = writeBatch(bob); policyBatch.set(doc(bob, 'clubs/club_reentry_ak/members/bob'), { uid: 'bob', clubId: 'club_reentry_ak', status: 'active', role: 'member', }); policyBatch.update(doc(bob, 'clubs/club_reentry_ak'), { admissionPolicy: 'inviteOnly', memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(policyBatch.commit()); }); // AL. Blocking parity: mutually blocked rider rejected on re-entry matching initial join test('Case AL: blocking parity: mutually blocked rider rejected on re-entry matching initial join', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setupClub(db, 'club_reentry_al_open', 'open'); await seedTombstone(db, 'club_reentry_al_open', 'bob', 'left'); // Alice (owner) blocks Bob await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' }); await setupClub(db, 'club_reentry_al_req', 'request'); await seedTombstone(db, 'club_reentry_al_req', 'bob', 'left'); }); // 1. Open re-entry when blocked by owner -> DENY const openBatch = writeBatch(bob); openBatch.set(doc(bob, 'clubs/club_reentry_al_open/members/bob'), { uid: 'bob', clubId: 'club_reentry_al_open', status: 'active', role: 'member', }); openBatch.update(doc(bob, 'clubs/club_reentry_al_open'), { memberCount: 2, updatedAt: new Date(), lastMembershipChangeUid: 'bob', }); await assertFails(openBatch.commit()); // 2. Request re-entry when blocked by owner -> DENY await assertFails(setDoc(doc(bob, 'clubs/club_reentry_al_req/members/bob'), { uid: 'bob', clubId: 'club_reentry_al_req', status: 'requested', role: 'member', })); }); }); }); describe('Club GroupRide Security Rules & Attack Matrix', () => { async function seedClubGroupRideEnv() { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); // Users await setDoc(doc(db, 'users/alice'), { id: 'alice' }); await setDoc(doc(db, 'users/bob'), { id: 'bob' }); await setDoc(doc(db, 'users/charlie'), { id: 'charlie' }); await setDoc(doc(db, 'users/dave'), { id: 'dave' }); await setDoc(doc(db, 'users/eve_invited'), { id: 'eve_invited' }); await setDoc(doc(db, 'users/eve_requested'), { id: 'eve_requested' }); await setDoc(doc(db, 'users/eve_declined'), { id: 'eve_declined' }); await setDoc(doc(db, 'users/eve_left'), { id: 'eve_left' }); await setDoc(doc(db, 'users/eve_removed'), { id: 'eve_removed' }); await setDoc(doc(db, 'users/frank'), { id: 'frank' }); // Club Alpha (Active) await setDoc(doc(db, 'clubs/club_alpha'), { id: 'club_alpha', name: 'Club Alpha', slug: 'club-alpha', ownerUid: 'alice', memberCount: 3, visibility: 'public', admissionPolicy: 'open', createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); await setDoc(doc(db, 'clubs/club_alpha/members/alice'), { uid: 'alice', clubId: 'club_alpha', role: 'owner', status: 'active', }); await setDoc(doc(db, 'clubs/club_alpha/members/bob'), { uid: 'bob', clubId: 'club_alpha', role: 'admin', status: 'active', }); await setDoc(doc(db, 'clubs/club_alpha/members/charlie'), { uid: 'charlie', clubId: 'club_alpha', role: 'member', status: 'active', }); await setDoc(doc(db, 'clubs/club_alpha/members/eve_invited'), { uid: 'eve_invited', clubId: 'club_alpha', role: 'member', status: 'invited', }); await setDoc(doc(db, 'clubs/club_alpha/members/eve_requested'), { uid: 'eve_requested', clubId: 'club_alpha', role: 'member', status: 'requested', }); await setDoc(doc(db, 'clubs/club_alpha/members/eve_declined'), { uid: 'eve_declined', clubId: 'club_alpha', role: 'member', status: 'declined', }); await setDoc(doc(db, 'clubs/club_alpha/members/eve_left'), { uid: 'eve_left', clubId: 'club_alpha', role: 'member', status: 'left', }); await setDoc(doc(db, 'clubs/club_alpha/members/eve_removed'), { uid: 'eve_removed', clubId: 'club_alpha', role: 'member', status: 'removed', }); // Club Beta (Active, Frank owner) await setDoc(doc(db, 'clubs/club_beta'), { id: 'club_beta', name: 'Club Beta', slug: 'club-beta', ownerUid: 'frank', memberCount: 1, visibility: 'public', admissionPolicy: 'open', createdAt: new Date(), updatedAt: new Date(), archivedAt: null, }); await setDoc(doc(db, 'clubs/club_beta/members/frank'), { uid: 'frank', clubId: 'club_beta', role: 'owner', status: 'active', }); // Club Archived await setDoc(doc(db, 'clubs/club_archived'), { id: 'club_archived', name: 'Club Archived', slug: 'club-archived', ownerUid: 'alice', memberCount: 1, visibility: 'public', admissionPolicy: 'open', createdAt: new Date(), updatedAt: new Date(), archivedAt: new Date(), }); await setDoc(doc(db, 'clubs/club_archived/members/alice'), { uid: 'alice', clubId: 'club_archived', role: 'owner', status: 'active', }); }); } const baseRideData = (id, organizerUid, clubId = undefined) => ({ id, organizerUid, title: 'Scenic Mountain Run', status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, accessUids: [organizerUid], visibility: 'friendsOnly', joinPolicy: 'inviteOnly', meetingPoint: { latitude: 34.6, longitude: -83.9, displayName: 'Dahlonega Square', locationType: 'meetup', }, ...(clubId !== undefined ? { clubId } : {}), }); // 1. LEGACY GROUPRIDES test('1. Legacy GroupRides: create and update without clubId or with explicit null', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); // 1a. Create legacy ride with missing clubId await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_legacy_missing'), baseRideData('ride_legacy_missing', 'alice'))); // 1b. Create legacy ride with explicit null clubId await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_legacy_null'), baseRideData('ride_legacy_null', 'alice', null))); // 1c. Update legacy ride (missing clubId) without introducing clubId await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_missing'), { title: 'Updated Legacy Title', updatedAt: '2026-09-10T10:00:00Z', })); // 1d. Update legacy ride (explicit null clubId) keeping clubId null await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_null'), { title: 'Updated Legacy Null Title', clubId: null, updatedAt: '2026-09-10T10:00:00Z', })); // 1e. Update legacy ride (missing clubId) with explicit null clubId succeeds (equivalent legacy state) await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_missing'), { title: 'Updated Legacy Missing to Null Title', clubId: null, updatedAt: '2026-09-10T10:05:00Z', })); // 1f. Update legacy ride (explicit null clubId) without mentioning clubId succeeds (equivalent legacy state) await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_null'), { title: 'Updated Legacy Null to Missing Title', updatedAt: '2026-09-10T10:05:00Z', })); }); // 2. CREATE AUTHORIZATION test('2. Club affiliation create authorization: owner/admin allowed; members, non-members, archived clubs denied', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); const frank = testEnv.authenticatedContext('frank').firestore(); // 2a. Active Club Owner (Alice) can create affiliated ride await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_club_alice'), baseRideData('ride_club_alice', 'alice', 'club_alpha'))); // 2b. Active Club Admin (Bob) can create affiliated ride await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_club_bob'), baseRideData('ride_club_bob', 'bob', 'club_alpha'))); // 2c. Active ordinary Member (Charlie) CANNOT create affiliated ride await assertFails(setDoc(doc(charlie, 'group_rides/ride_club_charlie'), baseRideData('ride_club_charlie', 'charlie', 'club_alpha'))); // 2d. Inactive/non-active memberships CANNOT create affiliated ride for (const status of ['invited', 'requested', 'declined', 'left', 'removed']) { const eve = testEnv.authenticatedContext(`eve_${status}`).firestore(); await assertFails(setDoc(doc(eve, `group_rides/ride_club_eve_${status}`), baseRideData(`group_rides/ride_club_eve_${status}`, `eve_${status}`, 'club_alpha'))); } // 2e. Non-member (Dave) CANNOT create affiliated ride await assertFails(setDoc(doc(dave, 'group_rides/ride_club_dave'), baseRideData('ride_club_dave', 'dave', 'club_alpha'))); // 2f. Admin of Club B (Frank) CANNOT create ride affiliated with Club A await assertFails(setDoc(doc(frank, 'group_rides/ride_club_frank_wrong'), baseRideData('ride_club_frank_wrong', 'frank', 'club_alpha'))); // 2g. Cannot create ride for non-existent club await assertFails(setDoc(doc(alice, 'group_rides/ride_club_missing'), baseRideData('ride_club_missing', 'alice', 'club_nonexistent'))); // 2h. Cannot create ride for archived club await assertFails(setDoc(doc(alice, 'group_rides/ride_club_archived'), baseRideData('ride_club_archived', 'alice', 'club_archived'))); // 2i. Blank, whitespace, or malformed clubId is denied await assertFails(setDoc(doc(alice, 'group_rides/ride_club_empty'), baseRideData('ride_club_empty', 'alice', ''))); await assertFails(setDoc(doc(alice, 'group_rides/ride_club_whitespace'), baseRideData('ride_club_whitespace', 'alice', ' '))); await assertFails(setDoc(doc(alice, 'group_rides/ride_club_spaces'), baseRideData('ride_club_spaces', 'alice', 'club alpha with space'))); }); // 3. AFFILIATION IMMUTABILITY test('3. Club affiliation immutability: cannot add, clear, or change clubId on existing rides', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'group_rides/ride_immut_personal'), baseRideData('ride_immut_personal', 'alice')); await setDoc(doc(db, 'group_rides/ride_immut_club'), baseRideData('ride_immut_club', 'alice', 'club_alpha')); }); // 3a. Personal ride cannot later gain clubId await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_personal'), { clubId: 'club_alpha', })); // 3b. Affiliated ride cannot clear clubId (set to null) await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), { clubId: null, })); // 3c. Affiliated ride cannot change Club A -> Club B await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), { clubId: 'club_beta', })); // 3d. Unrelated update cannot smuggle clubId change await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), { title: 'Attempted Smuggle', clubId: 'club_beta', })); // 3e. Legitimate update preserving same clubId succeeds await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_immut_club'), { title: 'Legitimate Title Update', clubId: 'club_alpha', })); }); // 4. ADMIN MANAGEMENT AUTHORITY & LIFECYCLE test('4. Club Admin management authority: allowed planned metadata when scheduled; dedicated cancellation; underway/terminal restrictions', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const frank = testEnv.authenticatedContext('frank').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); // Scheduled club ride organized by Alice await setDoc(doc(db, 'group_rides/ride_admin_scheduled'), baseRideData('ride_admin_scheduled', 'alice', 'club_alpha')); // Assembling club ride await setDoc(doc(db, 'group_rides/ride_admin_assembling'), { ...baseRideData('ride_admin_assembling', 'alice', 'club_alpha'), status: 'assembling', }); // Underway club ride await setDoc(doc(db, 'group_rides/ride_admin_underway'), { ...baseRideData('ride_admin_underway', 'alice', 'club_alpha'), status: 'underway', }); }); // 4a. Active Club Admin Bob can edit allowed scheduled metadata await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), { title: 'Updated By Club Admin', description: 'New route description', participantLimit: 20, meetingPoint: { latitude: 34.7, longitude: -83.8, displayName: 'New Meetup Point', locationType: 'meetup', }, updatedAt: '2026-09-10T10:00:00Z', })); // 4b. Ordinary Member Charlie CANNOT edit scheduled metadata await assertFails(updateDoc(doc(charlie, 'group_rides/ride_admin_scheduled'), { title: 'Hacked by Member', })); // 4c. Admin from another Club (Frank) CANNOT edit scheduled metadata await assertFails(updateDoc(doc(frank, 'group_rides/ride_admin_scheduled'), { title: 'Hacked by Foreign Admin', })); // 4d. Bob CANNOT mutate accessUids await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), { accessUids: ['alice', 'bob'], })); // 4e. Bob CANNOT mutate participantCount await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), { participantCount: 5, })); // 4f. Bob CANNOT change organizerUid await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), { organizerUid: 'bob', })); // 4g. Bob CANNOT perform arbitrary status transition to 'underway' await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), { status: 'underway', })); // 4h. Assembling ride rejects arbitrary admin metadata edit await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_assembling'), { title: 'Attempted Assembling Edit', })); // 4i. Scheduled ride: dedicated admin cancellation succeeds await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), { status: 'canceled', canceledAt: '2026-09-10T11:00:00Z', canceledReason: 'Rain predicted', updatedAt: '2026-09-10T11:00:00Z', })); // 4j. Assembling ride: dedicated admin cancellation succeeds await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_assembling'), { status: 'canceled', canceledAt: '2026-09-10T11:00:00Z', canceledReason: 'Club event canceled', updatedAt: '2026-09-10T11:00:00Z', })); // 4k. Underway ride: non-organizer Club admin cancellation FAILS await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_underway'), { status: 'canceled', canceledAt: '2026-09-10T11:00:00Z', canceledReason: 'Admin stopping ride', updatedAt: '2026-09-10T11:00:00Z', })); // 4l. Underway ride: Organizer Alice CAN cancel await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_admin_underway'), { status: 'canceled', canceledAt: '2026-09-10T11:00:00Z', canceledReason: 'Mechanical issue', updatedAt: '2026-09-10T11:00:00Z', })); // 4m. Underway ride: non-organizer Club admin metadata edit FAILS await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_underway'), { title: 'Admin attempting underway edit', })); }); // 5. TERMINAL-RIDE SECURITY HARDENING test('5. Terminal-ride security hardening: completed and canceled parent rides cannot be mutated', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); // Completed personal ride await setDoc(doc(db, 'group_rides/ride_term_personal_comp'), { ...baseRideData('ride_term_personal_comp', 'alice'), status: 'completed', }); // Canceled personal ride await setDoc(doc(db, 'group_rides/ride_term_personal_canc'), { ...baseRideData('ride_term_personal_canc', 'alice'), status: 'canceled', }); // Completed club ride await setDoc(doc(db, 'group_rides/ride_term_club_comp'), { ...baseRideData('ride_term_club_comp', 'alice', 'club_alpha'), status: 'completed', }); // Canceled club ride await setDoc(doc(db, 'group_rides/ride_term_club_canc'), { ...baseRideData('ride_term_club_canc', 'alice', 'club_alpha'), status: 'canceled', }); }); // 5a. Completed personal ride cannot be mutated by organizer (even with same status) await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_comp'), { title: 'New Title Post Completion', })); await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_comp'), { status: 'completed', title: 'Same-status mutate attempt', })); // 5b. Canceled personal ride cannot be mutated by organizer await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_canc'), { title: 'New Title Post Cancellation', })); await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_canc'), { status: 'canceled', title: 'Same-status mutate attempt', })); // 5c. Completed club ride cannot be mutated by organizer or club admin await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_club_comp'), { title: 'Organizer Mutate Attempt', })); await assertFails(updateDoc(doc(bob, 'group_rides/ride_term_club_comp'), { title: 'Admin Mutate Attempt', })); // 5d. Canceled club ride cannot be mutated by organizer or club admin await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_club_canc'), { title: 'Organizer Mutate Attempt', })); await assertFails(updateDoc(doc(bob, 'group_rides/ride_term_club_canc'), { title: 'Admin Mutate Attempt', })); }); // 6. LIVE-LOCATION PRIVACY (CLUBMEMBER != GROUPRIDEPARTICIPANT) test('6. Live-location privacy: Club membership or admin role NEVER grants live_presence access', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const validAlicePresence = { userId: 'alice', groupRideId: 'ride_privacy_live', latitude: 34.5378, longitude: -83.9849, heading: 90.0, speedMph: 45.0, accuracyMeters: 5.0, updatedAt: '2026-09-10T09:35:00Z', expiresAt: '2026-09-10T09:37:00Z', sharingEnabled: true, status: 'riding', }; await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); // Club-affiliated ride that is underway await setDoc(doc(db, 'group_rides/ride_privacy_live'), { ...baseRideData('ride_privacy_live', 'alice', 'club_alpha'), status: 'underway', visibility: 'public', }); // Alice is accepted participant await setDoc(doc(db, 'group_rides/ride_privacy_live/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', riderHandle: 'alice_rider', riderDisplayName: 'Alice', }); // Alice's live presence await setDoc(doc(db, 'group_rides/ride_privacy_live/live_presence/alice'), validAlicePresence); }); // 6a. Alice can read her own live presence await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_privacy_live/live_presence/alice'))); // 6b. Bob is Club Admin, but NOT an accepted participant -> CANNOT read Alice's live presence await assertFails(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice'))); // 6c. Charlie is Club Member, but NOT an accepted participant -> CANNOT read Alice's live presence await assertFails(getDoc(doc(charlie, 'group_rides/ride_privacy_live/live_presence/alice'))); // 6d. Bob cannot write live presence if not an accepted participant await assertFails(setDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/bob'), { ...validAlicePresence, userId: 'bob', })); // 6e. When Bob is added as accepted participant, Bob CAN read Alice's live presence await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'group_rides/ride_privacy_live/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob_rider', riderDisplayName: 'Bob', }); }); await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice'))); // 6f. If Alice blocks Bob, Bob CANNOT read Alice's live presence even though both are accepted participants and in the same Club await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' }); }); await assertFails(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice'))); }); // 7. READ & LIST PRIVACY test('7. Point-read authorization and list privacy: active club members can point-read; blocks deny point-read; broad club list query is denied', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); // Private club ride (not public, not friendsOnly) await setDoc(doc(db, 'group_rides/ride_discovery_private'), { ...baseRideData('ride_discovery_private', 'alice', 'club_alpha'), visibility: 'private', }); }); // 7a. Active Club Member Charlie can point-read private club ride await assertSucceeds(getDoc(doc(charlie, 'group_rides/ride_discovery_private'))); // 7b. Active Club Admin Bob can point-read private club ride await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_discovery_private'))); // 7c. Non-member Dave CANNOT read private club ride await assertFails(getDoc(doc(dave, 'group_rides/ride_discovery_private'))); // 7d-1. Block privacy: If Alice blocks Charlie, Charlie CANNOT read the ride await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'users/alice/blocks/charlie'), { blockedUid: 'charlie' }); }); await assertFails(getDoc(doc(charlie, 'group_rides/ride_discovery_private'))); // Clean up Alice's block await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await deleteDoc(doc(db, 'users/alice/blocks/charlie')); }); // 7d-2. Block privacy: If Charlie blocks Alice (reverse), Charlie CANNOT read the ride await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'users/charlie/blocks/alice'), { blockedUid: 'alice' }); }); await assertFails(getDoc(doc(charlie, 'group_rides/ride_discovery_private'))); // Clean up Charlie's block await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await deleteDoc(doc(db, 'users/charlie/blocks/alice')); }); // 7e. Unsafe broad Club-member list query is NOT authorized (rule branch removed) const charlieClubQuery = query( collection(charlie, 'group_rides'), where('clubId', '==', 'club_alpha'), ); await assertFails(getDocs(charlieClubQuery)); // 7f. Non-member Dave CANNOT list club rides const daveClubQuery = query( collection(dave, 'group_rides'), where('clubId', '==', 'club_alpha'), ); await assertFails(getDocs(daveClubQuery)); // 7g. Ordered broad query is also NOT authorized const charlieOrderedQuery = query( collection(charlie, 'group_rides'), where('clubId', '==', 'club_alpha'), orderBy('scheduledStartAt', 'desc'), limit(30), ); await assertFails(getDocs(charlieOrderedQuery)); }); // 8. CLUB ADMIN & OWNER GOVERNANCE LISTING & POINT-READING test('8. Club Admin and Owner governance listing and point-reading: active admins can point-read peer-admin rides and list their club rides; foreign admin, member, and non-member listing denied', async () => { await seedClubGroupRideEnv(); const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); const frank = testEnv.authenticatedContext('frank').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); // Alice (Club Owner) organizes a private club ride await setDoc(doc(db, 'group_rides/ride_gov_alice'), { ...baseRideData('ride_gov_alice', 'alice', 'club_alpha'), visibility: 'private', }); // Bob (Club Admin) organizes a private club ride await setDoc(doc(db, 'group_rides/ride_gov_bob'), { ...baseRideData('ride_gov_bob', 'bob', 'club_alpha'), visibility: 'private', }); // Frank (Club Beta Owner) organizes a private club ride for Club Beta await setDoc(doc(db, 'group_rides/ride_gov_frank'), { ...baseRideData('ride_gov_frank', 'frank', 'club_beta'), visibility: 'private', }); }); // 8a. Active Club Owner (Alice) can point-read peer-admin (Bob) Club GroupRide await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_gov_bob'))); // 8b. Active Club Admin (Bob) can point-read peer-admin (Alice) Club GroupRide await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_gov_alice'))); // 8c. Active Club Admin (Bob) can list where(clubId == 'club_alpha') const bobAlphaQuery = query( collection(bob, 'group_rides'), where('clubId', '==', 'club_alpha'), ); await assertSucceeds(getDocs(bobAlphaQuery)); // 8d. Active Club Owner (Alice) can list where(clubId == 'club_alpha') const aliceAlphaQuery = query( collection(alice, 'group_rides'), where('clubId', '==', 'club_alpha'), ); await assertSucceeds(getDocs(aliceAlphaQuery)); // 8e. Active Club Admin (Bob) can execute Founder Portal upcoming query const bobUpcomingQuery = query( collection(bob, 'group_rides'), where('clubId', '==', 'club_alpha'), where('status', 'in', ['scheduled', 'assembling', 'underway']), ); await assertSucceeds(getDocs(bobUpcomingQuery)); // 8f. Active Club Admin (Bob) can execute Founder Portal past query const bobPastQuery = query( collection(bob, 'group_rides'), where('clubId', '==', 'club_alpha'), where('status', 'in', ['completed', 'canceled']), ); await assertSucceeds(getDocs(bobPastQuery)); // 8g. Foreign Club Admin (Frank) CANNOT list club_alpha rides const frankAlphaQuery = query( collection(frank, 'group_rides'), where('clubId', '==', 'club_alpha'), ); await assertFails(getDocs(frankAlphaQuery)); // 8h. Bob CANNOT list club_beta rides (he is not admin of club_beta) const bobBetaQuery = query( collection(bob, 'group_rides'), where('clubId', '==', 'club_beta'), ); await assertFails(getDocs(bobBetaQuery)); // 8i. Ordinary active Club Member (Charlie) CANNOT list club_alpha rides const charlieAlphaQuery = query( collection(charlie, 'group_rides'), where('clubId', '==', 'club_alpha'), ); await assertFails(getDocs(charlieAlphaQuery)); // 8j. Non-member (Dave) CANNOT list club_alpha rides const daveAlphaQuery = query( collection(dave, 'group_rides'), where('clubId', '==', 'club_alpha'), ); await assertFails(getDocs(daveAlphaQuery)); }); // 9. CLUB GROUPRIDE JOIN WORKFLOW & PARTICIPANT MODERATION SECURITY RULES (SEC-J01 - SEC-J25) describe('9. Club GroupRide Join Workflow and Moderation Security Rules (SEC-J01 - SEC-J25)', () => { let alice, bob, charlie, dave, frank; async function seedJoinEnv() { await seedClubGroupRideEnv(); alice = testEnv.authenticatedContext('alice').firestore(); bob = testEnv.authenticatedContext('bob').firestore(); charlie = testEnv.authenticatedContext('charlie').firestore(); dave = testEnv.authenticatedContext('dave').firestore(); frank = testEnv.authenticatedContext('frank').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); // Target 1: Scheduled request-to-join official club ride await setDoc(doc(db, 'group_rides/ride_j_scheduled'), { ...baseRideData('ride_j_scheduled', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', participantLimit: 3, }); await setDoc(doc(db, 'group_rides/ride_j_scheduled/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); // Target 2: Underway request-to-join official club ride await setDoc(doc(db, 'group_rides/ride_j_underway'), { ...baseRideData('ride_j_underway', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', status: 'underway', }); await setDoc(doc(db, 'group_rides/ride_j_underway/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_underway/participants/requester_u'), { userId: 'requester_u', role: 'participant', status: 'requested', }); // Target 3: Full ride at capacity (limit 2, count 2) await setDoc(doc(db, 'group_rides/ride_j_full'), { ...baseRideData('ride_j_full', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', participantLimit: 2, participantCount: 2, accessUids: ['alice', 'bob'], }); await setDoc(doc(db, 'group_rides/ride_j_full/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_full/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', }); // Target 5: Club admin moderation test ride await setDoc(doc(db, 'group_rides/ride_j_bob_mod'), { ...baseRideData('ride_j_bob_mod', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', participantLimit: 5, }); await setDoc(doc(db, 'group_rides/ride_j_bob_mod/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_bob_mod/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); // Target 6: Decline test rides await setDoc(doc(db, 'group_rides/ride_j_dec_org'), { ...baseRideData('ride_j_dec_org', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', }); await setDoc(doc(db, 'group_rides/ride_j_dec_org/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); await setDoc(doc(db, 'group_rides/ride_j_dec_admin'), { ...baseRideData('ride_j_dec_admin', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', }); await setDoc(doc(db, 'group_rides/ride_j_dec_admin/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); // Target 7: Self cancel pending request await setDoc(doc(db, 'group_rides/ride_j_cancel'), { ...baseRideData('ride_j_cancel', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', }); await setDoc(doc(db, 'group_rides/ride_j_cancel/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); // Target 8: Self leave accepted ride await setDoc(doc(db, 'group_rides/ride_j_leave'), { ...baseRideData('ride_j_leave', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', participantCount: 2, accessUids: ['alice', 'charlie'], }); await setDoc(doc(db, 'group_rides/ride_j_leave/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_leave/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', }); // Target 9: Removal test rides await setDoc(doc(db, 'group_rides/ride_j_rem_org'), { ...baseRideData('ride_j_rem_org', 'alice', 'club_alpha'), visibility: 'public', participantCount: 2, accessUids: ['alice', 'charlie'], }); await setDoc(doc(db, 'group_rides/ride_j_rem_org/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_rem_org/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_rem_admin'), { ...baseRideData('ride_j_rem_admin', 'alice', 'club_alpha'), visibility: 'public', participantCount: 2, accessUids: ['alice', 'charlie'], }); await setDoc(doc(db, 'group_rides/ride_j_rem_admin/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_rem_admin/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', }); // Target 10: Open club ride await setDoc(doc(db, 'group_rides/ride_j_open'), { ...baseRideData('ride_j_open', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'open', participantLimit: 10, }); await setDoc(doc(db, 'group_rides/ride_j_open/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); // Target 11: Ineligible member (eve_removed) requesting await setDoc(doc(db, 'group_rides/ride_j_eve'), { ...baseRideData('ride_j_eve', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', }); await setDoc(doc(db, 'group_rides/ride_j_eve/participants/eve_removed'), { userId: 'eve_removed', role: 'participant', status: 'requested', }); // Target 12: Point-read roster testing await setDoc(doc(db, 'group_rides/ride_j_roster'), { ...baseRideData('ride_j_roster', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', accessUids: ['alice', 'bob'], }); await setDoc(doc(db, 'group_rides/ride_j_roster/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_roster/participants/bob'), { userId: 'bob', role: 'participant', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_j_roster/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); }); } beforeEach(async () => { await seedJoinEnv(); }); test('SEC-J01: Non-member Dave CANNOT request to join club-affiliated ride', async () => { await assertFails(setDoc(doc(dave, 'group_rides/ride_j_scheduled/participants/dave'), { userId: 'dave', role: 'participant', status: 'requested', })); }); test('SEC-J02: Active Club Member Charlie CAN requestToJoin official club ride (single-doc create, no parent mutation)', async () => { await assertSucceeds(setDoc(doc(charlie, 'group_rides/ride_j_scheduled/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', })); await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled'), { accessUids: ['alice', 'charlie'], updatedAt: '2026-10-01T08:00:00Z', })); }); test('SEC-J03: Requester Charlie CANNOT self-promote to accepted or inject self into accessUids', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_scheduled/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); }); await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled/participants/charlie'), { status: 'accepted', })); await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:01:00Z', })); }); test('SEC-J04: Requester CANNOT request when ride status is underway', async () => { await assertFails(setDoc(doc(charlie, 'group_rides/ride_j_underway/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', })); }); test('SEC-J05: Requester CAN create request when ride is currently at capacity (capacity enforced at approval)', async () => { await assertSucceeds(setDoc(doc(charlie, 'group_rides/ride_j_full/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', })); }); test('SEC-J06: Requester Charlie CANNOT modify parent ride keys', async () => { await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled'), { title: 'Hacked Title', updatedAt: '2026-10-01T08:00:00Z', })); }); test('SEC-J07: Blocked rider Frank CANNOT request to join Alice ride', async () => { await assertFails(setDoc(doc(frank, 'group_rides/ride_j_scheduled/participants/frank'), { userId: 'frank', role: 'participant', status: 'requested', })); }); test('SEC-J08: Approval cross-document atomicity (half-transactions fail, atomic batch succeeds)', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_scheduled/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); }); // Half-transaction (participant doc only) FAILS because parent accessUids/participantCount not updated await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_scheduled/participants/charlie'), { status: 'accepted', })); // Half-transaction (parent doc only) FAILS because participant doc status not updated await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_scheduled'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', })); // Full atomic batch SUCCEEDS const batchAliceApprove = writeBatch(alice); batchAliceApprove.update(doc(alice, 'group_rides/ride_j_scheduled/participants/charlie'), { status: 'accepted', }); batchAliceApprove.update(doc(alice, 'group_rides/ride_j_scheduled'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertSucceeds(batchAliceApprove.commit()); }); test('SEC-J09: Active Club Admin Bob CAN approve pending request on official club ride via atomic batch', async () => { const batchBobApprove = writeBatch(bob); batchBobApprove.update(doc(bob, 'group_rides/ride_j_bob_mod/participants/charlie'), { status: 'accepted', }); batchBobApprove.update(doc(bob, 'group_rides/ride_j_bob_mod'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertSucceeds(batchBobApprove.commit()); }); test('SEC-J10: Non-admin club member Charlie CANNOT approve requests', async () => { await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_bob_mod/participants/charlie'), { status: 'accepted', })); }); test('SEC-J11: Approval denied if ride is underway', async () => { const batchUnderway = writeBatch(alice); batchUnderway.update(doc(alice, 'group_rides/ride_j_underway/participants/requester_u'), { status: 'accepted', }); batchUnderway.update(doc(alice, 'group_rides/ride_j_underway'), { accessUids: ['alice', 'requester_u'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batchUnderway.commit()); }); test('SEC-J12: Approval denied if ride would exceed capacity', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_full/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', }); }); const batchFull = writeBatch(alice); batchFull.update(doc(alice, 'group_rides/ride_j_full/participants/charlie'), { status: 'accepted', }); batchFull.update(doc(alice, 'group_rides/ride_j_full'), { accessUids: ['alice', 'bob', 'charlie'], participantCount: 3, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batchFull.commit()); }); test('SEC-J13: Approval denied if requester is no longer an active member (eve_removed)', async () => { const batchEve = writeBatch(alice); batchEve.update(doc(alice, 'group_rides/ride_j_eve/participants/eve_removed'), { status: 'accepted', }); batchEve.update(doc(alice, 'group_rides/ride_j_eve'), { accessUids: ['alice', 'eve_removed'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batchEve.commit()); }); test('SEC-J14: Approval denied if requester is mutually blocked with organizer (frank)', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_scheduled/participants/frank'), { userId: 'frank', role: 'participant', status: 'requested', }); }); const batchFrank = writeBatch(alice); batchFrank.update(doc(alice, 'group_rides/ride_j_scheduled/participants/frank'), { status: 'accepted', }); batchFrank.update(doc(alice, 'group_rides/ride_j_scheduled'), { accessUids: ['alice', 'charlie', 'frank'], participantCount: 3, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batchFrank.commit()); }); test('SEC-J15: Organizer Alice CAN decline pending request (single doc on participant, zero parent writes)', async () => { await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_j_dec_org/participants/charlie'), { status: 'declined', })); }); test('SEC-J16: Active Club Admin Bob CAN decline pending request on official club ride (single doc, zero parent writes)', async () => { await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_j_dec_admin/participants/charlie'), { status: 'declined', })); }); test('SEC-J17: Requester Charlie CAN cancel own pending request (requested -> left) (single doc, zero parent writes)', async () => { await assertSucceeds(updateDoc(doc(charlie, 'group_rides/ride_j_cancel/participants/charlie'), { status: 'left', })); }); test('SEC-J18: Non-organizer participant Charlie leaving accepted ride cross-document atomicity', async () => { // Half-transaction (participant doc only) FAILS because parent accessUids/participantCount not updated await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_leave/participants/charlie'), { status: 'left', })); // Half-transaction (parent doc only) FAILS because participant status not updated await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_leave'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-10-01T08:05:00Z', })); // Full atomic batch SUCCEEDS const batchLeave = writeBatch(charlie); batchLeave.update(doc(charlie, 'group_rides/ride_j_leave/participants/charlie'), { status: 'left', }); batchLeave.update(doc(charlie, 'group_rides/ride_j_leave'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-10-01T08:05:00Z', }); await assertSucceeds(batchLeave.commit()); }); test('SEC-J19: Organizer Alice CAN remove accepted participant via atomic batch', async () => { // Half-transaction (participant doc only) FAILS await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_rem_org/participants/charlie'), { status: 'removed', })); // Full batch SUCCEEDS const batchRemOrg = writeBatch(alice); batchRemOrg.update(doc(alice, 'group_rides/ride_j_rem_org/participants/charlie'), { status: 'removed', }); batchRemOrg.update(doc(alice, 'group_rides/ride_j_rem_org'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-10-01T08:06:00Z', }); await assertSucceeds(batchRemOrg.commit()); }); test('SEC-J20: Active Club Admin Bob CAN remove accepted participant from official club ride via atomic batch', async () => { const batchRemAdmin = writeBatch(bob); batchRemAdmin.update(doc(bob, 'group_rides/ride_j_rem_admin/participants/charlie'), { status: 'removed', }); batchRemAdmin.update(doc(bob, 'group_rides/ride_j_rem_admin'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-10-01T08:06:00Z', }); await assertSucceeds(batchRemAdmin.commit()); }); test('SEC-J21: Moderator CANNOT remove the ride organizer', async () => { await assertFails(updateDoc(doc(bob, 'group_rides/ride_j_rem_admin/participants/alice'), { status: 'removed', })); await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_rem_org/participants/alice'), { status: 'removed', })); }); test('SEC-J22: Open club ride join enforces active club membership and cross-document binding', async () => { const batchDave = writeBatch(dave); batchDave.set(doc(dave, 'group_rides/ride_j_open/participants/dave'), { userId: 'dave', role: 'participant', status: 'accepted', }); batchDave.update(doc(dave, 'group_rides/ride_j_open'), { accessUids: ['alice', 'dave'], participantCount: 2, updatedAt: '2026-10-01T08:00:00Z', }); await assertFails(batchDave.commit()); const batchOpen = writeBatch(charlie); batchOpen.set(doc(charlie, 'group_rides/ride_j_open/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', }); batchOpen.update(doc(charlie, 'group_rides/ride_j_open'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:00:00Z', }); await assertSucceeds(batchOpen.commit()); }); test('SEC-J23: Club Admin Bob CAN point-read pending participant documents', async () => { await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_j_roster/participants/charlie'))); }); test('SEC-J24: Ordinary accepted participant Charlie CANNOT point-read pending requester docs of others', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_roster/participants/req_other'), { userId: 'req_other', role: 'participant', status: 'requested', }); }); await assertFails(getDoc(doc(charlie, 'group_rides/ride_j_roster/participants/req_other'))); }); test('SEC-J25: Fail-closed status: Ride with missing or invalid status fails mutations', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_no_status'), { id: 'ride_j_no_status', organizerUid: 'alice', clubId: 'club_alpha', accessUids: ['alice'], participantCount: 1, joinPolicy: 'requestToJoin', }); }); await assertFails(setDoc(doc(charlie, 'group_rides/ride_j_no_status/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', })); }); }); // 10. PARTICIPANT SCHEMA HARDENING & ADVERSARIAL ATTACKS describe('10. Participant Schema Hardening & Adversarial Attacks', () => { let alice, bob, charlie, dave, frank; beforeEach(async () => { await seedClubGroupRideEnv(); alice = testEnv.authenticatedContext('alice').firestore(); bob = testEnv.authenticatedContext('bob').firestore(); charlie = testEnv.authenticatedContext('charlie').firestore(); dave = testEnv.authenticatedContext('dave').firestore(); frank = testEnv.authenticatedContext('frank').firestore(); await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'group_rides/ride_schema_test'), { ...baseRideData('ride_schema_test', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', participantLimit: 5, participantCount: 1, accessUids: ['alice'], }); await setDoc(doc(db, 'group_rides/ride_schema_test/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_schema_test/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'requested', createdAt: '2026-10-01T08:00:00Z', updatedAt: '2026-10-01T08:00:00Z', }); // Open ride for schema testing await setDoc(doc(db, 'group_rides/ride_schema_open'), { ...baseRideData('ride_schema_open', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'open', participantLimit: 5, participantCount: 1, accessUids: ['alice'], }); await setDoc(doc(db, 'group_rides/ride_schema_open/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); // Accepted participant ride for leave / update testing await setDoc(doc(db, 'group_rides/ride_schema_accepted'), { ...baseRideData('ride_schema_accepted', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', participantLimit: 5, participantCount: 2, accessUids: ['alice', 'charlie'], }); await setDoc(doc(db, 'group_rides/ride_schema_accepted/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_schema_accepted/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', createdAt: '2026-10-01T08:00:00Z', updatedAt: '2026-10-01T08:00:00Z', }); // Invited participant ride await setDoc(doc(db, 'group_rides/ride_schema_inv'), { ...baseRideData('ride_schema_inv', 'alice', 'club_alpha'), visibility: 'public', joinPolicy: 'requestToJoin', participantLimit: 5, participantCount: 1, accessUids: ['alice', 'bob'], }); await setDoc(doc(db, 'group_rides/ride_schema_inv/participants/alice'), { userId: 'alice', role: 'organizer', status: 'accepted', }); await setDoc(doc(db, 'group_rides/ride_schema_inv/participants/bob'), { userId: 'bob', role: 'participant', status: 'invited', invitedBy: 'alice', createdAt: '2026-10-01T08:00:00Z', updatedAt: '2026-10-01T08:00:00Z', }); }); }); // SEC-F01 - SEC-F05: CREATE FIELD SMUGGLING ATTACKS test('SEC-F01: Request-to-join rejects unexpected fields (isAdmin: true)', async () => { await assertFails(setDoc(doc(bob, 'group_rides/ride_schema_test/participants/bob'), { userId: 'bob', role: 'participant', status: 'requested', isAdmin: true, })); }); test('SEC-F02: Request-to-join rejects unexpected fields (location / telemetry)', async () => { await assertFails(setDoc(doc(bob, 'group_rides/ride_schema_test/participants/bob'), { userId: 'bob', role: 'participant', status: 'requested', location: { latitude: 37.77, longitude: -122.41 }, })); }); test('SEC-F03: Open join rejects arbitrary extra fields', async () => { const batch = writeBatch(charlie); batch.set(doc(charlie, 'group_rides/ride_schema_open/participants/charlie'), { userId: 'charlie', role: 'participant', status: 'accepted', extraField: 'smuggled', }); batch.update(doc(charlie, 'group_rides/ride_schema_open'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:00:00Z', }); await assertFails(batch.commit()); }); test('SEC-F04: Organizer invitation rejects arbitrary extra fields', async () => { await assertFails(setDoc(doc(alice, 'group_rides/ride_schema_test/participants/dave'), { userId: 'dave', role: 'participant', status: 'invited', invitedBy: 'alice', clubRole: 'admin', })); }); test('SEC-F05: Legitimate production-shaped create succeeds with all allowed fields', async () => { await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_schema_test/participants/bob'), { userId: 'bob', role: 'participant', status: 'requested', riderHandle: 'bob_rider', riderDisplayName: 'Bob Rider', riderAvatarPreset: 'helmet_sport', riderAvatarUrl: 'https://example.com/avatar.png', createdAt: '2026-10-01T08:00:00Z', updatedAt: '2026-10-01T08:00:00Z', })); }); // SEC-U01 - SEC-U09: UPDATE FIELD SMUGGLING ATTACKS test('SEC-U01: Moderator approval rejects userId mutation', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', userId: 'attacker', }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); test('SEC-U02: Moderator approval rejects role mutation', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', role: 'organizer', }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); test('SEC-U03: Moderator approval rejects createdAt mutation', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', createdAt: '2020-01-01T00:00:00Z', }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); test('SEC-U04: Moderator approval rejects arbitrary extra fields', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', hacked: true, }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'charlie'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); test('SEC-U05: Moderator decline rejects arbitrary extra fields', async () => { await assertFails(updateDoc(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'declined', hacked: true, })); }); test('SEC-U06: Requested withdrawal rejects role mutation or extra fields', async () => { await assertFails(updateDoc(doc(charlie, 'group_rides/ride_schema_test/participants/charlie'), { status: 'left', role: 'organizer', })); await assertFails(updateDoc(doc(charlie, 'group_rides/ride_schema_test/participants/charlie'), { status: 'left', hacked: true, })); }); test('SEC-U07: Accepted leave rejects userId mutation or extra fields', async () => { const batch = writeBatch(charlie); batch.update(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'), { status: 'left', userId: 'attacker', }); batch.update(doc(charlie, 'group_rides/ride_schema_accepted'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-10-01T08:05:00Z', }); await assertFails(batch.commit()); }); test('SEC-U08: Moderator removal rejects arbitrary extra fields', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_accepted/participants/charlie'), { status: 'removed', unexpectedField: 'bad', }); batch.update(doc(alice, 'group_rides/ride_schema_accepted'), { accessUids: ['alice'], participantCount: 1, updatedAt: '2026-10-01T08:06:00Z', }); await assertFails(batch.commit()); }); test('SEC-U09: Invitee accept rejects arbitrary extra fields', async () => { const batch = writeBatch(bob); batch.update(doc(bob, 'group_rides/ride_schema_inv/participants/bob'), { status: 'accepted', unexpectedField: 'bad', }); batch.update(doc(bob, 'group_rides/ride_schema_inv'), { accessUids: ['alice', 'bob'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); // SEC-M01 - SEC-M02: MISMATCHED-TARGET APPROVAL ATTACKS test('SEC-M01: Parent adds rider A (bob) while participant doc accepts rider B (charlie) FAILS', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'bob'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); test('SEC-M02: Parent adds A (bob) with count +1, but participant A (bob) remains unaccepted and B (charlie) accepted FAILS', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_schema_test/participants/bob'), { userId: 'bob', role: 'participant', status: 'requested', }); }); const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'bob'], participantCount: 2, updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); // SEC-C01 - SEC-C05: EXPLICIT COUNT ATTACK TESTS test('SEC-C01: Approval with count delta 0 (access added but count unchanged) FAILS', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'charlie'], participantCount: 1, // Delta is 0, countDelta == 1 required updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); test('SEC-C02: Approval with count delta +2 (access added but count +2) FAILS', async () => { const batch = writeBatch(alice); batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { status: 'accepted', }); batch.update(doc(alice, 'group_rides/ride_schema_test'), { accessUids: ['alice', 'charlie'], participantCount: 3, // Delta is +2, countDelta == 1 required updatedAt: '2026-10-01T08:02:00Z', }); await assertFails(batch.commit()); }); test('SEC-C03: Accepted leave with count delta 0 (access removed but count unchanged) FAILS', async () => { const batch = writeBatch(charlie); batch.update(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'), { status: 'left', }); batch.update(doc(charlie, 'group_rides/ride_schema_accepted'), { accessUids: ['alice'], participantCount: 2, // Delta is 0, countDelta == -1 required updatedAt: '2026-10-01T08:05:00Z', }); await assertFails(batch.commit()); }); test('SEC-C04: Accepted leave with count delta -2 (access removed and count -2) FAILS', async () => { const batch = writeBatch(charlie); batch.update(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'), { status: 'left', }); batch.update(doc(charlie, 'group_rides/ride_schema_accepted'), { accessUids: ['alice'], participantCount: 0, // Delta is -2, countDelta == -1 required updatedAt: '2026-10-01T08:05:00Z', }); await assertFails(batch.commit()); }); test('SEC-C05: Requested withdrawal attempting parent count decrement FAILS', async () => { const batch = writeBatch(charlie); batch.update(doc(charlie, 'group_rides/ride_schema_test/participants/charlie'), { status: 'left', }); batch.update(doc(charlie, 'group_rides/ride_schema_test'), { participantCount: 0, updatedAt: '2026-10-01T08:05:00Z', }); await assertFails(batch.commit()); }); // SEC-P01: CLUB ADMIN PRIVACY TEST test('SEC-P01: Active club admin Bob who is not an accepted participant CANNOT read or write live_presence', async () => { // Bob is admin of club_alpha, ride_schema_test is affiliated with club_alpha, but Bob is not accepted participant // 1. Bob cannot write live_presence await assertFails(setDoc(doc(bob, 'group_rides/ride_schema_test/live_presence/bob'), { userId: 'bob', groupRideId: 'ride_schema_test', latitude: 37.77, longitude: -122.41, sharingEnabled: true, status: 'moving', updatedAt: '2026-10-01T08:00:00Z', expiresAt: '2026-10-01T08:15:00Z', })); // 2. Bob cannot read peer live_presence of Alice (who is accepted) await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'group_rides/ride_schema_test/live_presence/alice'), { userId: 'alice', groupRideId: 'ride_schema_test', latitude: 37.77, longitude: -122.41, sharingEnabled: true, status: 'moving', updatedAt: '2026-10-01T08:00:00Z', expiresAt: '2026-10-01T08:15:00Z', }); }); await assertFails(getDoc(doc(bob, 'group_rides/ride_schema_test/live_presence/alice'))); }); // SEC-DEL: PARTICIPANT HARD-DELETE INVARIANT TESTS test('SEC-DEL01: Hard-delete of accepted participant is BLOCKED for self and moderator', async () => { // Alice is organizer/moderator, Charlie is accepted participant in ride_schema_accepted await assertFails(deleteDoc(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'))); await assertFails(deleteDoc(doc(alice, 'group_rides/ride_schema_accepted/participants/charlie'))); }); test('SEC-DEL02: Hard-delete of organizer is BLOCKED for self and all callers', async () => { // Alice is organizer in ride_schema_accepted await assertFails(deleteDoc(doc(alice, 'group_rides/ride_schema_accepted/participants/alice'))); await assertFails(deleteDoc(doc(charlie, 'group_rides/ride_schema_accepted/participants/alice'))); }); test('SEC-DEL03: Hard-delete of non-accepted participant (invited/requested/declined/left/removed) is PERMITTED', async () => { // Seed non-accepted participant docs await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'group_rides/ride_schema_test/participants/del_left'), { userId: 'del_left', role: 'participant', status: 'left', }); await setDoc(doc(db, 'group_rides/ride_schema_test/participants/del_declined'), { userId: 'del_declined', role: 'participant', status: 'declined', }); }); const delLeftUser = testEnv.authenticatedContext('del_left').firestore(); const delDeclinedUser = testEnv.authenticatedContext('del_declined').firestore(); // Self-delete of left and declined records succeeds await assertSucceeds(deleteDoc(doc(delLeftUser, 'group_rides/ride_schema_test/participants/del_left'))); await assertSucceeds(deleteDoc(doc(delDeclinedUser, 'group_rides/ride_schema_test/participants/del_declined'))); }); // SEC-DEC: ATOMIC INVITATION DECLINE BATCH TESTS test('SEC-DEC01: Invitee declining invitation with atomic accessUids removal SUCCEEDS', async () => { // Setup ride where Dave is invited and in accessUids await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'group_rides/ride_decline_test'), { id: 'ride_decline_test', organizerUid: 'alice', title: 'Decline Test Ride', status: 'scheduled', scheduledStartAt: '2026-10-01T08:00:00Z', departureAt: '2026-10-01T08:30:00Z', participantCount: 1, accessUids: ['alice', 'dave'], visibility: 'friendsOnly', joinPolicy: 'inviteOnly', meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' }, }); await setDoc(doc(db, 'group_rides/ride_decline_test/participants/dave'), { userId: 'dave', role: 'participant', status: 'invited', invitedBy: 'alice', createdAt: '2026-10-01T07:30:00Z', updatedAt: '2026-10-01T07:30:00Z', }); }); const dave = testEnv.authenticatedContext('dave').firestore(); const declineBatch = writeBatch(dave); declineBatch.update(doc(dave, 'group_rides/ride_decline_test/participants/dave'), { status: 'declined', }); declineBatch.update(doc(dave, 'group_rides/ride_decline_test'), { accessUids: ['alice'], updatedAt: '2026-10-01T07:45:00Z', }); await assertSucceeds(declineBatch.commit()); }); test('SEC-DEC02: Decline batch attempting count decrement or extra access mutations FAILS', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); await setDoc(doc(db, 'group_rides/ride_decline_bad'), { id: 'ride_decline_bad', organizerUid: 'alice', title: 'Decline Bad Test Ride', status: 'scheduled', scheduledStartAt: '2026-10-01T08:00:00Z', departureAt: '2026-10-01T08:30:00Z', participantCount: 2, accessUids: ['alice', 'dave'], visibility: 'friendsOnly', joinPolicy: 'inviteOnly', meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' }, }); await setDoc(doc(db, 'group_rides/ride_decline_bad/participants/dave'), { userId: 'dave', role: 'participant', status: 'invited', invitedBy: 'alice', createdAt: '2026-10-01T07:30:00Z', updatedAt: '2026-10-01T07:30:00Z', }); }); const dave = testEnv.authenticatedContext('dave').firestore(); // 1. Trying to decrement count on decline FAILS const badCountBatch = writeBatch(dave); badCountBatch.update(doc(dave, 'group_rides/ride_decline_bad/participants/dave'), { status: 'declined', }); badCountBatch.update(doc(dave, 'group_rides/ride_decline_bad'), { accessUids: ['alice'], participantCount: 1, // Illegal count change on decline updatedAt: '2026-10-01T07:45:00Z', }); await assertFails(badCountBatch.commit()); // 2. Trying to remove someone else's accessUid on decline FAILS const badAccessBatch = writeBatch(dave); badAccessBatch.update(doc(dave, 'group_rides/ride_decline_bad/participants/dave'), { status: 'declined', }); badAccessBatch.update(doc(dave, 'group_rides/ride_decline_bad'), { accessUids: [], // Removed alice too! updatedAt: '2026-10-01T07:45:00Z', }); await assertFails(badAccessBatch.commit()); }); // SEC-BLK: MODERATOR DECLINING BLOCKED REQUEST TESTS test('SEC-BLK01: Organizer can decline join request from mutually-blocked user', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { const db = ctx.firestore(); // Mutually blocked users await setDoc(doc(db, 'users/alice/blocks/blocked_requester'), { blockedUid: 'blocked_requester' }); await setDoc(doc(db, 'users/blocked_requester/blocks/alice'), { blockedUid: 'alice' }); await setDoc(doc(db, 'group_rides/ride_block_test'), { id: 'ride_block_test', organizerUid: 'alice', title: 'Block Test Ride', status: 'scheduled', scheduledStartAt: '2026-10-01T08:00:00Z', departureAt: '2026-10-01T08:30:00Z', participantCount: 1, accessUids: ['alice'], visibility: 'public', joinPolicy: 'requestToJoin', meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' }, }); await setDoc(doc(db, 'group_rides/ride_block_test/participants/blocked_requester'), { userId: 'blocked_requester', role: 'participant', status: 'requested', riderHandle: 'blocked_rider', riderDisplayName: 'Blocked Rider', createdAt: '2026-10-01T07:00:00Z', updatedAt: '2026-10-01T07:00:00Z', }); }); // 1. Approval by Alice FAILS due to mutual block const approveBatch = writeBatch(alice); approveBatch.update(doc(alice, 'group_rides/ride_block_test/participants/blocked_requester'), { status: 'accepted', }); approveBatch.update(doc(alice, 'group_rides/ride_block_test'), { accessUids: ['alice', 'blocked_requester'], participantCount: 2, updatedAt: '2026-10-01T07:15:00Z', }); await assertFails(approveBatch.commit()); // 2. Decline by Alice SUCCEEDS despite mutual block await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_block_test/participants/blocked_requester'), { status: 'declined', })); }); }); }); }); describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => { const baseAlert = { id: 'alert_hazard_101', category: 'hazard', latitude: 37.7749, longitude: -122.4194, heading: 90.0, roadName: 'US-101 S', description: 'Debris on road', reporterUid: 'alice', reportedAt: 1000000, expiresAt: 1003600, confirmations: 1, dismissals: 0, }; test('valid road alert creation and reading by authenticated riders', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const signedOut = testEnv.unauthenticatedContext().firestore(); const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); const alertRefSignedOut = doc(signedOut, 'road_alerts/alert_hazard_101'); // Alice creates valid road alert await assertSucceeds(setDoc(alertRefAlice, baseAlert)); // Signed-in user Bob can read alert await assertSucceeds(getDoc(alertRefBob)); // Unauthenticated user cannot read alert await assertFails(getDoc(alertRefSignedOut)); }); test('road alert creation rejects unauthorized or invalid fields', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const signedOut = testEnv.unauthenticatedContext().firestore(); // Unauthenticated create fails await assertFails(setDoc(doc(signedOut, 'road_alerts/alert_unauth'), { ...baseAlert, id: 'alert_unauth', })); // Reporter UID mismatch (spoofing another rider) await assertFails(setDoc(doc(alice, 'road_alerts/alert_spoof'), { ...baseAlert, id: 'alert_spoof', reporterUid: 'bob', })); // Document ID mismatch await assertFails(setDoc(doc(alice, 'road_alerts/alert_wrong_id'), { ...baseAlert, id: 'alert_other_id', })); // Invalid category await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_cat'), { ...baseAlert, id: 'alert_bad_cat', category: 'invalid_category', })); // Non-initial confirmations != 1 await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_conf'), { ...baseAlert, id: 'alert_bad_conf', confirmations: 2, })); // Non-initial dismissals != 0 await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_dism'), { ...baseAlert, id: 'alert_bad_dism', dismissals: 1, })); // Invalid coordinates (out of range) await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_lat'), { ...baseAlert, id: 'alert_bad_lat', latitude: 95.0, })); // expiresAt not after reportedAt await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_exp'), { ...baseAlert, id: 'alert_bad_exp', expiresAt: 1000000, })); // Unknown/unauthorized keys await assertFails(setDoc(doc(alice, 'road_alerts/alert_extra_key'), { ...baseAlert, id: 'alert_extra_key', maliciousField: true, })); // Description exceeding 256 chars await assertFails(setDoc(doc(alice, 'road_alerts/alert_long_desc'), { ...baseAlert, id: 'alert_long_desc', description: 'x'.repeat(257), })); }); test('first valid confirmation by Bob on Alice report succeeds and can be read by signed-in users', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const signedOut = testEnv.unauthenticatedContext().firestore(); const alertId = 'alert_hazard_confirm_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, status: 'confirmed', }); tx.set(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); // Signed-in riders can read the vote subcollection await assertSucceeds(getDoc(voteRefBob)); await assertSucceeds(getDoc(doc(charlie, `road_alerts/${alertId}/votes/bob`))); // Signed-out user cannot read votes await assertFails(getDoc(doc(signedOut, `road_alerts/${alertId}/votes/bob`))); }); test('first valid dismissal by Bob succeeds', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_dismiss_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { dismissals: snap.data().dismissals + 1, status: 'active', }); tx.set(voteRefBob, { voterUid: 'bob', type: 'dismiss', votedAt: Date.now(), }); })); }); test('duplicate confirmation (Bob tries to overwrite or update road_alerts/alertId/votes/bob) fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_dup_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, status: 'confirmed', }); tx.set(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); // Trying to overwrite via setDoc fails (update blocked) await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), })); // Trying to update via updateDoc fails await assertFails(updateDoc(voteRefBob, { votedAt: Date.now(), })); // Second transaction also fails await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, status: 'confirmed', }); tx.set(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); }); test('confirmation followed by dismissal from same user (Bob tries to change vote) fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_change_vote_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, status: 'confirmed', }); tx.set(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); // Trying to change vote to dismiss via updateDoc fails await assertFails(updateDoc(voteRefBob, { type: 'dismiss', votedAt: Date.now(), })); // Trying to change vote to dismiss via setDoc fails await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'dismiss', votedAt: Date.now(), })); // Trying to retract/delete vote fails await assertFails(deleteDoc(voteRefBob)); // Trying to change vote via transaction fails await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { dismissals: snap.data().dismissals + 1, status: 'active', }); tx.set(voteRefBob, { voterUid: 'bob', type: 'dismiss', votedAt: Date.now(), }); })); }); test('voting on one\'s own report (Alice tries to vote on road_alerts/alert_by_alice/votes/alice) fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const alertId = 'alert_hazard_self_vote_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reporterUid: 'alice', reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefAlice = doc(alice, `road_alerts/${alertId}/votes/alice`); await assertFails(setDoc(voteRefAlice, { voterUid: 'alice', type: 'confirm', votedAt: Date.now(), })); }); test('forged voter UID (Bob authenticated tries to write to road_alerts/alertId/votes/charlie) fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_spoof_voter_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Bob authenticated writes to charlie's doc path with charlie voterUid await assertFails(setDoc(doc(bob, `road_alerts/${alertId}/votes/charlie`), { voterUid: 'charlie', type: 'confirm', votedAt: Date.now(), })); // Bob authenticated writes to charlie's doc path with bob voterUid await assertFails(setDoc(doc(bob, `road_alerts/${alertId}/votes/charlie`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), })); // Bob authenticated writes to bob's doc path with charlie voterUid await assertFails(setDoc(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'charlie', type: 'confirm', votedAt: Date.now(), })); }); test('direct confirmation count manipulation on road_alerts/alertId fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_direct_conf_1'; const alertRefAlice = doc(alice, `road_alerts/${alertId}`); const alertRefBob = doc(bob, `road_alerts/${alertId}`); await assertSucceeds(setDoc(alertRefAlice, { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Bob tries updateDoc(alertRefBob, { confirmations: 2 }) await assertFails(updateDoc(alertRefBob, { confirmations: 2, })); // Reporter Alice also cannot directly mutate confirmations await assertFails(updateDoc(alertRefAlice, { confirmations: 2, })); }); test('direct dismissal count manipulation on road_alerts/alertId fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_direct_dism_1'; const alertRefAlice = doc(alice, `road_alerts/${alertId}`); const alertRefBob = doc(bob, `road_alerts/${alertId}`); await assertSucceeds(setDoc(alertRefAlice, { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Bob tries updateDoc(alertRefBob, { dismissals: 1 }) await assertFails(updateDoc(alertRefBob, { dismissals: 1, })); // Reporter Alice also cannot directly mutate dismissals await assertFails(updateDoc(alertRefAlice, { dismissals: 1, })); }); test('direct expiration extension or status update on road_alerts/alertId by ordinary client fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_direct_exp_status_1'; const alertRefAlice = doc(alice, `road_alerts/${alertId}`); const alertRefBob = doc(bob, `road_alerts/${alertId}`); await assertSucceeds(setDoc(alertRefAlice, { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Bob tries extending expiresAt await assertFails(updateDoc(alertRefBob, { expiresAt: Date.now() + 9999999, })); // Bob tries setting status to cleared await assertFails(updateDoc(alertRefBob, { status: 'cleared', })); // Alice (reporter) also cannot update status or expiresAt await assertFails(updateDoc(alertRefAlice, { status: 'cleared', })); await assertFails(updateDoc(alertRefAlice, { expiresAt: Date.now() + 9999999, })); }); test('concurrent and distinct voters (Bob and Charlie) can both create their respective votes', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const alertId = 'alert_hazard_distinct_voters_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); const voteRefCharlie = doc(charlie, `road_alerts/${alertId}/votes/charlie`); await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, status: 'confirmed', }); tx.set(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); await assertSucceeds(runTransaction(charlie, async (tx) => { const alertRef = doc(charlie, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { dismissals: snap.data().dismissals + 1, status: 'confirmed', }); tx.set(voteRefCharlie, { voterUid: 'charlie', type: 'dismiss', votedAt: Date.now(), }); })); }); test('voting on an expired report fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_expired_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 5000, expiresAt: Date.now() - 1000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), })); }); test('voting on non-existent report fails', async () => { const bob = testEnv.authenticatedContext('bob').firestore(); const voteRefBob = doc(bob, 'road_alerts/alert_does_not_exist/votes/bob'); await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), })); }); test('voting on a cleared report fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_cleared_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'cleared', reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), })); }); test('voting with invalid payload (e.g. unknown keys, invalid type) fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_hazard_invalid_payload_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); // Unknown extra keys await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), injectedField: true, })); // Invalid type value await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'upvote', votedAt: Date.now(), })); // Invalid votedAt type (string instead of int/timestamp) await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: '2026-10-10', })); // Missing type await assertFails(setDoc(voteRefBob, { voterUid: 'bob', votedAt: Date.now(), })); }); test('signed-out voting fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const signedOut = testEnv.unauthenticatedContext().firestore(); const alertId = 'alert_hazard_unauth_vote_1'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefSignedOut = doc(signedOut, `road_alerts/${alertId}/votes/anon`); await assertFails(setDoc(voteRefSignedOut, { voterUid: 'anon', type: 'confirm', votedAt: Date.now(), })); }); test('delete authorization: only original reporter can delete', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const signedOut = testEnv.unauthenticatedContext().firestore(); const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101'); const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101'); const alertRefSignedOut = doc(signedOut, 'road_alerts/alert_hazard_101'); await assertSucceeds(setDoc(alertRefAlice, baseAlert)); // Bob cannot delete Alice's alert await assertFails(deleteDoc(alertRefBob)); // Signed-out user cannot delete await assertFails(deleteDoc(alertRefSignedOut)); // Alice (reporter) can delete her alert await assertSucceeds(deleteDoc(alertRefAlice)); }); test('creating road alerts with new categories (pothole, debris, disabledVehicle, weatherHazard)', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const newCategories = ['pothole', 'debris', 'disabledVehicle', 'weatherHazard']; for (const category of newCategories) { const alertId = `alert_${category}_test`; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, category, })); } }); test('creating road alerts with optional subtype, source, and status', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); // Valid optional fields with status 'active' await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_active'), { ...baseAlert, id: 'alert_opt_active', category: 'pothole', subtype: 'deep_crater', source: 'native_sensor', status: 'active', })); // Valid optional fields with status 'confirmed' await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_confirmed'), { ...baseAlert, id: 'alert_opt_confirmed', category: 'debris', subtype: 'tire_tread', source: 'rider_report', status: 'confirmed', })); // Valid optional fields with status 'cleared' await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_cleared'), { ...baseAlert, id: 'alert_opt_cleared', status: 'cleared', })); // Valid optional fields with status 'expired' await assertSucceeds(setDoc(doc(alice, 'road_alerts/alert_opt_expired'), { ...baseAlert, id: 'alert_opt_expired', status: 'expired', })); }); test('rejecting invalid status or too-long subtype', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); // Invalid status value await assertFails(setDoc(doc(alice, 'road_alerts/alert_bad_status'), { ...baseAlert, id: 'alert_bad_status', status: 'invalid_status_enum', })); // Subtype exceeding 64 characters await assertFails(setDoc(doc(alice, 'road_alerts/alert_long_subtype'), { ...baseAlert, id: 'alert_long_subtype', subtype: 'x'.repeat(65), })); // Source exceeding 64 characters await assertFails(setDoc(doc(alice, 'road_alerts/alert_long_source'), { ...baseAlert, id: 'alert_long_source', source: 's'.repeat(65), })); }); describe('Authoritative Cross-Device Vote Aggregation via Transactions', () => { test('votes from two devices produce consistent totals (using runTransaction)', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); const alertId = 'alert_cross_device_totals'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Bob (Device 1) runs transaction: confirms alert -> alert has confirmations: 2, status: 'confirmed' await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); const nextConf = snap.data().confirmations + 1; const nextStatus = nextConf >= 2 ? 'confirmed' : 'active'; tx.update(alertRef, { confirmations: nextConf, status: nextStatus, }); tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); const bobAlertSnap = await getDoc(doc(bob, `road_alerts/${alertId}`)); expect(bobAlertSnap.data().confirmations).toBe(2); expect(bobAlertSnap.data().status).toBe('confirmed'); // Charlie (Device 2) runs transaction: confirms alert -> alert has confirmations: 3, status: 'confirmed' await assertSucceeds(runTransaction(charlie, async (tx) => { const alertRef = doc(charlie, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); const nextConf = snap.data().confirmations + 1; const nextStatus = nextConf >= 2 ? 'confirmed' : 'active'; tx.update(alertRef, { confirmations: nextConf, status: nextStatus, }); tx.set(doc(charlie, `road_alerts/${alertId}/votes/charlie`), { voterUid: 'charlie', type: 'confirm', votedAt: Date.now(), }); })); // Dave (Device 3) reads alert -> sees confirmations: 3, status: 'confirmed' const daveAlertSnap = await getDoc(doc(dave, `road_alerts/${alertId}`)); expect(daveAlertSnap.data().confirmations).toBe(3); expect(daveAlertSnap.data().status).toBe('confirmed'); }); test('repeated voting cannot increase totals', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_no_repeated_votes'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Bob votes once via transaction -> succeeds await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); const nextConf = snap.data().confirmations + 1; tx.update(alertRef, { confirmations: nextConf, status: nextConf >= 2 ? 'confirmed' : 'active', }); tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); // Bob attempts a second transaction on the same alert -> runTransaction fails with PERMISSION_DENIED await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); const nextConf = snap.data().confirmations + 1; tx.update(alertRef, { confirmations: nextConf, status: 'confirmed', }); tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); // Alert remains at confirmations: 2 const snapAfter = await getDoc(doc(alice, `road_alerts/${alertId}`)); expect(snapAfter.data().confirmations).toBe(2); }); test('concurrent votes are counted correctly', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const alertId = 'alert_concurrent_tx_votes'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const runTx = async (client, uid) => { for (let attempt = 0; attempt < 5; attempt++) { try { await runTransaction(client, async (tx) => { const alertRef = doc(client, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); const nextConf = snap.data().confirmations + 1; tx.update(alertRef, { confirmations: nextConf, status: nextConf >= 2 ? 'confirmed' : 'active', }); tx.set(doc(client, `road_alerts/${alertId}/votes/${uid}`), { voterUid: uid, type: 'confirm', votedAt: Date.now(), }); }); return; } catch (e) { if (attempt === 4) throw e; await new Promise((resolve) => setTimeout(resolve, 50)); } } }; // Bob and Charlie submit concurrent transactions via Promise.all await assertSucceeds(Promise.all([ runTx(bob, 'bob'), runTx(charlie, 'charlie'), ])); // Both succeed and alert ends with confirmations: 3 const finalAlert = await getDoc(doc(alice, `road_alerts/${alertId}`)); expect(finalAlert.data().confirmations).toBe(3); expect(finalAlert.data().status).toBe('confirmed'); }); test('dismissal consensus clears an alert across devices', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const charlie = testEnv.authenticatedContext('charlie').firestore(); const dave = testEnv.authenticatedContext('dave').firestore(); const alertId = 'alert_dismissal_consensus'; const originalExpiresAt = Date.now() + 3600000; const reportedAt = Date.now() - 1000; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt, expiresAt: originalExpiresAt, })); // Bob submits dismissal transaction -> dismissals: 1, status: 'active' await assertSucceeds(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); const nextDismissals = snap.data().dismissals + 1; tx.update(alertRef, { dismissals: nextDismissals, status: 'active', }); tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'bob', type: 'dismiss', votedAt: Date.now(), }); })); const alertAfterBob = await getDoc(doc(bob, `road_alerts/${alertId}`)); expect(alertAfterBob.data().dismissals).toBe(1); expect(alertAfterBob.data().status).toBe('active'); // Charlie submits dismissal transaction -> dismissals: 2, status: 'cleared', expiresAt: reportedAt (or <= alert.expiresAt) const clearTime = Date.now(); await assertSucceeds(runTransaction(charlie, async (tx) => { const alertRef = doc(charlie, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); const nextDismissals = snap.data().dismissals + 1; tx.update(alertRef, { dismissals: nextDismissals, status: 'cleared', expiresAt: clearTime, }); tx.set(doc(charlie, `road_alerts/${alertId}/votes/charlie`), { voterUid: 'charlie', type: 'dismiss', votedAt: clearTime, }); })); // Dave reads alert -> sees status: 'cleared' and dismissals: 2 const daveAlert = await getDoc(doc(dave, `road_alerts/${alertId}`)); expect(daveAlert.data().dismissals).toBe(2); expect(daveAlert.data().status).toBe('cleared'); expect(daveAlert.data().expiresAt).toBe(clearTime); }); test('expired alerts cannot be revived by a modified client', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertIdExpired = 'alert_revive_expired'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertIdExpired}`), { ...baseAlert, id: alertIdExpired, status: 'active', reportedAt: Date.now() - 5000, expiresAt: Date.now() - 1000, })); // Modified client tries to update expiresAt on expired alert -> fails await assertFails(updateDoc(doc(bob, `road_alerts/${alertIdExpired}`), { expiresAt: Date.now() + 100000, })); // Modified client tries transaction vote on expired alert -> fails await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertIdExpired}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, status: 'confirmed', }); tx.set(doc(bob, `road_alerts/${alertIdExpired}/votes/bob`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); // Modified client tries to vote or update a cleared alert -> fails const alertIdCleared = 'alert_revive_cleared'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertIdCleared}`), { ...baseAlert, id: alertIdCleared, status: 'cleared', reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); await assertFails(updateDoc(doc(bob, `road_alerts/${alertIdCleared}`), { status: 'active', })); await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertIdCleared}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, status: 'confirmed', }); tx.set(doc(bob, `road_alerts/${alertIdCleared}/votes/bob`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); }); test('direct count manipulation without atomic vote creation remains rejected', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_direct_count_rejected'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const alertRefBob = doc(bob, `road_alerts/${alertId}`); // Direct confirmation count increment without vote doc creation fails await assertFails(updateDoc(alertRefBob, { confirmations: 2, })); // Direct dismissal count increment without vote doc creation fails await assertFails(updateDoc(alertRefBob, { dismissals: 1, })); }); test('standalone vote creation without a parent update fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_standalone_vote_fails'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); // Standalone confirmation vote without parent update fails await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), })); // Standalone dismissal vote without parent update fails await assertFails(setDoc(voteRefBob, { voterUid: 'bob', type: 'dismiss', votedAt: Date.now(), })); }); test('vote creation with unchanged totals fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_unchanged_totals_fails'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Transaction attempts to create vote while leaving parent counts unchanged await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations, dismissals: snap.data().dismissals, status: 'active', }); tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); }); test('confirmation vote paired with a dismissal increment fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_mismatched_conf_dism_fails'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Confirmation vote paired with dismissals + 1 await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { dismissals: snap.data().dismissals + 1, confirmations: snap.data().confirmations, status: 'active', }); tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'bob', type: 'confirm', votedAt: Date.now(), }); })); }); test('dismissal vote paired with a confirmation increment fails', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); const alertId = 'alert_mismatched_dism_conf_fails'; await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), { ...baseAlert, id: alertId, status: 'active', confirmations: 1, dismissals: 0, reportedAt: Date.now() - 1000, expiresAt: Date.now() + 3600000, })); // Dismissal vote paired with confirmations + 1 await assertFails(runTransaction(bob, async (tx) => { const alertRef = doc(bob, `road_alerts/${alertId}`); const snap = await tx.get(alertRef); tx.update(alertRef, { confirmations: snap.data().confirmations + 1, dismissals: snap.data().dismissals, status: 'confirmed', }); tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), { voterUid: 'bob', type: 'dismiss', votedAt: Date.now(), }); })); }); }); });