170 lines
11 KiB
JavaScript
170 lines
11 KiB
JavaScript
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') } });
|
||
}, 30000);
|
||
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' }));
|
||
});
|
||
});
|