426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
import { GET } from '../app/api/group-rides/[rideId]/join-requests/route';
|
|
import { getAdminAuth, getAdminFirestore } from '../lib/firebase-admin';
|
|
|
|
vi.mock('../lib/firebase-admin', () => ({
|
|
getAdminAuth: vi.fn(),
|
|
getAdminFirestore: vi.fn(),
|
|
}));
|
|
|
|
describe('GroupRide Join Requests Moderation API (GET /api/group-rides/[rideId]/join-requests)', () => {
|
|
let mockAuth: any;
|
|
let mockFirestore: any;
|
|
|
|
let groupRidesStore: Map<string, any>;
|
|
let participantsStore: Map<string, any[]>; // key: rideId -> array of participant docs { id, data }
|
|
let clubsStore: Map<string, any>;
|
|
let clubMembersStore: Map<string, any>; // key: `${clubId}/${uid}`
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
|
|
groupRidesStore = new Map();
|
|
participantsStore = new Map();
|
|
clubsStore = new Map();
|
|
clubMembersStore = new Map();
|
|
|
|
mockAuth = {
|
|
verifyIdToken: vi.fn(async (token: string) => {
|
|
if (token === 'token_alice_organizer') {
|
|
return { uid: 'alice' };
|
|
}
|
|
if (token === 'token_bob_admin') {
|
|
return { uid: 'bob' };
|
|
}
|
|
if (token === 'token_owner') {
|
|
return { uid: 'club_owner_user' };
|
|
}
|
|
if (token === 'token_charlie_member') {
|
|
return { uid: 'charlie' };
|
|
}
|
|
if (token === 'token_dave_outsider') {
|
|
return { uid: 'dave' };
|
|
}
|
|
if (token === 'token_eve_inactive') {
|
|
return { uid: 'eve' };
|
|
}
|
|
throw new Error('Invalid token');
|
|
}),
|
|
};
|
|
|
|
mockFirestore = {
|
|
collection: (colName: string) => {
|
|
if (colName === 'group_rides') {
|
|
return {
|
|
doc: (rideId: string) => ({
|
|
get: async () => {
|
|
const data = groupRidesStore.get(rideId);
|
|
return {
|
|
exists: !!data,
|
|
data: () => data,
|
|
};
|
|
},
|
|
collection: (subCol: string) => {
|
|
if (subCol === 'participants') {
|
|
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 }),
|
|
get: async () => {
|
|
const all = participantsStore.get(rideId) || [];
|
|
let results = all.filter((p) => {
|
|
if (filters.status && filters.status.op === '==' && p.data.status !== filters.status.val) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (filters.orderBy) {
|
|
results = [...results].sort((a, b) => {
|
|
const valA = a.data[filters.orderBy.field] || '';
|
|
const valB = b.data[filters.orderBy.field] || '';
|
|
if (valA < valB) return filters.orderBy.dir === 'desc' ? 1 : -1;
|
|
if (valA > valB) return filters.orderBy.dir === 'desc' ? -1 : 1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
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 subcollection: ${subCol}`);
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
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}`);
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected collection: ${colName}`);
|
|
},
|
|
};
|
|
|
|
(getAdminAuth as any).mockReturnValue(mockAuth);
|
|
(getAdminFirestore as any).mockReturnValue(mockFirestore);
|
|
|
|
// Setup club_alpha
|
|
clubsStore.set('club_alpha', { id: 'club_alpha', name: 'Alpha Riders' });
|
|
|
|
// Club memberships
|
|
clubMembersStore.set('club_alpha/club_owner_user', {
|
|
uid: 'club_owner_user',
|
|
role: 'owner',
|
|
status: 'active',
|
|
});
|
|
clubMembersStore.set('club_alpha/bob', {
|
|
uid: 'bob',
|
|
role: 'admin',
|
|
status: 'active',
|
|
});
|
|
clubMembersStore.set('club_alpha/charlie', {
|
|
uid: 'charlie',
|
|
role: 'member',
|
|
status: 'active',
|
|
});
|
|
clubMembersStore.set('club_alpha/eve', {
|
|
uid: 'eve',
|
|
role: 'admin',
|
|
status: 'suspended',
|
|
});
|
|
|
|
// Seed ride_club
|
|
groupRidesStore.set('ride_club', {
|
|
id: 'ride_club',
|
|
organizerUid: 'alice',
|
|
clubId: 'club_alpha',
|
|
title: 'Saturday Club Run',
|
|
status: 'scheduled',
|
|
});
|
|
|
|
// Seed ride_personal
|
|
groupRidesStore.set('ride_personal', {
|
|
id: 'ride_personal',
|
|
organizerUid: 'alice',
|
|
clubId: null,
|
|
title: 'Alice Solo Friends Run',
|
|
status: 'scheduled',
|
|
});
|
|
});
|
|
|
|
const createRequest = (rideId: string, authHeader?: string) => {
|
|
const headers: Record<string, string> = {};
|
|
if (authHeader !== undefined) {
|
|
headers['authorization'] = authHeader;
|
|
}
|
|
return new NextRequest(`https://motorsignal.app/api/group-rides/${rideId}/join-requests`, {
|
|
method: 'GET',
|
|
headers,
|
|
});
|
|
};
|
|
|
|
// ============================================================
|
|
// 1. Authentication Gates (401)
|
|
// ============================================================
|
|
it('returns 401 when Authorization header is missing', async () => {
|
|
const req = createRequest('ride_club');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
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('ride_club', 'Basic invalid');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(401);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('unauthorized');
|
|
});
|
|
|
|
it('returns 401 when token verification fails', async () => {
|
|
const req = createRequest('ride_club', 'Bearer invalid_or_expired');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
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. Ride ID Validation (400)
|
|
// ============================================================
|
|
it('returns 400 for empty or whitespace rideId', async () => {
|
|
const req = createRequest(' ', 'Bearer token_alice_organizer');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: ' ' }) });
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('invalid_request');
|
|
});
|
|
|
|
it('returns 400 for rideId containing whitespace', async () => {
|
|
const req = createRequest('ride 123', 'Bearer token_alice_organizer');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride 123' }) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 400 for rideId exceeding 64 characters', async () => {
|
|
const longId = 'r'.repeat(65);
|
|
const req = createRequest(longId, 'Bearer token_alice_organizer');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: longId }) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
// ============================================================
|
|
// 3. Existence Gate (404)
|
|
// ============================================================
|
|
it('returns 404 when group ride does not exist', async () => {
|
|
const req = createRequest('nonexistent_ride', 'Bearer token_alice_organizer');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'nonexistent_ride' }) });
|
|
expect(res.status).toBe(404);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('not_found');
|
|
expect(body.detail).toContain('Group ride not found');
|
|
});
|
|
|
|
// ============================================================
|
|
// 4. Authorization Gates (403 vs 200)
|
|
// ============================================================
|
|
it('returns 403 when caller is ordinary club member (not organizer or admin)', async () => {
|
|
const req = createRequest('ride_club', 'Bearer token_charlie_member');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('forbidden');
|
|
expect(body.detail).toContain('Only the organizer or a club administrator');
|
|
});
|
|
|
|
it('returns 403 when caller is non-member on club ride', async () => {
|
|
const req = createRequest('ride_club', 'Bearer token_dave_outsider');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('forbidden');
|
|
});
|
|
|
|
it('returns 403 when club admin role is inactive/suspended', async () => {
|
|
const req = createRequest('ride_club', 'Bearer token_eve_inactive');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('forbidden');
|
|
});
|
|
|
|
it('returns 403 when caller is non-organizer on personal ride (no club)', async () => {
|
|
const req = createRequest('ride_personal', 'Bearer token_bob_admin');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_personal' }) });
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toBe('forbidden');
|
|
});
|
|
|
|
it('returns 200 when caller is ride organizer (personal ride)', async () => {
|
|
const req = createRequest('ride_personal', 'Bearer token_alice_organizer');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_personal' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.rideId).toBe('ride_personal');
|
|
expect(body.requests).toEqual([]);
|
|
});
|
|
|
|
it('returns 200 when caller is ride organizer (club ride)', async () => {
|
|
const req = createRequest('ride_club', 'Bearer token_alice_organizer');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.rideId).toBe('ride_club');
|
|
expect(body.requests).toEqual([]);
|
|
});
|
|
|
|
it('returns 200 when caller is active club admin (role admin)', async () => {
|
|
const req = createRequest('ride_club', 'Bearer token_bob_admin');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.rideId).toBe('ride_club');
|
|
expect(body.requests).toEqual([]);
|
|
});
|
|
|
|
it('returns 200 when caller is active club owner (role owner)', async () => {
|
|
const req = createRequest('ride_club', 'Bearer token_owner');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.rideId).toBe('ride_club');
|
|
});
|
|
|
|
// ============================================================
|
|
// 5. Query Filtering, Ordering, Bounds, and Data Minimization
|
|
// ============================================================
|
|
it('filters only requested status, orders by createdAt asc, limits to 50, and minimizes DTO', async () => {
|
|
const participantsList = [
|
|
{
|
|
id: 'user_req_2',
|
|
data: {
|
|
userId: 'user_req_2',
|
|
role: 'participant',
|
|
status: 'requested',
|
|
createdAt: '2026-09-25T11:00:00Z',
|
|
riderDisplayName: 'Rider Two',
|
|
riderHandle: 'rider2',
|
|
riderAvatarPreset: 'preset_helmet',
|
|
riderAvatarUrl: null,
|
|
latitude: 34.123, // SENSITIVE
|
|
live_presence: { lat: 34.123 }, // SENSITIVE
|
|
},
|
|
},
|
|
{
|
|
id: 'user_accepted',
|
|
data: {
|
|
userId: 'user_accepted',
|
|
role: 'participant',
|
|
status: 'accepted',
|
|
createdAt: '2026-09-25T09:00:00Z',
|
|
},
|
|
},
|
|
{
|
|
id: 'user_declined',
|
|
data: {
|
|
userId: 'user_declined',
|
|
role: 'participant',
|
|
status: 'declined',
|
|
createdAt: '2026-09-25T09:30:00Z',
|
|
},
|
|
},
|
|
{
|
|
id: 'user_req_1',
|
|
data: {
|
|
userId: 'user_req_1',
|
|
role: 'participant',
|
|
status: 'requested',
|
|
createdAt: '2026-09-25T10:00:00Z',
|
|
riderDisplayName: 'Rider One',
|
|
riderHandle: 'rider1',
|
|
riderAvatarPreset: null,
|
|
riderAvatarUrl: 'https://example.com/avatar.jpg',
|
|
},
|
|
},
|
|
];
|
|
|
|
participantsStore.set('ride_club', participantsList);
|
|
|
|
const req = createRequest('ride_club', 'Bearer token_alice_organizer');
|
|
const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) });
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Cache-Control')).toBe('no-store, max-age=0');
|
|
|
|
const body = await res.json();
|
|
expect(body.rideId).toBe('ride_club');
|
|
expect(body.requests.length).toBe(2);
|
|
|
|
// Verified ordered by createdAt ASC: user_req_1 (10:00) then user_req_2 (11:00)
|
|
expect(body.requests[0].userId).toBe('user_req_1');
|
|
expect(body.requests[0].riderDisplayName).toBe('Rider One');
|
|
expect(body.requests[0].riderHandle).toBe('rider1');
|
|
expect(body.requests[0].riderAvatarUrl).toBe('https://example.com/avatar.jpg');
|
|
expect(body.requests[0].createdAt).toBe('2026-09-25T10:00:00Z');
|
|
|
|
expect(body.requests[1].userId).toBe('user_req_2');
|
|
expect(body.requests[1].riderDisplayName).toBe('Rider Two');
|
|
expect(body.requests[1].riderAvatarPreset).toBe('preset_helmet');
|
|
expect(body.requests[1].createdAt).toBe('2026-09-25T11:00:00Z');
|
|
|
|
// Data minimization verification: sensitive fields must not exist
|
|
expect((body.requests[1] as any).latitude).toBeUndefined();
|
|
expect((body.requests[1] as any).live_presence).toBeUndefined();
|
|
const rawJson = JSON.stringify(body);
|
|
expect(rawJson).not.toContain('34.123');
|
|
expect(rawJson).not.toContain('user_accepted');
|
|
expect(rawJson).not.toContain('user_declined');
|
|
});
|
|
});
|