447 lines
17 KiB
JavaScript
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'))
|
|
);
|
|
});
|
|
});
|