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

170 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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