Files
hackerspace.sh/__tests__/actions.test.ts
T
Moheeb ZaraandClaude Opus 4.8 d18d6acaff test: replace tautological actions.test with real authz/IDOR tests
The old file asserted on string literals it defined itself (e.g.
expect(".eq('space_id', ...)").toContain('space_id')) and never invoked an
action, so it stayed green regardless of the code — false confidence on exactly
the authz/scoping it claimed to cover.

Rewrite drives the real updateMember/removeMember/logCashPayment through a
recording mock Supabase client and asserts: unauthenticated + non-privileged
callers are rejected with no write; admin/treasurer writes are pinned to the
caller's own space_id (the IDOR guard). Removing a role gate or a space_id
scope now fails a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 21:23:04 -07:00

170 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
/**
* Real server-action authorization + tenant-scoping tests.
*
* The previous version of this file asserted on string literals it defined
* itself (e.g. `expect(".eq('space_id', ...)").toContain('space_id')`) and
* never invoked a single action — it stayed green no matter what the code did.
* These tests drive the ACTUAL actions through a recording mock Supabase client
* and assert the authz gate and the `.eq('space_id', …)` scoping that stop
* cross-tenant (IDOR) writes. Remove the scope or the role gate in a real
* action and one of these fails.
*/
// ─── Recording mock Supabase client ──────────────────────────────────────────
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 })
// A single chainable/awaitable recorder shared across every query on this
// client. Chain methods record their args and return the builder; terminal
// `.single()/.maybeSingle()` and awaiting the chain pull the next queued
// result, so a test scripts results in call order.
const builder: Record<string, unknown> = 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
},
_calls: calls,
}
return client
}
let currentClient: ReturnType<typeof makeClient>
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(currentClient),
}))
vi.mock('@/lib/supabase/admin', () => ({
// Permissive stand-in; the tests below avoid code paths that use it.
createAdminClient: () => makeClient({ user: null, queue: [] }),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
import { updateMember, removeMember } from '@/lib/actions/members'
import { logCashPayment } from '@/lib/actions/payments'
const UUID = '11111111-1111-1111-1111-111111111111'
const user = { id: 'user-A', email: '[email protected]' }
const memberRow = (role: string, space_id = 'space-A') => ({
id: 'member-A',
space_id,
user_id: user.id,
role,
status: 'current',
display_name: 'Admin A',
handle: 'admin-a',
})
/** Find recorded `.eq(col, val)` calls. */
const eqCalls = (c: ReturnType<typeof makeClient>) =>
c._calls.filter(x => x.method === 'eq').map(x => x.args)
beforeEach(() => {
vi.clearAllMocks()
})
describe('server-action authorization', () => {
it('rejects an unauthenticated caller and performs no write', async () => {
currentClient = makeClient({ user: null, queue: [] })
const res = await updateMember(UUID, { display_name: 'x' })
expect(res).toHaveProperty('error')
// No space_members UPDATE reached the client (only the auth lookup, if any).
expect(eqCalls(currentClient).some(a => a[0] === 'space_id')).toBe(false)
})
it('rejects a non-privileged member from updating members', async () => {
currentClient = makeClient({ user, queue: [{ data: memberRow('member') }] })
const res = await updateMember(UUID, { display_name: 'x' })
expect(res).toHaveProperty('error')
expect((res as { error: string }).error).toMatch(/admin/i)
expect(eqCalls(currentClient).some(a => a[0] === 'space_id')).toBe(false)
})
it('rejects a board member from REMOVING a member (admin-only)', async () => {
currentClient = makeClient({ user, queue: [{ data: memberRow('board') }] })
const res = await removeMember(UUID)
expect(res).toHaveProperty('error')
expect(eqCalls(currentClient).some(a => a[0] === 'space_id')).toBe(false)
})
it('rejects a plain member from logging a cash payment (treasurer-gated)', async () => {
currentClient = makeClient({ user, queue: [{ data: memberRow('member') }] })
const res = await logCashPayment({ amount: 10, from_note: 'cash' })
expect(res).toHaveProperty('error')
})
})
describe('server-action tenant scoping (IDOR guard)', () => {
it('scopes an admin updateMember to the caller’s space_id', async () => {
currentClient = makeClient({
user,
queue: [{ data: memberRow('admin', 'space-A') }, { error: null }],
})
// Attacker-style call: a valid member id that might belong to another space.
const res = await updateMember(UUID, { display_name: 'x' })
expect(res).not.toHaveProperty('error')
// The UPDATE must be pinned to the caller's own space, so a row in another
// space can never be touched.
expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A'])
expect(eqCalls(currentClient)).toContainEqual(['id', UUID])
})
it('scopes an admin removeMember to the caller’s space_id', async () => {
currentClient = makeClient({
user,
queue: [{ data: memberRow('admin', 'space-A') }, { error: null }],
})
const res = await removeMember(UUID)
expect(res).not.toHaveProperty('error')
expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A'])
})
it('stamps a treasurer cash payment with the caller’s space_id', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('treasurer', 'space-A') }, // requireMember
{ data: { id: 'pay-1' } }, // payments insert().select().single()
{ error: null }, // activity_log insert
],
})
const res = await logCashPayment({ amount: 25, from_note: 'dues' })
expect(res).not.toHaveProperty('error')
const inserts = currentClient._calls.filter(x => x.method === 'insert').map(x => x.args[0])
const paymentInsert = inserts.find(
(o): o is Record<string, unknown> => !!o && typeof o === 'object' && 'platform' in o,
)
expect(paymentInsert?.space_id).toBe('space-A')
expect(paymentInsert?.platform).toBe('cash')
})
})