1509 lines
56 KiB
JavaScript
1509 lines
56 KiB
JavaScript
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
|
import {
|
|
assertFails,
|
|
assertSucceeds,
|
|
initializeTestEnvironment,
|
|
} from '@firebase/rules-unit-testing';
|
|
import { collection, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc } from 'firebase/firestore';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const projectId = 'motorsignal-emulator';
|
|
let testEnv;
|
|
|
|
beforeAll(async () => {
|
|
testEnv = await initializeTestEnvironment({
|
|
projectId,
|
|
firestore: {
|
|
rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8'),
|
|
},
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await testEnv?.cleanup();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await testEnv?.clearFirestore();
|
|
});
|
|
|
|
const cleanupCollections = [
|
|
{
|
|
name: 'rides/{rideId}/events',
|
|
collectionPath: 'users/alice/rides/ride_cleanup/events',
|
|
documentPath: 'users/alice/rides/ride_cleanup/events/event_cleanup',
|
|
},
|
|
{
|
|
name: 'rides/{rideId}/performance',
|
|
collectionPath: 'users/alice/rides/ride_cleanup/performance',
|
|
documentPath: 'users/alice/rides/ride_cleanup/performance/performance_cleanup',
|
|
},
|
|
{
|
|
name: 'rides',
|
|
collectionPath: 'users/alice/rides',
|
|
documentPath: 'users/alice/rides/ride_cleanup',
|
|
},
|
|
{
|
|
name: 'motorcycles',
|
|
collectionPath: 'users/alice/motorcycles',
|
|
documentPath: 'users/alice/motorcycles/motorcycle_cleanup',
|
|
},
|
|
{
|
|
name: 'trips',
|
|
collectionPath: 'users/alice/trips',
|
|
documentPath: 'users/alice/trips/trip_cleanup',
|
|
},
|
|
{
|
|
name: 'events',
|
|
collectionPath: 'users/alice/events',
|
|
documentPath: 'users/alice/events/event_cleanup',
|
|
},
|
|
{
|
|
name: 'friends',
|
|
collectionPath: 'users/alice/friends',
|
|
documentPath: 'users/alice/friends/friend_cleanup',
|
|
},
|
|
];
|
|
|
|
async function seedCleanupTree() {
|
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
|
const db = context.firestore();
|
|
await setDoc(doc(db, 'users/alice'), { id: 'alice' });
|
|
await setDoc(doc(db, 'users/alice/rides/ride_cleanup'), {
|
|
id: 'ride_cleanup',
|
|
ownerId: 'alice',
|
|
});
|
|
await setDoc(doc(db, 'users/alice/rides/ride_cleanup/events/event_cleanup'), {
|
|
type: 'ride-event',
|
|
});
|
|
await setDoc(doc(db, 'users/alice/rides/ride_cleanup/performance/performance_cleanup'), {
|
|
type: 'performance-run',
|
|
});
|
|
await setDoc(doc(db, 'users/alice/motorcycles/motorcycle_cleanup'), {
|
|
ownerId: 'alice',
|
|
});
|
|
await setDoc(doc(db, 'users/alice/trips/trip_cleanup'), { type: 'trip' });
|
|
await setDoc(doc(db, 'users/alice/events/event_cleanup'), { type: 'event' });
|
|
await setDoc(doc(db, 'users/alice/friends/friend_cleanup'), { type: 'friend' });
|
|
});
|
|
}
|
|
|
|
describe('MotorCloud ride ownership rules', () => {
|
|
test('a signed-in owner can create and read their own ride', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const ride = doc(alice, 'users/alice/rides/ride_test_alice');
|
|
|
|
await assertSucceeds(setDoc(ride, {
|
|
id: 'ride_test_alice',
|
|
ownerId: 'alice',
|
|
startTime: '2026-09-01T00:00:00.000Z',
|
|
}));
|
|
await assertSucceeds(getDoc(ride));
|
|
});
|
|
|
|
test('User A cannot read or update User B ride data', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
const bobRideForSetup = doc(bob, 'users/bob/rides/ride_bob');
|
|
const bobRideForAlice = doc(alice, 'users/bob/rides/ride_bob');
|
|
|
|
await assertSucceeds(setDoc(bobRideForSetup, {
|
|
id: 'ride_bob',
|
|
ownerId: 'bob',
|
|
startTime: '2026-09-01T00:00:00.000Z',
|
|
}));
|
|
|
|
await assertFails(getDoc(bobRideForAlice));
|
|
await assertFails(updateDoc(bobRideForAlice, { distanceMiles: 999 }));
|
|
});
|
|
|
|
test('a caller cannot create a ride with another ownerId', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const forged = doc(alice, 'users/alice/rides/ride_forged');
|
|
|
|
await assertFails(setDoc(forged, {
|
|
id: 'ride_forged',
|
|
ownerId: 'bob',
|
|
startTime: '2026-09-01T00:00:00.000Z',
|
|
}));
|
|
});
|
|
|
|
test('a caller cannot change an existing ride ownerId', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const ride = doc(alice, 'users/alice/rides/ride_owner_lock');
|
|
|
|
await assertSucceeds(setDoc(ride, {
|
|
id: 'ride_owner_lock',
|
|
ownerId: 'alice',
|
|
startTime: '2026-09-01T00:00:00.000Z',
|
|
}));
|
|
await assertFails(updateDoc(ride, { ownerId: 'bob' }));
|
|
});
|
|
|
|
test('signed-out callers cannot access user-owned rides', async () => {
|
|
const unauthenticated = testEnv.unauthenticatedContext().firestore();
|
|
const ride = doc(unauthenticated, 'users/alice/rides/ride_test_alice');
|
|
|
|
await assertFails(getDoc(ride));
|
|
});
|
|
|
|
test('an owner can list and delete every account-cleanup path', async () => {
|
|
await seedCleanupTree();
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
|
|
await assertSucceeds(getDoc(doc(alice, 'users/alice')));
|
|
for (const entry of cleanupCollections) {
|
|
await assertSucceeds(getDocs(collection(alice, entry.collectionPath)));
|
|
}
|
|
for (const entry of cleanupCollections) {
|
|
await assertSucceeds(deleteDoc(doc(alice, entry.documentPath)));
|
|
}
|
|
await assertSucceeds(deleteDoc(doc(alice, 'users/alice')));
|
|
});
|
|
|
|
test('another user cannot list or delete any account-cleanup path', async () => {
|
|
await seedCleanupTree();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
|
|
await assertFails(getDoc(doc(bob, 'users/alice')));
|
|
await assertFails(deleteDoc(doc(bob, 'users/alice')));
|
|
for (const entry of cleanupCollections) {
|
|
await assertFails(getDocs(collection(bob, entry.collectionPath)));
|
|
await assertFails(deleteDoc(doc(bob, entry.documentPath)));
|
|
}
|
|
});
|
|
|
|
test('signed-out callers cannot list or delete account-cleanup paths', async () => {
|
|
await seedCleanupTree();
|
|
const signedOut = testEnv.unauthenticatedContext().firestore();
|
|
|
|
await assertFails(getDoc(doc(signedOut, 'users/alice')));
|
|
await assertFails(deleteDoc(doc(signedOut, 'users/alice')));
|
|
for (const entry of cleanupCollections) {
|
|
await assertFails(getDocs(collection(signedOut, entry.collectionPath)));
|
|
await assertFails(deleteDoc(doc(signedOut, entry.documentPath)));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Social Architecture Security Rules', () => {
|
|
test('public profile creation, reading, and privacy protection', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
|
|
// Alice creates public profile
|
|
await assertSucceeds(setDoc(doc(alice, 'public_profiles/alice'), {
|
|
id: 'alice',
|
|
username: 'alice_apex',
|
|
displayName: 'Alice Apex',
|
|
avatarPreset: 'helmet_cyan',
|
|
bio: 'Apex hunter',
|
|
location: 'Atlanta',
|
|
isSearchableByHandle: true,
|
|
isProfilePublic: true,
|
|
allowFriendRequests: true,
|
|
}));
|
|
|
|
// Bob can read Alice's public profile
|
|
await assertSucceeds(getDoc(doc(bob, 'public_profiles/alice')));
|
|
|
|
// Bob CANNOT edit Alice's public profile
|
|
await assertFails(updateDoc(doc(bob, 'public_profiles/alice'), { bio: 'Hacked' }));
|
|
|
|
// Bob CANNOT list all public profiles
|
|
await assertFails(getDocs(collection(bob, 'public_profiles')));
|
|
|
|
// Alice sets isSearchableByHandle = false
|
|
await assertSucceeds(updateDoc(doc(alice, 'public_profiles/alice'), {
|
|
isSearchableByHandle: false,
|
|
}));
|
|
|
|
// Bob can no longer read Alice's non-searchable profile since they are not friends
|
|
await assertFails(getDoc(doc(bob, 'public_profiles/alice')));
|
|
});
|
|
|
|
test('handle reservation uniqueness and directory scraping prevention', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
|
|
// Alice creates public profile first so handle lookup can check searchability
|
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
|
const db = context.firestore();
|
|
await setDoc(doc(db, 'public_profiles/alice'), {
|
|
id: 'alice',
|
|
username: 'alice_apex',
|
|
isSearchableByHandle: true,
|
|
});
|
|
});
|
|
|
|
// Alice claims handle
|
|
await assertSucceeds(setDoc(doc(alice, 'handles/alice_apex'), {
|
|
uid: 'alice',
|
|
handle: 'alice_apex',
|
|
}));
|
|
|
|
// Bob CANNOT overwrite Alice's handle
|
|
await assertFails(setDoc(doc(bob, 'handles/alice_apex'), {
|
|
uid: 'bob',
|
|
handle: 'alice_apex',
|
|
}));
|
|
|
|
// Bob CANNOT delete Alice's handle
|
|
await assertFails(deleteDoc(doc(bob, 'handles/alice_apex')));
|
|
|
|
// Bob CANNOT list all handles
|
|
await assertFails(getDocs(collection(bob, 'handles')));
|
|
});
|
|
|
|
test('friend requests: legitimate vs forged vs blocked vs disabled', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
|
|
// Seed Bob's public profile accepting requests
|
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
|
const db = context.firestore();
|
|
await setDoc(doc(db, 'public_profiles/bob'), {
|
|
id: 'bob',
|
|
username: 'bob_rider',
|
|
allowFriendRequests: true,
|
|
});
|
|
});
|
|
|
|
// Alice sends legitimate friend request to Bob
|
|
await assertSucceeds(setDoc(doc(alice, 'users/bob/friend_requests_incoming/alice'), {
|
|
receiverUid: 'bob',
|
|
senderUid: 'alice',
|
|
senderHandle: 'alice_apex',
|
|
}));
|
|
|
|
// Alice CANNOT forge request pretending to be Charlie
|
|
await assertFails(setDoc(doc(alice, 'users/bob/friend_requests_incoming/charlie'), {
|
|
receiverUid: 'bob',
|
|
senderUid: 'charlie',
|
|
senderHandle: 'charlie',
|
|
}));
|
|
|
|
// Alice CANNOT send friend request to herself
|
|
await assertFails(setDoc(doc(alice, 'users/alice/friend_requests_incoming/alice'), {
|
|
receiverUid: 'alice',
|
|
senderUid: 'alice',
|
|
senderHandle: 'alice',
|
|
}));
|
|
});
|
|
|
|
test('friendship establishment requires incoming request; unilateral declaration fails', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
|
|
// Unilateral friendship: Alice tries to add Bob directly as a friend without incoming request -> FAILS
|
|
await assertFails(setDoc(doc(alice, 'users/alice/friends/bob'), {
|
|
uid: 'bob',
|
|
handle: 'bob_rider',
|
|
}));
|
|
|
|
// Seed incoming request from Alice to Bob
|
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
|
const db = context.firestore();
|
|
await setDoc(doc(db, 'users/bob/friend_requests_incoming/alice'), {
|
|
receiverUid: 'bob',
|
|
senderUid: 'alice',
|
|
});
|
|
});
|
|
|
|
// Bob accepts request -> SUCCEEDS because Bob has incoming request from Alice
|
|
await assertSucceeds(setDoc(doc(bob, 'users/bob/friends/alice'), {
|
|
uid: 'alice',
|
|
handle: 'alice_apex',
|
|
}));
|
|
|
|
// Bob can also create Alice's corresponding friend doc in the mutual acceptance
|
|
await assertSucceeds(setDoc(doc(bob, 'users/alice/friends/bob'), {
|
|
uid: 'bob',
|
|
handle: 'bob_rider',
|
|
}));
|
|
|
|
// Outsider Charlie CANNOT delete Alice and Bob's friendship
|
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
|
await assertFails(deleteDoc(doc(charlie, 'users/alice/friends/bob')));
|
|
|
|
// Either participant (Alice) can remove friend
|
|
await assertSucceeds(deleteDoc(doc(alice, 'users/alice/friends/bob')));
|
|
});
|
|
|
|
test('blocking prevents requests, hides profiles, and blocks cannot be forged', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
|
|
// Seed Alice's public profile
|
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
|
const db = context.firestore();
|
|
await setDoc(doc(db, 'public_profiles/alice'), {
|
|
id: 'alice',
|
|
username: 'alice_apex',
|
|
isSearchableByHandle: true,
|
|
allowFriendRequests: true,
|
|
});
|
|
await setDoc(doc(db, 'handles/alice_apex'), {
|
|
uid: 'alice',
|
|
handle: 'alice_apex',
|
|
});
|
|
});
|
|
|
|
// Alice blocks Bob
|
|
await assertSucceeds(setDoc(doc(alice, 'users/alice/blocks/bob'), {
|
|
blockedUid: 'bob',
|
|
}));
|
|
|
|
// Bob CANNOT read or delete Alice's block doc
|
|
await assertFails(getDoc(doc(bob, 'users/alice/blocks/bob')));
|
|
await assertFails(deleteDoc(doc(bob, 'users/alice/blocks/bob')));
|
|
|
|
// Bob CANNOT read Alice's public profile once blocked
|
|
await assertFails(getDoc(doc(bob, 'public_profiles/alice')));
|
|
|
|
// Bob CANNOT get Alice's handle doc once blocked
|
|
await assertFails(getDoc(doc(bob, 'handles/alice_apex')));
|
|
|
|
// Bob CANNOT send friend request to Alice once blocked
|
|
await assertFails(setDoc(doc(bob, 'users/alice/friend_requests_incoming/bob'), {
|
|
receiverUid: 'alice',
|
|
senderUid: 'bob',
|
|
}));
|
|
});
|
|
|
|
describe('Group Rides Security Rules', () => {
|
|
test('organizer can create group ride and initialize organizer participant', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
|
|
const rideData = {
|
|
id: 'ride_100',
|
|
organizerUid: 'alice',
|
|
organizerHandle: 'alice_apex',
|
|
organizerDisplayName: 'Alice Apex',
|
|
title: 'Blood Mountain Run',
|
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
|
departureAt: '2026-09-10T09:30:00Z',
|
|
participantCount: 1,
|
|
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',
|
|
}));
|
|
|
|
// 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,
|
|
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,
|
|
visibility: 'friendsOnly',
|
|
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
|
});
|
|
// Bob is Alice's friend
|
|
await setDoc(doc(db, 'users/alice/friends/bob'), { uid: 'bob' });
|
|
// Alice blocks Charlie
|
|
await setDoc(doc(db, 'users/alice/blocks/charlie'), { blockedUid: 'charlie' });
|
|
});
|
|
|
|
// Private ride: non-member Bob CANNOT read
|
|
await assertFails(getDoc(doc(bob, 'group_rides/ride_private')));
|
|
|
|
// Friends ride: friend Bob CAN read
|
|
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_friends')));
|
|
|
|
// Blocked user Charlie CANNOT read friends ride
|
|
await assertFails(getDoc(doc(charlie, 'group_rides/ride_friends')));
|
|
});
|
|
|
|
test('invitations, acceptance, and capacity modification', async () => {
|
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
|
const db = context.firestore();
|
|
await setDoc(doc(db, 'group_rides/ride_inv'), {
|
|
id: 'ride_inv',
|
|
organizerUid: 'alice',
|
|
title: 'Invite Run',
|
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
|
departureAt: '2026-09-10T09:30:00Z',
|
|
participantCount: 1,
|
|
visibility: 'friendsOnly',
|
|
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
|
});
|
|
});
|
|
|
|
// Alice invites Bob
|
|
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_inv/participants/bob'), {
|
|
userId: 'bob',
|
|
role: 'participant',
|
|
status: 'invited',
|
|
riderHandle: 'bob_rider',
|
|
riderDisplayName: 'Bob Rider',
|
|
invitedBy: 'alice',
|
|
}));
|
|
|
|
// Outsider Charlie CANNOT forge an invitation to himself
|
|
await assertFails(setDoc(doc(charlie, 'group_rides/ride_inv/participants/charlie'), {
|
|
userId: 'charlie',
|
|
role: 'participant',
|
|
status: 'invited',
|
|
invitedBy: 'alice',
|
|
}));
|
|
|
|
// Charlie CANNOT accept Bob's invite
|
|
await assertFails(updateDoc(doc(charlie, 'group_rides/ride_inv/participants/bob'), {
|
|
status: 'accepted',
|
|
}));
|
|
|
|
// Bob accepts his own invite
|
|
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv/participants/bob'), {
|
|
status: 'accepted',
|
|
}));
|
|
|
|
// Bob can adjust participantCount on the ride during acceptance
|
|
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv'), {
|
|
participantCount: 2,
|
|
updatedAt: '2026-09-10T09:05:00Z',
|
|
}));
|
|
|
|
// Bob CANNOT edit authority fields like title or organizerUid
|
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_inv'), {
|
|
title: 'Hijacked Title',
|
|
}));
|
|
});
|
|
|
|
test('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,
|
|
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
|
|
await assertSucceeds(getDoc(doc(alice, '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')));
|
|
|
|
// 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,
|
|
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,
|
|
visibility: 'public',
|
|
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
|
|
});
|
|
await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/alice'), {
|
|
userId: 'alice',
|
|
role: 'organizer',
|
|
status: 'accepted',
|
|
});
|
|
await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/bob'), {
|
|
userId: 'bob',
|
|
role: 'participant',
|
|
status: 'accepted',
|
|
});
|
|
await setDoc(doc(db, 'group_rides/ride_lifecycle/live_presence/bob'), validBobPresence);
|
|
await setDoc(doc(db, 'group_rides/ride_lifecycle/live_presence/alice'), validAlicePresence);
|
|
await setDoc(doc(db, 'users/alice'), { id: 'alice' });
|
|
await setDoc(doc(db, 'users/bob'), { id: 'bob' });
|
|
await deleteDoc(doc(db, 'users/alice/blocks/bob'));
|
|
await deleteDoc(doc(db, 'users/bob/blocks/alice'));
|
|
});
|
|
};
|
|
|
|
// Scenario A: Participant Removal (Organizer removes Rider B)
|
|
await resetLiveState();
|
|
// Alice reads Bob initially -> succeeds
|
|
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
|
// Organizer removes Bob by deleting participant record
|
|
await assertSucceeds(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob')));
|
|
// Now Bob CANNOT write to live presence
|
|
await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), {
|
|
...validBobPresence,
|
|
speedMph: 55.0,
|
|
}));
|
|
// Alice CANNOT read Bob's old presence
|
|
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
|
|
|
// Scenario B: Voluntary Leave (Bob leaves the ride)
|
|
await resetLiveState();
|
|
// Bob deletes his participant record (voluntary leave)
|
|
await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob')));
|
|
// Bob CANNOT write to live presence
|
|
await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), {
|
|
...validBobPresence,
|
|
speedMph: 50.0,
|
|
}));
|
|
// Alice CANNOT read Bob's presence
|
|
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
|
|
|
// Scenario 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 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')));
|
|
});
|
|
|
|
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)));
|
|
});
|
|
});
|
|
});
|
|
|