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