diff --git a/__tests__/proposals-authz.test.ts b/__tests__/proposals-authz.test.ts new file mode 100644 index 0000000..37e9898 --- /dev/null +++ b/__tests__/proposals-authz.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +/** + * Authorization tests for governance proposal actions. + * + * `require_approval` spaces keep a freshly-joined member at status + * 'unverified' until an admin approves them. requireMember admits + * 'unverified' (it's an ACTIVE_STATUS), so the proposal actions must add the + * privilege-eligible gate themselves — otherwise a pending member could + * create and vote on proposals. These tests drive the real actions through a + * recording mock Supabase client and assert the gate blocks 'unverified' + * while a 'current' member is allowed. + */ + +// ─── Recording mock Supabase client (mirrors __tests__/actions.test.ts) ────── +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 { createProposal, castVote } from '@/lib/actions/proposals' + +const UUID = '11111111-2222-3333-4444-555555555555' +const user = { id: 'user-A', email: 'a@example.com' } +const memberRow = (status: string, space_id = 'space-A') => ({ + id: 'member-A', + space_id, + user_id: user.id, + role: 'member', + status, + display_name: 'Pat Member', + handle: 'pat', +}) + +/** True if any recorded insert/upsert wrote to the given table. */ +const wroteTo = (c: ReturnType, table: string) => + c._calls.some(x => x.method === 'from' && x.args[0] === table) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('proposal actions block unverified (pending-approval) members', () => { + it('rejects an unverified member from createProposal and performs no insert', async () => { + currentClient = makeClient({ user, queue: [{ data: memberRow('unverified') }] }) + const res = await createProposal({ title: 'Buy a new laser' }) + expect(res).toHaveProperty('error') + expect((res as { error: string }).error).toMatch(/pending approval/i) + // No proposals row was inserted. + expect(currentClient._calls.some(x => x.method === 'insert')).toBe(false) + }) + + it('rejects an unverified member from castVote and casts no vote', async () => { + currentClient = makeClient({ user, queue: [{ data: memberRow('unverified') }] }) + const res = await castVote({ proposalId: UUID, position: 'yes' }) + expect(res).toHaveProperty('error') + expect((res as { error: string }).error).toMatch(/pending approval/i) + // The gate fires before the proposal lookup / upsert, so nothing is written. + expect(wroteTo(currentClient, 'proposal_votes')).toBe(false) + }) + + it('allows a current member to createProposal', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('current') }, // requireMember + { data: { id: 'prop-1', title: 'Buy a new laser' } }, // insert().select().single() + { error: null }, // activity_log insert + ], + }) + const res = await createProposal({ title: 'Buy a new laser' }) + expect(res).not.toHaveProperty('error') + expect(wroteTo(currentClient, 'proposals')).toBe(true) + }) + + it('allows a current member to castVote', async () => { + currentClient = makeClient({ + user, + queue: [ + { data: memberRow('current') }, // requireMember + { data: { id: UUID, status: 'open' } }, // proposal lookup maybeSingle + { error: null }, // proposal_votes upsert + { error: null }, // activity_log insert + ], + }) + const res = await castVote({ proposalId: UUID, position: 'yes' }) + expect(res).not.toHaveProperty('error') + expect(wroteTo(currentClient, 'proposal_votes')).toBe(true) + }) +}) diff --git a/lib/actions/proposals.ts b/lib/actions/proposals.ts index 3956736..e8d14f4 100644 --- a/lib/actions/proposals.ts +++ b/lib/actions/proposals.ts @@ -8,7 +8,7 @@ import { logActivity, parseInput, } from '@/lib/auth-helpers' -import { ADMIN_ROLES } from '@/lib/permissions' +import { ADMIN_ROLES, isPrivilegeEligible } from '@/lib/permissions' import { createProposalSchema, openProposalSchema, @@ -41,6 +41,11 @@ export async function createProposal(formData: { const auth = await requireMember(supabase) if (!auth.ok) return { error: auth.error } const { member } = auth + // require_approval gate: an 'unverified' member (pending admin approval) + // must not create or participate in governance. Mirrors requireMemberWithRole. + if (!isPrivilegeEligible(member.status)) { + return { error: 'Your membership is pending approval.' } + } const status = v.data.open_immediately ? 'open' : 'draft' @@ -83,6 +88,11 @@ export async function openProposal(proposalId: string, voting_closes_at?: string const auth = await requireMember(supabase) if (!auth.ok) return { error: auth.error } const { member } = auth + // require_approval gate: an 'unverified' member (pending admin approval) + // must not create or participate in governance. Mirrors requireMemberWithRole. + if (!isPrivilegeEligible(member.status)) { + return { error: 'Your membership is pending approval.' } + } const updates: Record = { status: 'open' } if (v.data.voting_closes_at) updates.voting_closes_at = v.data.voting_closes_at @@ -115,6 +125,11 @@ export async function withdrawProposal(proposalId: string) { const auth = await requireMember(supabase) if (!auth.ok) return { error: auth.error } const { member } = auth + // require_approval gate: an 'unverified' member (pending admin approval) + // must not create or participate in governance. Mirrors requireMemberWithRole. + if (!isPrivilegeEligible(member.status)) { + return { error: 'Your membership is pending approval.' } + } const { error } = await supabase .from('proposals') @@ -150,6 +165,11 @@ export async function castVote(formData: { const auth = await requireMember(supabase) if (!auth.ok) return { error: auth.error } const { member } = auth + // require_approval gate: an 'unverified' member (pending admin approval) + // must not create or participate in governance. Mirrors requireMemberWithRole. + if (!isPrivilegeEligible(member.status)) { + return { error: 'Your membership is pending approval.' } + } // Defense in depth: the votes RLS policies already enforce same-space, // open status and the voting window, but this is a sensitive mutation, so