import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest'; import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing'; import { collection, deleteDoc, doc, getDoc, getDocs, limit, orderBy, query, setDoc, Timestamp, updateDoc } from 'firebase/firestore'; import fs from 'node:fs'; import path from 'node:path'; let env; const clubId = 'ClubRoutesChatTest'; const publicClubId = 'ClubPublicRoutesChatTest'; const profile = (cid, uid, role = 'member', status = 'active') => ({ uid, clubId: cid, role, status, displayRank: role === 'owner' ? 'President' : role === 'admin' ? 'Vice President' : 'Road Captain', }); const sampleRoute = (cid = clubId, routeId = 'rt-1', curatorUid = 'member') => ({ id: routeId, clubId: cid, title: 'Blood Mountain Loop', description: 'Fast elevation transitions and pristine sweepers.', distanceMiles: 48.6, estimatedMinutes: 65, elevationGainFt: 3120, curvatureRating: 'High Twisties', surfaceType: 'Paved Asphalt', startLocation: 'Dahlonega Outpost', endLocation: 'Vogel State Park', gpxUrl: '/fixtures/blood-mountain.gpx', curatorUid, curatorName: 'Rider', createdAt: Timestamp.now(), }); const sampleMessage = (cid = clubId, channelId = 'general', msgId = 'msg-1', authorUid = 'member', role = 'member') => ({ id: msgId, clubId: cid, channelId, content: 'Kickstands up at 09:00 AM sharp at the north staging point.', authorUid, authorDisplayName: authorUid, authorRole: role, authorHandle: `@${authorUid}`, authorDisplayRank: role === 'owner' ? 'President' : role === 'admin' ? 'Vice President' : 'Road Captain', createdAt: Timestamp.now(), }); const dbFor = (uid) => env.authenticatedContext(uid).firestore(); beforeAll(async () => { env = await initializeTestEnvironment({ projectId: 'demo-motorsignal-club-routes-chat', firestore: { rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8'), }, }); }, 30000); afterAll(async () => { await env?.cleanup(); }); beforeEach(async () => { await env.clearFirestore(); await env.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); // 1. Seed private club await setDoc(doc(db, 'clubs', clubId), { id: clubId, ownerUid: 'owner', memberCount: 3, archivedAt: null, visibility: 'private', }); // Seed members for private club await setDoc(doc(db, 'clubs', clubId, 'members', 'owner'), profile(clubId, 'owner', 'owner', 'active')); await setDoc(doc(db, 'clubs', clubId, 'members', 'admin'), profile(clubId, 'admin', 'admin', 'active')); await setDoc(doc(db, 'clubs', clubId, 'members', 'member'), profile(clubId, 'member', 'member', 'active')); await setDoc(doc(db, 'clubs', clubId, 'members', 'bannedMember'), profile(clubId, 'bannedMember', 'member', 'active')); await setDoc(doc(db, 'clubs', clubId, 'bans', 'bannedMember'), { uid: 'bannedMember' }); // Seed initial route in private club await setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-initial'), sampleRoute(clubId, 'rt-initial', 'owner')); // Seed initial messages in private club await setDoc( doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'), sampleMessage(clubId, 'general', 'msg-gen-1', 'member', 'member') ); await setDoc( doc(db, 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1'), sampleMessage(clubId, 'officers', 'msg-off-1', 'admin', 'admin') ); // 2. Seed public club await setDoc(doc(db, 'clubs', publicClubId), { id: publicClubId, ownerUid: 'owner', memberCount: 3, archivedAt: null, visibility: 'public', }); await setDoc(doc(db, 'clubs', publicClubId, 'members', 'owner'), profile(publicClubId, 'owner', 'owner', 'active')); await setDoc(doc(db, 'clubs', publicClubId, 'members', 'admin'), profile(publicClubId, 'admin', 'admin', 'active')); await setDoc(doc(db, 'clubs', publicClubId, 'members', 'member'), profile(publicClubId, 'member', 'member', 'active')); await setDoc(doc(db, 'clubs', publicClubId, 'routes', 'rt-pub-1'), sampleRoute(publicClubId, 'rt-pub-1', 'owner')); }); }); // ============================================================================ // SUITE 1: Curated Club Routes // ============================================================================ describe('Curated Club Routes Security Rules', () => { test('Active member can create a route in their club', async () => { const db = dbFor('member'); await assertSucceeds( setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-member-1'), sampleRoute(clubId, 'rt-member-1', 'member')) ); }); test('Club owner can create a route in their club', async () => { const db = dbFor('owner'); await assertSucceeds( setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-owner-1'), sampleRoute(clubId, 'rt-owner-1', 'owner')) ); }); test('Outsider CANNOT create a route in the club', async () => { const db = dbFor('outsider'); await assertFails( setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-outsider-1'), sampleRoute(clubId, 'rt-outsider-1', 'outsider')) ); }); test('Banned member CANNOT create a route in the club', async () => { const db = dbFor('bannedMember'); await assertFails( setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-banned-1'), sampleRoute(clubId, 'rt-banned-1', 'bannedMember')) ); }); test('Signed-out or anonymous caller CANNOT create a route', async () => { const anonDb = env.authenticatedContext('anon', { firebase: { sign_in_provider: 'anonymous' } }).firestore(); const unauthDb = env.unauthenticatedContext().firestore(); await assertFails(setDoc(doc(anonDb, 'clubs', clubId, 'routes', 'rt-anon'), sampleRoute(clubId, 'rt-anon', 'anon'))); await assertFails(setDoc(doc(unauthDb, 'clubs', clubId, 'routes', 'rt-unauth'), sampleRoute(clubId, 'rt-unauth', 'unauth'))); }); test('Curator UID forgery is strictly rejected', async () => { const db = dbFor('member'); await assertFails( setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-forged'), sampleRoute(clubId, 'rt-forged', 'owner')) ); }); test('Archived club rejects route creation, even from club owner', async () => { await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), 'clubs', clubId), { archivedAt: Timestamp.now() }) ); const db = dbFor('owner'); await assertFails( setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-archived'), sampleRoute(clubId, 'rt-archived', 'owner')) ); }); test('Malformed or schema-polluted route payloads are rejected', async () => { const db = dbFor('member'); const valid = sampleRoute(clubId, 'rt-bad', 'member'); // Missing required field const { title: _, ...missingTitle } = valid; await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), missingTitle)); // Negative distance await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), { ...valid, distanceMiles: -5 })); // Empty startLocation await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), { ...valid, startLocation: '' })); // Extra forbidden field await assertFails(setDoc(doc(db, 'clubs', clubId, 'routes', 'rt-bad'), { ...valid, extraField: 'exploit' })); }); test('Route read in public club is allowed for any registered user (including outsider)', async () => { const db = dbFor('outsider'); await assertSucceeds(getDoc(doc(db, 'clubs', publicClubId, 'routes', 'rt-pub-1'))); await assertSucceeds(getDocs(query(collection(db, 'clubs', publicClubId, 'routes'), limit(10)))); }); test('Route read in private club is allowed for active members and owner', async () => { await assertSucceeds(getDoc(doc(dbFor('member'), 'clubs', clubId, 'routes', 'rt-initial'))); await assertSucceeds(getDoc(doc(dbFor('admin'), 'clubs', clubId, 'routes', 'rt-initial'))); await assertSucceeds(getDoc(doc(dbFor('owner'), 'clubs', clubId, 'routes', 'rt-initial'))); }); test('Route read in private club is DENIED for outsider', async () => { const db = dbFor('outsider'); await assertFails(getDoc(doc(db, 'clubs', clubId, 'routes', 'rt-initial'))); await assertFails(getDocs(collection(db, 'clubs', clubId, 'routes'))); }); test('Banned member is DENIED reading routes in any club', async () => { const db = dbFor('bannedMember'); await assertFails(getDoc(doc(db, 'clubs', clubId, 'routes', 'rt-initial'))); }); test('Club admin can delete a route; regular member is DENIED deleting a route', async () => { // Regular member attempts delete -> DENIED await assertFails(deleteDoc(doc(dbFor('member'), 'clubs', clubId, 'routes', 'rt-initial'))); // Outsider attempts delete -> DENIED await assertFails(deleteDoc(doc(dbFor('outsider'), 'clubs', clubId, 'routes', 'rt-initial'))); // Club admin deletes route -> ALLOWED await assertSucceeds(deleteDoc(doc(dbFor('admin'), 'clubs', clubId, 'routes', 'rt-initial'))); }); test('Club owner can delete a route', async () => { await assertSucceeds(deleteDoc(doc(dbFor('owner'), 'clubs', clubId, 'routes', 'rt-initial'))); }); test('Route updates are DENIED for everyone (immutable routes)', async () => { await assertFails(updateDoc(doc(dbFor('owner'), 'clubs', clubId, 'routes', 'rt-initial'), { title: 'New Name' })); await assertFails(updateDoc(doc(dbFor('admin'), 'clubs', clubId, 'routes', 'rt-initial'), { title: 'New Name' })); await assertFails(updateDoc(doc(dbFor('member'), 'clubs', clubId, 'routes', 'rt-initial'), { title: 'New Name' })); }); }); // ============================================================================ // SUITE 2: Squad Live Chat Channels & Messages // ============================================================================ describe('Squad Live Chat Channels & Messages Security Rules', () => { test('Active member can post to general channel; outsider is DENIED', async () => { // Active member post -> ALLOWED await assertSucceeds( setDoc( doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-2'), sampleMessage(clubId, 'general', 'msg-gen-2', 'member', 'member') ) ); // Outsider post -> DENIED await assertFails( setDoc( doc(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-3'), sampleMessage(clubId, 'general', 'msg-gen-3', 'outsider', 'member') ) ); }); test('Active member can post to ride-intel channel', async () => { await assertSucceeds( setDoc( doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'ride-intel', 'messages', 'msg-intel-1'), sampleMessage(clubId, 'ride-intel', 'msg-intel-1', 'member', 'member') ) ); }); test('Club admin can post to officers channel; regular member is DENIED posting to officers channel', async () => { // Regular member attempts to post to officers channel -> DENIED await assertFails( setDoc( doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-2'), sampleMessage(clubId, 'officers', 'msg-off-2', 'member', 'member') ) ); // Club admin posts to officers channel -> ALLOWED await assertSucceeds( setDoc( doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-2'), sampleMessage(clubId, 'officers', 'msg-off-2', 'admin', 'admin') ) ); }); test('Club owner can post to officers channel', async () => { await assertSucceeds( setDoc( doc(dbFor('owner'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-3'), sampleMessage(clubId, 'officers', 'msg-off-3', 'owner', 'owner') ) ); }); test('Regular member is DENIED reading messages in officers channel; club admin is allowed', async () => { // Regular member reading officers channel message -> DENIED await assertFails( getDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1')) ); await assertFails( getDocs(collection(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages')) ); // Outsider reading officers channel message -> DENIED await assertFails( getDoc(doc(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1')) ); // Club admin reading officers channel message -> ALLOWED await assertSucceeds( getDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1')) ); await assertSucceeds( getDocs(collection(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages')) ); // Club owner reading officers channel message -> ALLOWED await assertSucceeds( getDoc(doc(dbFor('owner'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1')) ); }); test('Active member can read messages in general channel; outsider is DENIED', async () => { await assertSucceeds( getDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1')) ); await assertSucceeds( getDocs(collection(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages')) ); await assertFails( getDoc(doc(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1')) ); await assertFails( getDocs(collection(dbFor('outsider'), 'clubs', clubId, 'chat_channels', 'general', 'messages')) ); }); test('Role spoofing or author forgery in chat message is rejected', async () => { const db = dbFor('member'); // Member claims role: 'admin' await assertFails( setDoc( doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-spoof-1'), sampleMessage(clubId, 'general', 'msg-spoof-1', 'member', 'admin') ) ); // Member claims role: 'owner' await assertFails( setDoc( doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-spoof-2'), sampleMessage(clubId, 'general', 'msg-spoof-2', 'member', 'owner') ) ); // Member claims someone else is authorUid await assertFails( setDoc( doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-spoof-3'), sampleMessage(clubId, 'general', 'msg-spoof-3', 'owner', 'member') ) ); }); test('Chat message content validation limits (1..1000 chars)', async () => { const db = dbFor('member'); const valid = sampleMessage(clubId, 'general', 'msg-len', 'member', 'member'); // Empty content -> DENIED await assertFails( setDoc(doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-len'), { ...valid, content: ' ', }) ); // Exceeds 1000 chars -> DENIED await assertFails( setDoc(doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-len'), { ...valid, content: 'x'.repeat(1001), }) ); // Exactly 1000 chars -> ALLOWED await assertSucceeds( setDoc(doc(db, 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-len'), { ...valid, content: 'x'.repeat(1000), }) ); }); test('Chat messages are immutable (update is DENIED)', async () => { await assertFails( updateDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'), { content: 'Edited content', }) ); await assertFails( updateDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-gen-1'), { content: 'Admin edited content', }) ); }); test('Author can delete own message; Admin can delete other messages; Member cannot delete other messages', async () => { // Seed message by member const memberMsgDoc = doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-1'); await assertSucceeds( setDoc(memberMsgDoc, sampleMessage(clubId, 'general', 'msg-del-1', 'member', 'member')) ); // Another non-admin member cannot delete it await env.withSecurityRulesDisabled((ctx) => setDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'otherMember'), profile(clubId, 'otherMember')) ); await assertFails( deleteDoc(doc(dbFor('otherMember'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-1')) ); // Author can delete their own message await assertSucceeds(deleteDoc(memberMsgDoc)); // Admin can delete a member message await env.withSecurityRulesDisabled((ctx) => setDoc( doc(ctx.firestore(), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-2'), sampleMessage(clubId, 'general', 'msg-del-2', 'member', 'member') ) ); await assertSucceeds( deleteDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'general', 'messages', 'msg-del-2')) ); }); test('Officers channel deletion: admin can delete, regular member is DENIED', async () => { // Regular member attempts delete in officers channel -> DENIED await assertFails( deleteDoc(doc(dbFor('member'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1')) ); // Admin deletes in officers channel -> ALLOWED await assertSucceeds( deleteDoc(doc(dbFor('admin'), 'clubs', clubId, 'chat_channels', 'officers', 'messages', 'msg-off-1')) ); }); });