MotorSignal-App/firebase/emulator-tests/club-routes-chat.rules.test.js

447 lines
17 KiB
JavaScript

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