fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op (#1852)
* fix(pending-ops): MCP approval of bulk-book works and failed approvals no longer consume the op Feedback seq 261545 (deepCFO): approving a bulk_book_transactions op over MCP returned BULK_BOOK_UNAUTHORIZED, yet the op vanished from /pending with nothing booked; the user believed it had been approved. Two defects: 1. The bulk_book_transactions RPC gates on auth.uid(), which is NULL on the cookieless service client every MCP approval runs on, so EVERY API-key approval of a samlingsverifikat was refused. New migration 20260824170000 adds p_user_id, honored only for service_role callers (same gate as match_batch_allocate 20260817150000 and undo_sie_import); the executor passes the approving user, who is now also the actor stamped on the verifikat. pg-real test covers member/spoof/no-JWT/ grants like the precedent. 2. The dispatcher consumed the op on ANY executor error other than 404/ 409. An authorization refusal happens before any side-effect and says nothing about the op, so 401/403 now release the claim back to 'pending'. The executor maps RPC codes through the structured-error registry so 403/404/409 are distinguishable from 400. Every CommitResult carries operation_status (pending | committed | rejected | failed_partial), exposed on gnubok_approve_pending_operation, so agents stop inferring consumption from status 'failed'. Catalog token ceiling 59.95K -> 60K per the documented ratchet protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ * fix(pending-ops): revoke anon explicitly on the service-actor bulk_book signature Default privileges grant EXECUTE on new functions to anon; the pg-real grants test (mirroring match_batch_allocate) caught it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
e35714518f
commit
dc92fb5c0c
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Authorization refusals must not consume a pending operation.
|
||||
*
|
||||
* Feedback seq 261545: an API-key approve of a bulk_book_transactions op hit
|
||||
* BULK_BOOK_UNAUTHORIZED (the RPC saw auth.uid() = NULL on the service
|
||||
* client), and the dispatcher landed the op as 'rejected'. It vanished from
|
||||
* the /pending queue with nothing booked, and the user believed it had been
|
||||
* approved. A 401/403 happens before any side-effect and says nothing about
|
||||
* the op's content, so the claim is released back to 'pending' and the
|
||||
* result says so explicitly (operation_status) instead of leaving agents to
|
||||
* infer consumption from status 'failed'.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
import { commitPendingOperation } from '../commit'
|
||||
|
||||
function makeBulkBookOp(): PendingOperation {
|
||||
return {
|
||||
id: 'op-bulk-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'bulk_book_transactions',
|
||||
status: 'pending',
|
||||
title: 'Samlingsverifikation: 3 transaktioner 2026-07-22',
|
||||
params: {
|
||||
tx_ids: ['tx-1', 'tx-2', 'tx-3'],
|
||||
existing_journal_entry_id: null,
|
||||
new_entry: { description: 'Dagskassa', lines: [] },
|
||||
},
|
||||
preview_data: {},
|
||||
result_data: null,
|
||||
actor_type: 'api_key',
|
||||
actor_id: 'key-1',
|
||||
actor_label: 'deepCFO',
|
||||
risk_level: 'medium',
|
||||
created_at: '2026-08-24T00:00:00Z',
|
||||
resolved_at: null,
|
||||
updated_at: '2026-08-24T00:00:00Z',
|
||||
} as PendingOperation
|
||||
}
|
||||
|
||||
describe('commitPendingOperation: authorization refusal is recoverable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('releases the claim back to pending on BULK_BOOK_UNAUTHORIZED and reports operation_status', async () => {
|
||||
const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: { id: 'op-bulk-1' }, error: null }, // atomic claim pending -> committing
|
||||
{ data: { ok: false, code: 'BULK_BOOK_UNAUTHORIZED' }, error: null }, // RPC refusal
|
||||
{ data: null, error: null }, // release claim back to pending
|
||||
])
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makeBulkBookOp(),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(403)
|
||||
expect(result.code).toBe('BULK_BOOK_UNAUTHORIZED')
|
||||
expect(result.operation_status).toBe('pending')
|
||||
|
||||
const updates = findCalls('pending_operations', 'update')
|
||||
expect(updates).toContainEqual([{ status: 'committing' }])
|
||||
expect(updates).toContainEqual([{ status: 'pending' }])
|
||||
expect(updates.some((args) => (args[0] as { status?: string }).status === 'rejected')).toBe(false)
|
||||
})
|
||||
|
||||
it('passes the approving user as p_user_id so the service client is attributed', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: { id: 'op-bulk-1' }, error: null },
|
||||
{ data: { ok: true, journal_entry_id: 'je-1', mode: 'create_new', linked_tx_count: 3 }, error: null },
|
||||
{ data: null, error: null }, // finalize committed
|
||||
])
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makeBulkBookOp(),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.operation_status).toBe('committed')
|
||||
expect(supabase.rpc).toHaveBeenCalledTimes(1)
|
||||
expect(supabase.rpc).toHaveBeenCalledWith(
|
||||
'bulk_book_transactions',
|
||||
expect.objectContaining({ p_user_id: 'user-1', p_company_id: 'company-1' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('still consumes the op as rejected on a genuine input error (400)', async () => {
|
||||
const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: { id: 'op-bulk-1' }, error: null },
|
||||
{ data: { ok: false, code: 'BULK_BOOK_INVALID_PAYLOAD' }, error: null },
|
||||
{ data: null, error: null }, // rejected update
|
||||
])
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makeBulkBookOp(),
|
||||
)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.operation_status).toBe('rejected')
|
||||
const updates = findCalls('pending_operations', 'update')
|
||||
expect(updates.some((args) => (args[0] as { status?: string }).status === 'rejected')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user