import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; import { assertFails, assertSucceeds, initializeTestEnvironment, } from '@firebase/rules-unit-testing'; import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where } 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(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', 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 await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv/participants/bob'), { status: 'accepted', })); // Bob can adjust participantCount on the ride during acceptance await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv'), { participantCount: 2, updatedAt: '2026-09-10T09:05:00Z', })); // 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 await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_request_only/participants/charlie'), { status: 'accepted', })); // 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' await assertSucceeds(setDoc(doc(dave, 'group_rides/ride_open/participants/dave'), { userId: 'dave', role: 'participant', status: 'accepted', })); // 7. Dave can update accessUids and increment participantCount on open ride await assertSucceeds(updateDoc(doc(dave, 'group_rides/ride_open'), { accessUids: ['alice', 'dave'], participantCount: 2, updatedAt: '2026-09-10T09:05:00Z', })); }); 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: Participant Removal (Organizer removes Rider B) await resetLiveState(); // Alice reads Bob initially -> succeeds await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Organizer removes Bob by deleting participant record await assertSucceeds(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'))); // Now Bob CANNOT write to live presence await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { ...validBobPresence, speedMph: 55.0, })); // Alice CANNOT read Bob's old presence await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Scenario B: Voluntary Leave (Bob deletes his participant record) await resetLiveState(); // Bob deletes his participant record (voluntary leave) await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'))); // Bob CANNOT write to live presence await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { ...validBobPresence, speedMph: 50.0, })); // Alice CANNOT read Bob's presence await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Scenario B2: Voluntary Leave via status transition (status: 'accepted' -> 'left', presence doc temporarily remains) await resetLiveState(); await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'), { status: 'left', })); // 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(); await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'), { status: 'removed', })); // 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(), })); }); }); });