diff --git a/__tests__/equipment-reserve.test.ts b/__tests__/equipment-reserve.test.ts new file mode 100644 index 0000000..cf82381 --- /dev/null +++ b/__tests__/equipment-reserve.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +/** + * Cross-tenant guard for reserveEquipment's manager "book on behalf" path. + * + * When a manager passes a target `memberId`, the action must verify that member + * belongs to the caller's space (mirroring signUpForClass) BEFORE inserting a + * reservation stamped with the target's membership. Without the guard a manager + * could pass a member id from another space and create a reservation there. + * + * These drive the ACTUAL action through recording mock Supabase clients: one + * RLS client (createClient) whose results are scripted in call order, and a + * separate admin client (createAdminClient) for the space_members lookup. + */ + +type QueueItem = { data?: unknown; error?: unknown } + +function makeClient(opts: { user?: { id: string; email?: string } | null; queue?: QueueItem[] }) { + const calls: Array<{ method: string; args: unknown[] }> = [] + const queue = [...(opts.queue ?? [])] + const next = (): QueueItem => (queue.length ? queue.shift()! : { data: null, error: null }) + + const builder: Record = new Proxy( + {}, + { + get(_t, prop: string) { + if (prop === 'then') { + return (resolve: (v: QueueItem) => void) => resolve(next()) + } + if (prop === 'single' || prop === 'maybeSingle') { + return (...args: unknown[]) => { + calls.push({ method: prop, args }) + return Promise.resolve(next()) + } + } + return (...args: unknown[]) => { + calls.push({ method: prop, args }) + return builder + } + }, + }, + ) + + const client = { + auth: { + getUser: vi.fn(() => Promise.resolve({ data: { user: opts.user ?? null } })), + }, + from: (...args: unknown[]) => { + calls.push({ method: 'from', args }) + return builder + }, + // rpc returns the (thenable) builder, so `await rpc(...)` pulls the next + // queued result — used for the user_has_permission manager check. + rpc: (...args: unknown[]) => { + calls.push({ method: 'rpc', args }) + return builder + }, + _calls: calls, + } + return client +} + +let currentClient: ReturnType +let currentAdmin: ReturnType + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(currentClient), +})) +vi.mock('@/lib/supabase/admin', () => ({ + createAdminClient: () => currentAdmin, +})) +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) + +import { reserveEquipment } from '@/lib/actions/equipment' + +const user = { id: 'user-A', email: 'a@example.com' } +const managerRow = { + id: 'member-A', + space_id: 'space-A', + user_id: user.id, + role: 'manager', + status: 'current', + display_name: 'Manager A', + handle: 'manager-a', +} +const equipRow = { + id: '22222222-2222-2222-2222-222222222222', + name: 'Laser Cutter', + location: 'Lab', + space_id: 'space-A', + status: 'available', + is_active: true, + required_certification_id: null, +} +const FOREIGN_MEMBER = '33333333-3333-3333-3333-333333333333' +const baseInput = { + equipmentId: equipRow.id, + starts_at: '2026-09-01T10:00:00Z', + ends_at: '2026-09-01T11:00:00Z', +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('reserveEquipment cross-space target guard', () => { + it('rejects a manager booking for a member from another space', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: managerRow }, // requireMember (space_members .single) + { data: true }, // user_has_permission rpc -> manager + { data: equipRow }, // equipment .maybeSingle + ], + }) + // Admin client's space_members lookup finds nothing: the target belongs to + // another space (or does not exist). + currentAdmin = makeClient({ user: null, queue: [{ data: null }] }) + + const res = await reserveEquipment({ ...baseInput, memberId: FOREIGN_MEMBER }) + + expect(res).toEqual({ error: 'That member was not found in this space.' }) + // The guard ran: a space_members lookup pinned to the caller's space. + const eqArgs = currentAdmin._calls.filter(c => c.method === 'eq').map(c => c.args) + expect(eqArgs).toContainEqual(['id', FOREIGN_MEMBER]) + expect(eqArgs).toContainEqual(['space_id', 'space-A']) + // No reservation was inserted. + expect(currentAdmin._calls.some(c => c.method === 'insert')).toBe(false) + }) +}) diff --git a/lib/actions/equipment.ts b/lib/actions/equipment.ts index 9e1a0ed..19d0993 100644 --- a/lib/actions/equipment.ts +++ b/lib/actions/equipment.ts @@ -257,6 +257,20 @@ export async function reserveEquipment(input: unknown) { if (!equip) return { error: 'Equipment not found' } const admin = createAdminClient() + + // When booking on someone else's behalf, the target must belong to the + // caller's space, mirroring signUpForClass. Without this a manager could + // pass a member id from another space and stamp a reservation there. + if (targetMemberId !== member.id) { + const { data: tgt } = await admin + .from('space_members') + .select('id') + .eq('id', targetMemberId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!tgt) return { error: 'That member was not found in this space.' } + } + const { data: existing } = await admin .from('equipment_reservations') .select('starts_at, ends_at, status')