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]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4b98c21e2b
commit
7fe3ffc632
@@ -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<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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
+41
-2
@@ -8,6 +8,22 @@ import { canAssignInviteRole } from '@/lib/invite-logic'
|
|||||||
|
|
||||||
const INVITE_ADMIN_ROLES = ['admin', 'board'] as const
|
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 {
|
function generateInviteCode(length = 8): string {
|
||||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
let out = ''
|
let out = ''
|
||||||
@@ -86,8 +102,31 @@ export async function updateInvite(inviteId: string, updates: {
|
|||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
const { member } = auth
|
const { member } = auth
|
||||||
|
|
||||||
if (v.data.role !== undefined && !canAssignInviteRole(member.role, v.data.role)) {
|
// Load the current row (scoped to the caller's space) so the privilege guard
|
||||||
return { error: `You cannot set an invite to grant the "${v.data.role}" role.` }
|
// 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<string, unknown> = {}
|
const patch: Record<string, unknown> = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user