import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; import { GET } from '../app/api/clubs/[clubId]/rides/route'; import { getAdminAuth, getAdminFirestore } from '../lib/firebase-admin'; vi.mock('../lib/firebase-admin', () => ({ getAdminAuth: vi.fn(), getAdminFirestore: vi.fn(), })); describe('Club GroupRide Secure Member Discovery API (GET /api/clubs/[clubId]/rides)', () => { let mockAuth: any; let mockFirestore: any; // In-memory store for mock firestore let clubsStore: Map; let clubMembersStore: Map; // key: `${clubId}/${uid}` let userBlocksStore: Map>; // key: uid -> Set of blocked uids let groupRidesStore: Array<{ id: string; data: any }>; beforeEach(() => { vi.restoreAllMocks(); clubsStore = new Map(); clubMembersStore = new Map(); userBlocksStore = new Map(); groupRidesStore = []; mockAuth = { verifyIdToken: vi.fn(async (token: string) => { if (token === 'valid_token_charlie') { return { uid: 'charlie' }; } if (token === 'valid_token_alice') { return { uid: 'alice' }; } if (token === 'valid_token_bob') { return { uid: 'bob' }; } if (token === 'valid_token_dave') { return { uid: 'dave' }; } throw new Error('Invalid token'); }), }; mockFirestore = { collection: (colName: string) => { if (colName === 'clubs') { return { doc: (clubId: string) => ({ get: async () => { const data = clubsStore.get(clubId); return { exists: !!data, data: () => data, }; }, collection: (subCol: string) => { if (subCol === 'members') { return { doc: (uid: string) => ({ get: async () => { const data = clubMembersStore.get(`${clubId}/${uid}`); return { exists: !!data, data: () => data, }; }, }), }; } throw new Error(`Unexpected subcollection: ${subCol}`); }, }), }; } if (colName === 'users') { return { doc: (uid: string) => ({ collection: (subCol: string) => { if (subCol === 'blocks') { return { get: async () => { const blockedSet = userBlocksStore.get(uid) || new Set(); return { docs: Array.from(blockedSet).map((bUid) => ({ id: bUid, data: () => ({ blockedUid: bUid }), })), }; }, doc: (targetUid: string) => ({ get: async () => { const blockedSet = userBlocksStore.get(uid) || new Set(); const exists = blockedSet.has(targetUid); return { exists, data: () => (exists ? { blockedUid: targetUid } : undefined), }; }, }), }; } throw new Error(`Unexpected subcollection: ${subCol}`); }, }), }; } if (colName === 'group_rides') { const createQuery = (filters: any) => ({ where: (field: string, op: string, val: any) => { return createQuery({ ...filters, [field]: { op, val } }); }, orderBy: (field: string, dir: string) => createQuery({ ...filters, orderBy: { field, dir } }), limit: (num: number) => createQuery({ ...filters, limit: num }), startAfter: (lastDoc: any) => createQuery({ ...filters, startAfterId: lastDoc?.id }), get: async () => { let results = groupRidesStore.filter((r) => { const d = r.data; if (filters.clubId && d.clubId !== filters.clubId.val) return false; if (filters.status && filters.status.op === 'in') { if (!filters.status.val.includes(d.status)) return false; } return true; }); if (filters.orderBy) { const { field, dir } = filters.orderBy; results = [...results].sort((a, b) => { const valA = a.data[field] || ''; const valB = b.data[field] || ''; return dir === 'desc' ? (valB > valA ? 1 : -1) : (valA > valB ? 1 : -1); }); } if (filters.startAfterId) { const idx = results.findIndex((r) => r.id === filters.startAfterId); if (idx !== -1) { results = results.slice(idx + 1); } } if (filters.limit) { results = results.slice(0, filters.limit); } return { empty: results.length === 0, docs: results.map((r) => ({ id: r.id, data: () => r.data, })), }; }, }); return createQuery({}); } throw new Error(`Unexpected collection: ${colName}`); }, }; (getAdminAuth as any).mockReturnValue(mockAuth); (getAdminFirestore as any).mockReturnValue(mockFirestore); // Setup standard active club_alpha clubsStore.set('club_alpha', { id: 'club_alpha', name: 'Alpha Riders', archivedAt: null, }); // Alice is active owner clubMembersStore.set('club_alpha/alice', { uid: 'alice', clubId: 'club_alpha', role: 'owner', status: 'active', }); // Bob is active admin clubMembersStore.set('club_alpha/bob', { uid: 'bob', clubId: 'club_alpha', role: 'admin', status: 'active', }); // Charlie is active member clubMembersStore.set('club_alpha/charlie', { uid: 'charlie', clubId: 'club_alpha', role: 'member', status: 'active', }); // Eve is inactive requested member clubMembersStore.set('club_alpha/eve', { uid: 'eve', clubId: 'club_alpha', role: 'member', status: 'requested', }); }); const createRequest = (clubId: string, authHeader?: string) => { const headers: Record = {}; if (authHeader !== undefined) { headers['authorization'] = authHeader; } return new NextRequest(`https://motorsignal.app/api/clubs/${clubId}/rides`, { method: 'GET', headers, }); }; // ============================================================ // 1. Authentication Gates (401) // ============================================================ it('returns 401 when Authorization header is missing', async () => { const req = createRequest('club_alpha'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe('unauthorized'); expect(body.detail).toContain('Missing or malformed Authorization header'); }); it('returns 401 when Authorization header does not start with Bearer', async () => { const req = createRequest('club_alpha', 'Basic some_creds'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe('unauthorized'); }); it('returns 401 when token verification fails or is expired', async () => { const req = createRequest('club_alpha', 'Bearer invalid_or_expired_token'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe('unauthorized'); expect(body.detail).toContain('Invalid or expired authentication token'); }); // ============================================================ // 2. Club ID Validation (400) // ============================================================ it('returns 400 for empty or whitespace clubId', async () => { const req = createRequest(' ', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: ' ' }) }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toBe('invalid_request'); }); it('returns 400 for clubId with whitespace inside', async () => { const req = createRequest('club alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club alpha' }) }); expect(res.status).toBe(400); }); it('returns 400 for clubId exceeding 64 characters', async () => { const longId = 'a'.repeat(65); const req = createRequest(longId, 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: longId }) }); expect(res.status).toBe(400); }); // ============================================================ // 3. Club Existence & Status (404) // ============================================================ it('returns 404 when club does not exist', async () => { const req = createRequest('club_nonexistent', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_nonexistent' }) }); expect(res.status).toBe(404); const body = await res.json(); expect(body.error).toBe('not_found'); expect(body.detail).toContain('Club not found'); }); it('returns 404 when club is archived', async () => { clubsStore.set('club_archived', { id: 'club_archived', name: 'Archived Club', archivedAt: '2026-09-01T00:00:00Z', }); const req = createRequest('club_archived', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_archived' }) }); expect(res.status).toBe(404); const body = await res.json(); expect(body.error).toBe('not_found'); expect(body.detail).toContain('archived'); }); // ============================================================ // 4. Membership Authorization (403 vs 200) // ============================================================ it('returns 403 when authenticated user is not a club member', async () => { // Dave has valid token but is not in club_alpha/members const req = createRequest('club_alpha', 'Bearer valid_token_dave'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe('forbidden'); expect(body.detail).toContain('Active club membership required'); }); it('returns 403 when authenticated user is an inactive club member', async () => { // Eve is in club_alpha/members but status is 'requested' mockAuth.verifyIdToken.mockResolvedValueOnce({ uid: 'eve' }); const req = createRequest('club_alpha', 'Bearer valid_token_eve'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe('forbidden'); }); it('returns 200 with empty list when club has no upcoming rides', async () => { const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.clubId).toBe('club_alpha'); expect(body.rides).toEqual([]); }); // ============================================================ // 5. Server-Side Mutual-Block Enforcement // ============================================================ it('excludes rides when caller blocks organizer, organizer blocks caller, and includes unblocked rides', async () => { // Seed 4 rides: // 1. ride_alice organized by Alice (scheduled) // 2. ride_bob organized by Bob (assembling) // 3. ride_charlie organized by Charlie (scheduled, self) // 4. ride_foreign organized by Frank (scheduled) groupRidesStore.push( { id: 'ride_alice', data: { id: 'ride_alice', clubId: 'club_alpha', organizerUid: 'alice', title: 'Alice Mountain Ride', status: 'scheduled', scheduledStartAt: '2026-09-25T10:00:00Z', accessUids: ['alice'], participantCount: 1, meetingPoint: { displayName: 'North Gap', latitude: 34.5, longitude: -83.9 }, }, }, { id: 'ride_bob', data: { id: 'ride_bob', clubId: 'club_alpha', organizerUid: 'bob', title: 'Bob Valley Loop', status: 'assembling', scheduledStartAt: '2026-09-25T11:00:00Z', accessUids: ['bob'], participantCount: 1, meetingPoint: { displayName: 'South Gap', latitude: 34.6, longitude: -83.8 }, }, }, { id: 'ride_charlie', data: { id: 'ride_charlie', clubId: 'club_alpha', organizerUid: 'charlie', title: 'Charlie Afternoon Cruise', status: 'scheduled', scheduledStartAt: '2026-09-25T14:00:00Z', accessUids: ['charlie'], participantCount: 1, meetingPoint: { displayName: 'Town Square', latitude: 34.7, longitude: -83.7 }, }, } ); // Charlie blocks Alice userBlocksStore.set('charlie', new Set(['alice'])); // Bob blocks Charlie (reverse block) userBlocksStore.set('bob', new Set(['charlie'])); const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); // Alice ride must be EXCLUDED (caller blocks organizer) // Bob ride must be EXCLUDED (organizer blocks caller) // Charlie ride must be INCLUDED (caller is organizer, no mutual block) expect(body.rides.length).toBe(1); expect(body.rides[0].rideId).toBe('ride_charlie'); expect(body.rides[0].title).toBe('Charlie Afternoon Cruise'); }); // ============================================================ // 6. Status Filtering (scheduled & assembling only) // ============================================================ it('includes scheduled and assembling rides; excludes underway, completed, and canceled rides', async () => { groupRidesStore.push( { id: 'ride_sched', data: { id: 'ride_sched', clubId: 'club_alpha', organizerUid: 'alice', title: 'Scheduled Ride', status: 'scheduled', scheduledStartAt: '2026-09-25T09:00:00Z', }, }, { id: 'ride_assem', data: { id: 'ride_assem', clubId: 'club_alpha', organizerUid: 'alice', title: 'Assembling Ride', status: 'assembling', scheduledStartAt: '2026-09-25T09:30:00Z', }, }, { id: 'ride_underway', data: { id: 'ride_underway', clubId: 'club_alpha', organizerUid: 'alice', title: 'Underway Ride', status: 'underway', scheduledStartAt: '2026-09-25T10:00:00Z', }, }, { id: 'ride_completed', data: { id: 'ride_completed', clubId: 'club_alpha', organizerUid: 'alice', title: 'Completed Ride', status: 'completed', scheduledStartAt: '2026-09-25T11:00:00Z', }, }, { id: 'ride_canceled', data: { id: 'ride_canceled', clubId: 'club_alpha', organizerUid: 'alice', title: 'Canceled Ride', status: 'canceled', scheduledStartAt: '2026-09-25T12:00:00Z', }, } ); const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); const returnedIds = body.rides.map((r: any) => r.rideId); expect(returnedIds).toContain('ride_sched'); expect(returnedIds).toContain('ride_assem'); expect(returnedIds).not.toContain('ride_underway'); expect(returnedIds).not.toContain('ride_completed'); expect(returnedIds).not.toContain('ride_canceled'); expect(returnedIds.length).toBe(2); }); // ============================================================ // 7. Response Data Minimization & Physical-Location Privacy // ============================================================ it('strictly minimizes response data: excludes accessUids, exact coordinates, full route geometry, and live presence', async () => { groupRidesStore.push({ id: 'ride_minimized', data: { id: 'ride_minimized', clubId: 'club_alpha', organizerUid: 'alice', organizerDisplayName: 'Alice W.', organizerHandle: 'alice_rider', title: 'Safe Scenic Cruise', description: 'Casual pace cruise through foothills', status: 'scheduled', scheduledStartAt: '2026-09-26T10:00:00Z', departureAt: '2026-09-26T10:30:00Z', joinPolicy: 'requestToJoin', participantCount: 3, participantLimit: 15, accessUids: ['alice', 'rider2', 'rider3'], // SENSITIVE meetingPoint: { displayName: 'Dahlonega Town Square', address: '1 Public Square, Dahlonega, GA', latitude: 34.5328, // SENSITIVE longitude: -83.9849, // SENSITIVE locationType: 'meetup', }, routeId: 'private_route_internal_123', // SENSITIVE routeTitle: 'Foothills Run', routeDistanceMeters: 45000, routeStopCount: 2, paceClassification: 'Casual', live_presence: { alice: { lat: 34.5, lon: -83.9 } }, // SENSITIVE }, }); const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.rides.length).toBe(1); const ride = body.rides[0]; // Allowed minimized fields expect(ride.rideId).toBe('ride_minimized'); expect(ride.clubId).toBe('club_alpha'); expect(ride.title).toBe('Safe Scenic Cruise'); expect(ride.description).toBe('Casual pace cruise through foothills'); expect(ride.organizerDisplayName).toBe('Alice W.'); expect(ride.organizerHandle).toBe('alice_rider'); expect(ride.scheduledStartAt).toBe('2026-09-26T10:00:00Z'); expect(ride.departureAt).toBe('2026-09-26T10:30:00Z'); expect(ride.joinPolicy).toBe('requestToJoin'); expect(ride.participantCount).toBe(3); expect(ride.participantLimit).toBe(15); expect(ride.status).toBe('scheduled'); expect(ride.meetingLocationLabel).toBe('Dahlonega Town Square'); expect(ride.routeTitle).toBe('Foothills Run'); expect(ride.routeDistanceMeters).toBe(45000); expect(ride.paceClassification).toBe('Casual'); // Strictly forbidden fields expect((ride as any).organizerUid).toBeUndefined(); expect((ride as any).accessUids).toBeUndefined(); expect((ride as any).latitude).toBeUndefined(); expect((ride as any).longitude).toBeUndefined(); expect((ride as any).meetingPoint).toBeUndefined(); expect((ride as any).routeId).toBeUndefined(); expect((ride as any).waypoints).toBeUndefined(); expect((ride as any).routePolyline).toBeUndefined(); expect((ride as any).live_presence).toBeUndefined(); expect((ride as any).telemetry).toBeUndefined(); expect((ride as any).stops).toBeUndefined(); }); it('location privacy: returns safe displayName and never leaks raw formatted address or coordinates', async () => { groupRidesStore.push({ id: 'ride_loc_safe', data: { id: 'ride_loc_safe', clubId: 'club_alpha', organizerUid: 'alice', title: 'Mountain Ridge Loop', status: 'scheduled', scheduledStartAt: '2026-09-27T10:00:00Z', meetingPoint: { displayName: 'Dahlonega Square', address: '13 South Park Street, Dahlonega, GA 30533', latitude: 34.5328, longitude: -83.9849, }, }, }); const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); const ride = body.rides.find((r: any) => r.rideId === 'ride_loc_safe'); expect(ride).toBeDefined(); // Safe display name is returned expect(ride.meetingLocationLabel).toBe('Dahlonega Square'); // Formatted address and coordinates must not appear anywhere in serialized response const jsonStr = JSON.stringify(body); expect(jsonStr).not.toContain('13 South Park Street'); expect(jsonStr).not.toContain('34.5328'); expect(jsonStr).not.toContain('-83.9849'); expect((ride as any).meetingPoint).toBeUndefined(); expect((ride as any).latitude).toBeUndefined(); expect((ride as any).longitude).toBeUndefined(); }); it('location privacy: returns null meetingLocationLabel when displayName is missing and does not fall back to address', async () => { groupRidesStore.push({ id: 'ride_loc_no_name', data: { id: 'ride_loc_no_name', clubId: 'club_alpha', organizerUid: 'alice', title: 'Secret Meetup Run', status: 'scheduled', scheduledStartAt: '2026-09-27T11:00:00Z', meetingPoint: { displayName: null, address: '123 Example Street, Example City, GA', latitude: 33.7490, longitude: -84.3880, }, }, }); const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); const ride = body.rides.find((r: any) => r.rideId === 'ride_loc_no_name'); expect(ride).toBeDefined(); // When displayName is missing, meetingLocationLabel MUST be null expect(ride.meetingLocationLabel).toBeNull(); // Raw address and coordinates must NOT appear anywhere in the serialized response const jsonStr = JSON.stringify(body); expect(jsonStr).not.toContain('123 Example Street'); expect(jsonStr).not.toContain('33.7490'); expect(jsonStr).not.toContain('-84.3880'); expect((ride as any).meetingPoint).toBeUndefined(); expect((ride as any).latitude).toBeUndefined(); expect((ride as any).longitude).toBeUndefined(); }); it('pagination through blocks: pages candidate rides until 30 eligible rides collected or exhausted', async () => { // Charlie blocks a high-volume organizer userBlocksStore.set('charlie', new Set(['blocked_organizer'])); // Seed 40 scheduled rides: first 15 from blocked_organizer, next 25 from alice for (let i = 0; i < 15; i++) { groupRidesStore.push({ id: `ride_blocked_${i.toString().padStart(2, '0')}`, data: { id: `ride_blocked_${i.toString().padStart(2, '0')}`, clubId: 'club_alpha', organizerUid: 'blocked_organizer', title: `Blocked Ride ${i}`, status: 'scheduled', scheduledStartAt: `2026-09-25T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`, }, }); } for (let i = 0; i < 25; i++) { groupRidesStore.push({ id: `ride_alice_${i.toString().padStart(2, '0')}`, data: { id: `ride_alice_${i.toString().padStart(2, '0')}`, clubId: 'club_alpha', organizerUid: 'alice', title: `Alice Ride ${i}`, status: 'scheduled', scheduledStartAt: `2026-09-26T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`, }, }); } const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); // Must return all 25 unblocked rides from Alice (paged past the 15 blocked rides) expect(body.rides.length).toBe(25); for (const r of body.rides) { expect(r.rideId).toMatch(/^ride_alice_/); } }); it('pagination continues past 150 blocked candidates until 30 eligible rides found or exhausted', async () => { // Charlie blocks a spammy/blocked organizer userBlocksStore.set('charlie', new Set(['heavy_blocked_organizer'])); // Seed 160 scheduled rides from heavy_blocked_organizer for (let i = 0; i < 160; i++) { groupRidesStore.push({ id: `ride_blocked_dense_${i.toString().padStart(3, '0')}`, data: { id: `ride_blocked_dense_${i.toString().padStart(3, '0')}`, clubId: 'club_alpha', organizerUid: 'heavy_blocked_organizer', title: `Blocked Ride ${i}`, status: 'scheduled', scheduledStartAt: `2026-09-25T${(10 + Math.floor(i / 60)).toString().padStart(2, '0')}:${((i % 60)).toString().padStart(2, '0')}:00Z`, }, }); } // Seed 30 scheduled rides from unblocked organizer alice for (let i = 0; i < 30; i++) { groupRidesStore.push({ id: `ride_alice_dense_${i.toString().padStart(2, '0')}`, data: { id: `ride_alice_dense_${i.toString().padStart(2, '0')}`, clubId: 'club_alpha', organizerUid: 'alice', title: `Alice Ride ${i}`, status: 'scheduled', scheduledStartAt: `2026-09-28T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`, }, }); } const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); expect(res.status).toBe(200); const body = await res.json(); // Must page past all 160 blocked rides and collect all 30 eligible rides from Alice expect(body.rides.length).toBe(30); for (const r of body.rides) { expect(r.rideId).toMatch(/^ride_alice_dense_/); } }); });