fix(incidents): restrict appealIncident to admin/board, no orphan proposal (M2)

appealIncident was gated by requireMember but the incident UPDATE is RLS-
restricted to admin/board, so a reporter's appeal inserted a draft proposal,
updated 0 incident rows, and returned success — an orphan proposal. Gate with
ADMIN_ROLES to match the write, and assert the link UPDATE affected a row
(.select), rolling back the draft proposal if it did not.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Moheeb Zara
2026-08-01 21:31:51 -07:00
co-authored by Claude Opus 4.8
parent 7fe3ffc632
commit 7fc08c5165
2 changed files with 174 additions and 6 deletions
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
/**
* Authorization + orphan-proposal guard for appealIncident.
*
* The incident UPDATE inside appealIncident is RLS-restricted to admin/board.
* Before the fix the action was gated by requireMember (ANY member): a plain
* member/reporter would insert a draft proposal, update ZERO incident rows, and
* still get `{ data: proposal }` back — an orphan proposal the incident never
* links to. These tests drive the REAL action through a recording mock client
* and assert (a) a non-admin/board caller is rejected with NO proposal created,
* and (b) on the admin path the proposal survives only when the incident update
* actually affects a row, otherwise it is rolled back.
*/
// ─── Recording mock Supabase client (shared style with 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 { appealIncident } from '@/lib/actions/incidents'
const INCIDENT_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: 'Admin A',
handle: 'admin-a',
})
/** Recorded `.from(table)` targets, in order. */
const fromTables = (c: ReturnType<typeof makeClient>) =>
c._calls.filter(x => x.method === 'from').map(x => x.args[0])
/** Recorded `.insert(payload)` calls into a given table are hard to attribute
* to a table directly, so detect the proposal insert by its shape. */
const proposalInserts = (c: ReturnType<typeof makeClient>) =>
c._calls
.filter(x => x.method === 'insert')
.map(x => x.args[0])
.filter(
(o): o is Record<string, unknown> =>
!!o && typeof o === 'object' && (o as Record<string, unknown>).proposal_type === 'membership_vote',
)
const deleteCalls = (c: ReturnType<typeof makeClient>) =>
c._calls.filter(x => x.method === 'delete')
beforeEach(() => {
vi.clearAllMocks()
})
describe('appealIncident authorization', () => {
it('rejects a plain member with an admin/board message and creates NO proposal', async () => {
currentClient = makeClient({ user, queue: [{ data: memberRow('member') }] })
const res = await appealIncident({ incidentId: INCIDENT_ID, title: 'Appeal' })
expect(res).toHaveProperty('error')
expect((res as { error: string }).error).toMatch(/admin/i)
// The action must bail before ever touching incidents or proposals.
expect(proposalInserts(currentClient)).toHaveLength(0)
expect(fromTables(currentClient)).not.toContain('proposals')
})
})
describe('appealIncident orphan-proposal guard', () => {
it('links the incident and returns the proposal when the update affects a row', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('admin', 'space-A') }, // requireMember
{ data: { id: INCIDENT_ID, status: 'decided', space_id: 'space-A' } }, // incident lookup
{ data: { id: 'proposal-1' } }, // proposal insert().select().single()
{ data: [{ id: INCIDENT_ID }] }, // incident update().select() → 1 row affected
{ error: null }, // activity_log insert
],
})
const res = await appealIncident({ incidentId: INCIDENT_ID, title: 'Appeal' })
expect(res).not.toHaveProperty('error')
expect((res as { data: { id: string } }).data.id).toBe('proposal-1')
expect(proposalInserts(currentClient)).toHaveLength(1)
// A successful update must NOT roll the proposal back.
expect(deleteCalls(currentClient)).toHaveLength(0)
})
it('rolls the draft proposal back (no orphan) when the incident update affects zero rows', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('admin', 'space-A') }, // requireMember
{ data: { id: INCIDENT_ID, status: 'decided', space_id: 'space-A' } }, // incident lookup
{ data: { id: 'proposal-1' } }, // proposal insert().select().single()
{ data: [], error: null }, // incident update().select() → RLS-silent 0 rows
],
})
const res = await appealIncident({ incidentId: INCIDENT_ID, title: 'Appeal' })
expect(res).toHaveProperty('error')
// The orphan draft proposal must be deleted, not left dangling.
expect(proposalInserts(currentClient)).toHaveLength(1)
const deletes = deleteCalls(currentClient)
expect(deletes.length).toBeGreaterThan(0)
// And the incident-linking UPDATE must be scoped and asserted via .select().
const eqCols = currentClient._calls.filter(x => x.method === 'eq').map(x => x.args[0])
expect(eqCols).toContain('space_id')
expect(currentClient._calls.some(x => x.method === 'select')).toBe(true)
})
})
+17 -6
View File
@@ -200,8 +200,11 @@ export async function addIncidentUpdate(formData: {
/** /**
* Appeal a dismissed incident by spawning a membership-vote proposal. * Appeal a dismissed incident by spawning a membership-vote proposal.
* The reporter or admin/board can trigger the appeal; the proposal goes * Admin/board only: the incident UPDATE below is RLS-restricted to
* to draft status so the petitioner (or board) can review before opening. * admin/board, so a non-privileged caller could otherwise insert a draft
* proposal, update zero incident rows, and still see success — an orphan
* proposal that never links back. The proposal goes to draft status so the
* board can review before opening.
*/ */
export async function appealIncident(formData: { export async function appealIncident(formData: {
incidentId: string incidentId: string
@@ -212,7 +215,7 @@ export async function appealIncident(formData: {
if (!v.ok) return { error: v.error } if (!v.ok) return { error: v.error }
const supabase = await createClient() const supabase = await createClient()
const auth = await requireMember(supabase) const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error } if (!auth.ok) return { error: auth.error }
const { member } = auth const { member } = auth
@@ -243,12 +246,20 @@ export async function appealIncident(formData: {
.single() .single()
if (proposalErr) return { error: proposalErr.message } if (proposalErr) return { error: proposalErr.message }
// Mark the incident as appealed and link it to the proposal. // Mark the incident as appealed and link it to the proposal. Assert the
const { error: linkErr } = await supabase // update actually touched a row via .select(): if RLS rejects the write
// (or the row is gone), roll back the draft proposal so we never leave an
// orphan that isn't linked from any incident.
const { data: linked, error: linkErr } = await supabase
.from('incidents') .from('incidents')
.update({ status: 'appealed', appeal_proposal_id: proposal.id }) .update({ status: 'appealed', appeal_proposal_id: proposal.id })
.eq('id', v.data.incidentId) .eq('id', v.data.incidentId)
if (linkErr) return { error: linkErr.message } .eq('space_id', member.space_id)
.select('id')
if (linkErr || !linked || linked.length === 0) {
await supabase.from('proposals').delete().eq('id', proposal.id)
return { error: linkErr?.message ?? 'Could not link the appeal to the incident.' }
}
await logActivity(supabase, member, 'appealed', 'incident', v.data.incidentId) await logActivity(supabase, member, 'appealed', 'incident', v.data.incidentId)