Files
hackerspace.sh/__tests__/invite-rearm.test.ts
T
Moheeb ZaraandClaude Opus 4.8 7fe3ffc632 fix(invites): block re-arming an admin invite without role change (M1)
updateInvite ran the canAssignInviteRole guard only when the payload included
'role', so a board member could re-enable/extend an exhausted admin-granting
invite (is_enabled/max_uses/expires_at) and distribute admin access they cannot
mint. Load the existing invite and require canAssignInviteRole against its
effective role whenever any privilege-affecting field is (re-)armed.

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

153 lines
4.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
/**
* Regression: a board member must not be able to re-arm an admin-granting
* invite by updating privilege-affecting fields (is_enabled / max_uses /
* expires_at) WITHOUT touching `role`. Because updateInviteSchema makes `role`
* optional, the old role gate ran only when `role` was in the payload, so
* `updateInvite(adminInviteId, { is_enabled: true, max_uses: 5 })` sailed
* through and re-distributed admin access the board caller could never mint.
*
* These drive the real action through a recording mock Supabase client that
* scripts the requireMember lookup and the existing-invite load, then assert
* whether the tenant-scoped UPDATE reached the 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 })
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', () => ({
createAdminClient: () => makeClient({ user: null, queue: [] }),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
import { updateInvite } from '@/lib/actions/invites'
const INVITE_ID = '22222222-2222-2222-2222-222222222222'
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: 'Caller',
handle: 'caller',
})
/** An exhausted + disabled admin invite in space-A. */
const adminInvite = {
role: 'admin',
is_enabled: false,
max_uses: 1,
expires_at: null,
}
const memberInvite = {
role: 'member',
is_enabled: false,
max_uses: 1,
expires_at: null,
}
const eqCalls = (c: ReturnType<typeof makeClient>) =>
c._calls.filter(x => x.method === 'eq').map(x => x.args)
const updateReached = (c: ReturnType<typeof makeClient>) =>
c._calls.some(x => x.method === 'update')
beforeEach(() => {
vi.clearAllMocks()
})
describe('updateInvite privilege re-arm guard', () => {
it('blocks a BOARD member from re-enabling/extending an admin invite with no role field', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('board') }, // requireMemberWithRole
{ data: adminInvite }, // existing-invite load (maybeSingle)
],
})
const res = await updateInvite(INVITE_ID, { is_enabled: true, max_uses: 5 })
expect(res).toHaveProperty('error')
expect((res as { error: string }).error).toMatch(/admin/i)
// The re-arming UPDATE must never reach the database.
expect(updateReached(currentClient)).toBe(false)
})
it('allows an ADMIN member to re-enable/extend an admin invite', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('admin') }, // requireMemberWithRole
{ data: adminInvite }, // existing-invite load
{ error: null }, // update
],
})
const res = await updateInvite(INVITE_ID, { is_enabled: true, max_uses: 5 })
expect(res).not.toHaveProperty('error')
expect(updateReached(currentClient)).toBe(true)
// Still tenant-scoped.
expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A'])
})
it('lets a BOARD member still edit a member-role invite', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('board') }, // requireMemberWithRole
{ data: memberInvite }, // existing-invite load
{ error: null }, // update
],
})
const res = await updateInvite(INVITE_ID, { is_enabled: true, max_uses: 5 })
expect(res).not.toHaveProperty('error')
expect(updateReached(currentClient)).toBe(true)
expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A'])
})
})