import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest'; import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing'; import { collection, deleteDoc, doc, documentId, getDoc, getDocs, limit, orderBy, query, setDoc, startAfter, Timestamp, updateDoc, where, writeBatch } from 'firebase/firestore'; import fs from 'node:fs'; import path from 'node:path'; let env; const clubId = 'ClubCaseSensitive'; const postPath = `clubs/${clubId}/wall_posts/post1`; const profile = (uid, role = 'member', status = 'active') => ({ uid, clubId, role, status, displayRank: role === 'owner' ? 'President' : 'Member' }); const post = (uid = 'member', role = 'member') => ({ id: 'post1', clubId, authorUid: uid, authorRole: role, authorDisplayName: uid, authorDisplayRank: role === 'owner' ? 'President' : 'Member', content: 'Ride briefing', createdAt: Timestamp.now(), isPinned: false, fistBumpCount: 0, fistBumpUserIds: [], comments: [], }); const comment = (uid = 'member', role = 'member') => ({ id: 'reply1', postId: 'post1', authorUid: uid, authorRole: role, authorDisplayName: uid, authorDisplayRank: role === 'owner' ? 'President' : 'Member', content: 'See you there', createdAt: new Date().toISOString(), }); const dbFor = (uid) => env.authenticatedContext(uid).firestore(); beforeAll(async () => { env = await initializeTestEnvironment({ projectId: 'demo-motorsignal-club-wall', firestore: { rules: fs.readFileSync(path.resolve(process.cwd(), '../../firestore.rules'), 'utf8') } }); }); afterAll(async () => { await env?.cleanup(); }); beforeEach(async () => { await env.clearFirestore(); await env.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, 'clubs', clubId), { id: clubId, ownerUid: 'owner', memberCount: 4, archivedAt: null, visibility: 'private' }); for (const [uid, role, status] of [['owner', 'owner', 'active'], ['admin', 'admin', 'active'], ['peer', 'admin', 'active'], ['member', 'member', 'active'], ['invited', 'member', 'invited'], ['requested', 'member', 'requested'], ['removed', 'member', 'removed']]) { await setDoc(doc(db, 'clubs', clubId, 'members', uid), profile(uid, role, status)); } await setDoc(doc(db, postPath), post()); }); }); describe('Club wall privacy and author authority', () => { test.each(['owner', 'admin', 'member'])('%s can point-read and subscribe to the club wall query', async (uid) => { const db = dbFor(uid); await assertSucceeds(getDoc(doc(db, postPath))); await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'wall_posts'), orderBy('createdAt', 'desc'), limit(50)))); }); test.each(['outsider', 'invited', 'requested', 'removed'])('%s cannot read or create posts', async (uid) => { const db = dbFor(uid); await assertFails(getDoc(doc(db, postPath))); await assertFails(getDocs(collection(db, 'clubs', clubId, 'wall_posts'))); await assertFails(setDoc(doc(db, 'clubs', clubId, 'wall_posts', 'post2'), { ...post(uid), id: 'post2' })); }); test('signed-out and anonymous accounts cannot access posts', async () => { for (const db of [env.unauthenticatedContext().firestore(), env.authenticatedContext('member', { firebase: { sign_in_provider: 'anonymous' } }).firestore()]) { await assertFails(getDoc(doc(db, postPath))); await assertFails(setDoc(doc(db, 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2' })); } }); test('a banned active member cannot read or mutate the wall', async () => { await env.withSecurityRulesDisabled((ctx) => setDoc(doc(ctx.firestore(), 'clubs', clubId, 'bans', 'member'), { uid: 'member' })); const db = dbFor('member'); await assertFails(getDoc(doc(db, postPath))); await assertFails(deleteDoc(doc(db, postPath))); await assertFails(updateDoc(doc(db, postPath), { comments: [comment()] })); }); test('archived clubs deny wall access, even for an owner', async () => { await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), 'clubs', clubId), { archivedAt: Timestamp.now() })); await assertFails(getDoc(doc(dbFor('owner'), postPath))); await assertFails(deleteDoc(doc(dbFor('owner'), postPath))); }); test('active member can create an unpinned post with the mobile-compatible model', async () => { await assertSucceeds(setDoc(doc(dbFor('member'), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2' })); }); test.each(['owner', 'admin'])('%s can publish a pinned announcement', async (uid) => { await assertSucceeds(setDoc(doc(dbFor(uid), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(uid, uid), id: 'post2', isPinned: true })); }); test.each([ { authorUid: 'owner' }, { authorRole: 'owner' }, { authorDisplayRank: 'President' }, { isPinned: true }, { clubId: 'otherClub' }, { content: ' ' }, { content: 'x'.repeat(5001) }, { comments: [comment()] }, { fistBumpCount: 10 }, { latitude: 1 }, { createdAt: 'today' }, ])('rejects forged or malformed create: %j', async (change) => { await assertFails(setDoc(doc(dbFor('member'), 'clubs', clubId, 'wall_posts', 'post2'), { ...post(), id: 'post2', ...change })); }); test('wall access does not grant private ride telemetry or live presence', async () => { const db = dbFor('admin'); await assertFails(getDoc(doc(db, 'users', 'member', 'rides', 'privateRide'))); await assertFails(getDoc(doc(db, 'group_rides', 'privateRide', 'live_presence', 'member'))); }); }); describe('Replies and moderation preserve existing content and identity', () => { test('members append replies without rewriting previous comments', async () => { const ref = doc(dbFor('member'), postPath); const first = comment(); await assertSucceeds(updateDoc(ref, { comments: [first] })); await assertSucceeds(updateDoc(ref, { comments: [first, { ...comment(), id: 'reply2' }] })); expect((await getDoc(ref)).data().comments).toHaveLength(2); await assertFails(updateDoc(ref, { comments: [{ ...first, content: 'forged edit' }, { ...comment(), id: 'reply3' }, comment()] })); await assertFails(updateDoc(ref, { comments: [] })); }); test.each([{ authorUid: 'owner' }, { authorRole: 'admin' }, { postId: 'otherPost' }, { content: '' }, { content: 'x'.repeat(2001) }, { latitude: 1 }])('rejects forged reply: %j', async (change) => { await assertFails(updateDoc(doc(dbFor('member'), postPath), { comments: [{ ...comment(), ...change }] })); }); test('reply limit preserves the full existing thread', async () => { const replies = Array.from({ length: 200 }, (_, i) => ({ ...comment(), id: `reply${i}` })); await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), postPath), { comments: replies })); await assertFails(updateDoc(doc(dbFor('member'), postPath), { comments: [...replies, comment()] })); }); test.each(['owner', 'admin'])('%s can pin/unpin and delete another member’s post', async (uid) => { const ref = doc(dbFor(uid), postPath); await assertSucceeds(updateDoc(ref, { isPinned: true })); await assertSucceeds(updateDoc(ref, { isPinned: false })); await assertSucceeds(deleteDoc(ref)); }); test('members cannot pin or delete someone else’s post, even with an officer display rank', async () => { await env.withSecurityRulesDisabled((ctx) => updateDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'member'), { displayRank: 'President' })); const ref = doc(dbFor('member'), postPath); await assertFails(updateDoc(ref, { isPinned: true })); }); test('post author may delete their own post; members cannot delete others', async () => { await env.withSecurityRulesDisabled((ctx) => setDoc(doc(ctx.firestore(), 'clubs', clubId, 'members', 'otherMember'), profile('otherMember'))); await assertFails(deleteDoc(doc(dbFor('otherMember'), postPath))); await assertSucceeds(deleteDoc(doc(dbFor('member'), postPath))); }); test.each([{ authorUid: 'owner' }, { createdAt: Timestamp.fromMillis(1) }, { content: 'rewritten' }, { clubId: 'otherClub' }])('even admins cannot rewrite post identity/content: %j', async (change) => { await assertFails(updateDoc(doc(dbFor('admin'), postPath), change)); }); test('mobile fist bump toggles can change only the caller’s own reaction', async () => { const ref = doc(dbFor('member'), postPath); await assertSucceeds(updateDoc(ref, { fistBumpCount: 1, fistBumpUserIds: ['member'] })); await assertFails(updateDoc(ref, { fistBumpCount: 2, fistBumpUserIds: ['member', 'admin'] })); await assertSucceeds(updateDoc(ref, { fistBumpCount: 0, fistBumpUserIds: [] })); }); }); describe('Members management remains transactional and preserves hierarchy', () => { test('roster pagination uses the active-member query and a document ID cursor', async () => { const db = dbFor('admin'); const first = await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'members'), where('status', '==', 'active'), orderBy(documentId()), limit(2)))); const next = await assertSucceeds(getDocs(query(collection(db, 'clubs', clubId, 'members'), where('status', '==', 'active'), orderBy(documentId()), startAfter(first.docs[1].id), limit(2)))); expect(new Set([...first.docs, ...next.docs].map((item) => item.id)).size).toBe(4); }); test.each(['owner', 'admin'])('%s can remove an active member with the matching count decrement', async (uid) => { const db = dbFor(uid); const batch = writeBatch(db); batch.update(doc(db, 'clubs', clubId, 'members', 'member'), { status: 'removed', removedAt: Timestamp.now(), updatedAt: Timestamp.now() }); batch.update(doc(db, 'clubs', clubId), { memberCount: 3, lastMembershipChangeUid: 'member', updatedAt: Timestamp.now() }); await assertSucceeds(batch.commit()); expect((await getDoc(doc(db, 'clubs', clubId, 'members', 'member'))).data().status).toBe('removed'); }); test('removal without a count decrement is denied', async () => { await assertFails(updateDoc(doc(dbFor('admin'), 'clubs', clubId, 'members', 'member'), { status: 'removed' })); }); test('admin cannot remove owner or peer admin', async () => { for (const uid of ['owner', 'peer']) { const db = dbFor('admin'); const batch = writeBatch(db); batch.update(doc(db, 'clubs', clubId, 'members', uid), { status: 'removed' }); batch.update(doc(db, 'clubs', clubId), { memberCount: 3, lastMembershipChangeUid: uid }); await assertFails(batch.commit()); } }); test('admin can change cosmetic rank without changing the member role', async () => { const ref = doc(dbFor('admin'), 'clubs', clubId, 'members', 'member'); await assertSucceeds(updateDoc(ref, { displayRank: 'Road Captain', updatedAt: Timestamp.now() })); expect((await getDoc(ref)).data().role).toBe('member'); await assertFails(updateDoc(ref, { role: 'admin' })); await assertFails(updateDoc(doc(dbFor('member'), 'clubs', clubId, 'members', 'member'), { displayRank: 'President' })); }); });