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) <[email protected]>
This commit is contained in:
Moheeb Zara
2026-08-04 19:10:42 -07:00
co-authored by Claude Opus 4.8
parent 3243e4ce98
commit 2dbd52e2e4
+174 -1
View File
@@ -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: '[email protected]' }
@@ -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<typeof sendMessage>[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<string, unknown> => !!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<string, unknown> => !!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)
})
})