MotorSignal-App/firebase/emulator-tests/firestore.rules.test.js

3905 lines
146 KiB
JavaScript

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(),
}));
});
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',
}));
});
});
});
});