From 2dbd52e2e49bc09baa8212513adb5d5b460c18c8 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 4 Aug 2026 19:10:42 -0700 Subject: [PATCH] test: add authz/scope/anti-impersonation tests for the new actions Real mock-client tests for createAreaLead/updateAreaLead/deleteAreaLead (admin gate + space scope), sendMessage (identity from session not input; foreign channel rejected), and payments member-in-space rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/actions.test.ts | 175 +++++++++++++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/__tests__/actions.test.ts b/__tests__/actions.test.ts index 950f0cf..d596292 100644 --- a/__tests__/actions.test.ts +++ b/__tests__/actions.test.ts @@ -70,7 +70,9 @@ vi.mock('@/lib/supabase/admin', () => ({ vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) import { updateMember, removeMember } from '@/lib/actions/members' -import { logCashPayment } from '@/lib/actions/payments' +import { logCashPayment, linkPaymentToMember } from '@/lib/actions/payments' +import { sendMessage } from '@/lib/actions/comms' +import { createAreaLead, updateAreaLead, deleteAreaLead } from '@/lib/actions/area-leads' const UUID = '11111111-1111-1111-1111-111111111111' const user = { id: 'user-A', email: 'a@example.com' } @@ -166,4 +168,175 @@ describe('server-action tenant scoping (IDOR guard)', () => { expect(paymentInsert?.space_id).toBe('space-A') expect(paymentInsert?.platform).toBe('cash') }) + + it('rejects a cash payment whose member_id belongs to another space', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('treasurer', 'space-A') }, // requireMember + { data: null }, // space_members scope lookup: foreign member -> not found + ], + }) + const res = await logCashPayment({ amount: 25, from_note: 'dues', member_id: UUID }) + expect(res).toHaveProperty('error') + expect((res as { error: string }).error).toMatch(/not found in this space/i) + // The IDOR write must never reach the payments table. + const inserts = currentClient._calls.filter(x => x.method === 'insert').map(x => x.args[0]) + expect(inserts.some(o => !!o && typeof o === 'object' && 'platform' in (o as object))).toBe(false) + // The scope lookup was pinned to the caller's own space. + expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A']) + expect(eqCalls(currentClient)).toContainEqual(['id', UUID]) + }) + + it('rejects linking a payment to a member_id from another space', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('treasurer', 'space-A') }, // requireMember + { data: null }, // space_members scope lookup: foreign member -> not found + ], + }) + const res = await linkPaymentToMember(UUID, UUID) + expect(res).toHaveProperty('error') + expect((res as { error: string }).error).toMatch(/not found in this space/i) + // No payments UPDATE was recorded (the guard returns before the link write). + expect(currentClient._calls.some(x => x.method === 'update')).toBe(false) + expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A']) + }) +}) + +describe('comms sendMessage anti-impersonation', () => { + const CHANNEL = '22222222-2222-2222-2222-222222222222' + + it('stamps the message with session identity, ignoring client-supplied fields', async () => { + currentClient = makeClient({ + user, // user-A — the authenticated sender + queue: [ + { data: memberRow('member', 'space-A') }, // requireMember + { data: { id: CHANNEL } }, // channel scope lookup: belongs to space-A + { data: { id: 'msg-1' } }, // comms_messages insert().select().single() + { error: null }, // activity_log insert + ], + }) + // Attacker tries to spoof identity + tenant through the input. The action + // types forbid these keys, so cast through unknown to simulate a hand-rolled + // client payload; Zod strips them and identity is derived from the session. + const spoofed = { + channel_id: CHANNEL, + content: 'hello', + user_id: 'attacker', + display_name: 'Someone Else', + handle: 'imposter', + space_id: 'space-VICTIM', + } as unknown as Parameters[0] + const res = await sendMessage(spoofed) + expect(res).not.toHaveProperty('error') + + const inserts = currentClient._calls.filter(x => x.method === 'insert').map(x => x.args[0]) + const msgInsert = inserts.find( + (o): o is Record => !!o && typeof o === 'object' && 'content' in o, + ) + // Identity + tenant come from the session/member, NOT the input. + expect(msgInsert?.user_id).toBe('user-A') + expect(msgInsert?.space_id).toBe('space-A') + expect(msgInsert?.display_name).toBe('Admin A') + expect(msgInsert?.handle).toBe('admin-a') + // The spoofed values must never survive. + expect(msgInsert?.user_id).not.toBe('attacker') + expect(msgInsert?.space_id).not.toBe('space-VICTIM') + expect(msgInsert?.display_name).not.toBe('Someone Else') + }) + + it('rejects posting to a channel_id from another space and writes nothing', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('member', 'space-A') }, // requireMember + { data: null }, // channel scope lookup: foreign channel -> not found + ], + }) + const res = await sendMessage({ channel_id: CHANNEL, content: 'hello' }) + expect(res).toHaveProperty('error') + // No message ever reached comms_messages. + const inserts = currentClient._calls.filter(x => x.method === 'insert').map(x => x.args[0]) + expect(inserts.some(o => !!o && typeof o === 'object' && 'content' in (o as object))).toBe(false) + // The channel lookup was pinned to the caller's own space. + expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A']) + expect(eqCalls(currentClient)).toContainEqual(['id', CHANNEL]) + }) +}) + +describe('area-leads roster actions (admin-gated, space-scoped)', () => { + const fields = { area_name: 'Woodshop', lead_handle: 'alice', description: null } + + it('rejects a non-admin from creating an area lead and writes nothing', async () => { + currentClient = makeClient({ user, queue: [{ data: memberRow('member') }] }) + const res = await createAreaLead(fields) + expect(res).toHaveProperty('error') + expect((res as { error: string }).error).toMatch(/admin/i) + // The insert must never reach area_leads. + const inserts = currentClient._calls.filter(x => x.method === 'insert').map(x => x.args[0]) + expect(inserts.some(o => !!o && typeof o === 'object' && 'area_name' in (o as object))).toBe(false) + }) + + it('stamps a created area lead with the caller’s space_id', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('admin', 'space-A') }, // requireMemberWithRole + { data: { id: 'al-1' } }, // area_leads insert().select().single() + { error: null }, // activity_log insert + ], + }) + const res = await createAreaLead(fields) + expect(res).not.toHaveProperty('error') + const inserts = currentClient._calls.filter(x => x.method === 'insert').map(x => x.args[0]) + const leadInsert = inserts.find( + (o): o is Record => !!o && typeof o === 'object' && 'area_name' in o, + ) + // Tenant is derived from the session member, never the client. + expect(leadInsert?.space_id).toBe('space-A') + expect(leadInsert?.area_name).toBe('Woodshop') + // area_code stays null for this roster interface. + expect(leadInsert?.area_code).toBeUndefined() + }) + + it('scopes an admin updateAreaLead to the caller’s space_id and id', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('admin', 'space-A') }, // requireMemberWithRole + { data: { id: UUID } }, // area_leads update().select().single() + { error: null }, // activity_log insert + ], + }) + // Attacker-style call: a lead id that might belong to another space. + const res = await updateAreaLead(UUID, { area_name: 'Metal', lead_handle: 'bob' }) + expect(res).not.toHaveProperty('error') + expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A']) + expect(eqCalls(currentClient)).toContainEqual(['id', UUID]) + }) + + it('scopes an admin deleteAreaLead to the caller’s space_id', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('admin', 'space-A') }, // requireMemberWithRole + { error: null }, // area_leads delete({ count: 'exact' }) -> row deleted + { error: null }, // activity_log insert + ], + }) + const res = await deleteAreaLead(UUID) + expect(res).not.toHaveProperty('error') + expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A']) + expect(eqCalls(currentClient)).toContainEqual(['id', UUID]) + }) + + it('rejects a non-admin from deleting an area lead and writes nothing', async () => { + currentClient = makeClient({ user, queue: [{ data: memberRow('member') }] }) + const res = await deleteAreaLead(UUID) + expect(res).toHaveProperty('error') + // No space-scoped delete ever ran. + expect(eqCalls(currentClient).some(a => a[0] === 'space_id')).toBe(false) + }) })