MotorSignal-App/website/tests/club-rides-discovery.test.ts

726 lines
27 KiB
TypeScript

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<string, any>;
let clubMembersStore: Map<string, any>; // key: `${clubId}/${uid}`
let userBlocksStore: Map<string, Set<string>>; // 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<string, string> = {};
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_/);
}
});
});