From 7fe3ffc632c7fcdabd589d19e790f8829d068da2 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 1 Aug 2026 21:31:51 -0700 Subject: [PATCH] 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) --- __tests__/invite-rearm.test.ts | 152 +++++++++++++++++++++++++++++++++ lib/actions/invites.ts | 43 +++++++++- 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 __tests__/invite-rearm.test.ts diff --git a/__tests__/invite-rearm.test.ts b/__tests__/invite-rearm.test.ts new file mode 100644 index 0000000..ac7c089 --- /dev/null +++ b/__tests__/invite-rearm.test.ts @@ -0,0 +1,152 @@ +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 = 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 + +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: 'a@example.com' } + +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) => + c._calls.filter(x => x.method === 'eq').map(x => x.args) +const updateReached = (c: ReturnType) => + 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']) + }) +}) diff --git a/lib/actions/invites.ts b/lib/actions/invites.ts index 587a899..3c59720 100644 --- a/lib/actions/invites.ts +++ b/lib/actions/invites.ts @@ -8,6 +8,22 @@ import { canAssignInviteRole } from '@/lib/invite-logic' const INVITE_ADMIN_ROLES = ['admin', 'board'] as const +// Whether `next` lets the invite be redeemed more times than `current`. +// null = unlimited, which is wider than any finite cap. +function widensUses(current: number | null, next: number | null): boolean { + if (next === null) return current !== null + if (current === null) return false + return next > current +} + +// Whether `next` pushes the expiry later than `current`. +// null = never expires, which is later than any date. +function extendsExpiry(current: string | null, next: string | null): boolean { + if (next === null) return current !== null + if (current === null) return false + return new Date(next).getTime() > new Date(current).getTime() +} + function generateInviteCode(length = 8): string { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' let out = '' @@ -86,8 +102,31 @@ export async function updateInvite(inviteId: string, updates: { if (!auth.ok) return { error: auth.error } const { member } = auth - if (v.data.role !== undefined && !canAssignInviteRole(member.role, v.data.role)) { - return { error: `You cannot set an invite to grant the "${v.data.role}" role.` } + // Load the current row (scoped to the caller's space) so the privilege guard + // applies even when the payload omits `role`. Setting the role, re-enabling, + // widening max_uses, or extending expiry all (re-)distribute whatever role the + // invite already grants — so a board member must not be able to re-arm an + // exhausted/disabled admin invite they could never have minted. + const { data: existing, error: loadError } = await supabase + .from('space_invites') + .select('role, is_enabled, max_uses, expires_at') + .eq('id', idCheck.data) + .eq('space_id', member.space_id) + .maybeSingle() + if (loadError) return { error: loadError.message } + if (!existing) return { error: 'Invite not found' } + + // The role the invite grants after this update: the explicit new role, or the + // current role when the payload leaves it untouched. + const effectiveRole = v.data.role ?? existing.role + const rearms = + v.data.role !== undefined || + (v.data.is_enabled === true && existing.is_enabled !== true) || + (v.data.max_uses !== undefined && widensUses(existing.max_uses, v.data.max_uses)) || + (v.data.expires_at !== undefined && extendsExpiry(existing.expires_at, v.data.expires_at)) + + if (rearms && !canAssignInviteRole(member.role, effectiveRole)) { + return { error: `You cannot modify an invite that grants the "${effectiveRole}" role.` } } const patch: Record = {}