375 lines
13 KiB
JavaScript
375 lines
13 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',
|
|
}));
|
|
});
|
|
});
|