fix(proposals): block unverified members from governance writes (L2)
createProposal/openProposal/withdrawProposal/castVote gated only with requireMember, which admits status='unverified' — so a member pending approval in a require_approval space could propose and cast counted votes before vetting. Add the isPrivilegeEligible gate (same as requireMemberWithRole) to all four. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7fc08c5165
commit
c7a6cd3853
@@ -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<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 { createProposal, castVote } from '@/lib/actions/proposals'
|
||||||
|
|
||||||
|
const UUID = '11111111-2222-3333-4444-555555555555'
|
||||||
|
const user = { id: 'user-A', email: '[email protected]' }
|
||||||
|
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<typeof makeClient>, 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
logActivity,
|
logActivity,
|
||||||
parseInput,
|
parseInput,
|
||||||
} from '@/lib/auth-helpers'
|
} from '@/lib/auth-helpers'
|
||||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
import { ADMIN_ROLES, isPrivilegeEligible } from '@/lib/permissions'
|
||||||
import {
|
import {
|
||||||
createProposalSchema,
|
createProposalSchema,
|
||||||
openProposalSchema,
|
openProposalSchema,
|
||||||
@@ -41,6 +41,11 @@ export async function createProposal(formData: {
|
|||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
const { member } = auth
|
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'
|
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)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
const { member } = auth
|
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<string, unknown> = { status: 'open' }
|
const updates: Record<string, unknown> = { status: 'open' }
|
||||||
if (v.data.voting_closes_at) updates.voting_closes_at = v.data.voting_closes_at
|
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)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
const { member } = auth
|
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
|
const { error } = await supabase
|
||||||
.from('proposals')
|
.from('proposals')
|
||||||
@@ -150,6 +165,11 @@ export async function castVote(formData: {
|
|||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
const { member } = auth
|
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,
|
// Defense in depth: the votes RLS policies already enforce same-space,
|
||||||
// open status and the voting window, but this is a sensitive mutation, so
|
// open status and the voting window, but this is a sensitive mutation, so
|
||||||
|
|||||||
Reference in New Issue
Block a user