import { describe, it, expect } from 'vitest' import { claimInviteUse } from '@/lib/invite-claim' /** * Builds a mock admin client whose `space_invites` UPDATE ... .eq('uses_count', n) * behaves like a real compare-and-swap: it only "affects a row" when the guarded * `uses_count` equals the row's current value, incrementing it on success. This * lets us simulate concurrent redemptions deterministically. */ function makeAdmin(initial: { uses_count: number; max_uses: number | null }) { const row = { ...initial } const client = { from(_table: string) { return { // UPDATE path: .update(patch).eq('id',..).eq('uses_count',guard).select('id') update(patch: { uses_count: number }) { let guard: number | null = null const builder: Record = { eq(col: string, val: number) { if (col === 'uses_count') guard = val return builder }, select() { // CAS: apply only if the guard matches the live value. if (guard === row.uses_count) { row.uses_count = patch.uses_count return Promise.resolve({ data: [{ id: 'inv-1' }] }) } return Promise.resolve({ data: [] }) }, } return builder }, // SELECT path: .select('uses_count').eq('id',..).maybeSingle() select() { const builder: Record = { eq() { return builder }, maybeSingle() { return Promise.resolve({ data: { uses_count: row.uses_count } }) }, } return builder }, } }, _row: row, } return client as unknown as Parameters[0] & { _row: typeof row } } describe('claimInviteUse (invite max_uses TOCTOU fix)', () => { it('claims a use when under the cap and returns the new count', async () => { const admin = makeAdmin({ uses_count: 0, max_uses: 1 }) const result = await claimInviteUse(admin, 'inv-1', 0, 1) expect(result).toEqual({ ok: true, count: 1 }) expect(admin._row.uses_count).toBe(1) }) it('rejects a single-use invite already at cap (no over-issue)', async () => { const admin = makeAdmin({ uses_count: 1, max_uses: 1 }) // Second redeemer read uses_count=0 stale, but the row is already at 1. const result = await claimInviteUse(admin, 'inv-1', 0, 1) expect(result).toEqual({ ok: false, reason: 'cap_reached' }) expect(admin._row.uses_count).toBe(1) // unchanged — not incremented past cap }) it('lets the loser of a race retry into remaining multi-use capacity', async () => { // max_uses=5, another redeemer already bumped it to 1 after our stale read of 0. const admin = makeAdmin({ uses_count: 1, max_uses: 5 }) const result = await claimInviteUse(admin, 'inv-1', 0, 5) expect(result).toEqual({ ok: true, count: 2 }) // re-read to 1, then CAS-claimed to 2 expect(admin._row.uses_count).toBe(2) }) it('treats an unlimited invite (max_uses null) as always claimable', async () => { const admin = makeAdmin({ uses_count: 99, max_uses: null }) const result = await claimInviteUse(admin, 'inv-1', 99, null) expect(result).toEqual({ ok: true, count: 100 }) }) it('reports contention (not cap_reached) when it loses the CAS every attempt under a high cap', async () => { // A perpetually-contended invite with plenty of headroom (cap 1000): every // CAS loses because a competing writer keeps advancing the row between our // re-read and our next update, so we never win within the attempt budget. // The row stays well under cap, so the failure is contention — a false // 'cap_reached' here would wrongly turn away a legitimate joiner. const row = { uses_count: 5, max_uses: 1000 as number | null } const admin = { from() { return { update() { const builder: Record = { eq() { return builder }, select() { // Always lose the CAS: a competitor also bumped the row. row.uses_count += 1 return Promise.resolve({ data: [] }) }, } return builder }, select() { const builder: Record = { eq() { return builder }, maybeSingle() { return Promise.resolve({ data: { uses_count: row.uses_count } }) }, } return builder }, } }, } as unknown as Parameters[0] const result = await claimInviteUse(admin, 'inv-1', 5, 1000, 5) expect(result).toEqual({ ok: false, reason: 'contention' }) expect(row.uses_count).toBeLessThan(1000) // never hit the cap }) it('gives up (contention) if it cannot win the CAS within the attempt budget', async () => { // Caller's known count (0) is behind the live row (3), so the first CAS // guard misses; with a 1-attempt budget it re-reads and then gives up // rather than looping — proving the retry is bounded, not infinite. Cap is // far off (10), so this is contention, not a reached cap. const admin = makeAdmin({ uses_count: 3, max_uses: 10 }) const result = await claimInviteUse(admin, 'inv-1', 0, 10, 1) expect(result).toEqual({ ok: false, reason: 'contention' }) expect(admin._row.uses_count).toBe(3) // untouched }) })