fix(api): register the v1 stamp endpoint scope and derive the webhook event catalogue from one source (#1930)
POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp registered itself
with scope documents:write but had no V1_ENDPOINT_SCOPES entry, and the
wrapper resolves the required scope from that map before it validates the
bearer token, so the route answered NOT_FOUND to every caller. Add the entry,
drop the three phantom entries that had no route (GET openapi.yaml, GET
companies/:companyId, GET companies/:companyId/events), and add a parity test
that pins the scope map to the endpoint registry in both directions, checks
every pattern against an existing route file, and checks every v1 route file
is imported by load-routes.ts.
The webhook event catalogue was hand-copied in three places and had drifted:
the fan-out handler delivered 28 events while the v1 create enum, the OpenAPI
spec, the generated agent skill and the docs page listed 24, so the four
reconciliation.* events could not be subscribed to. lib/webhooks/public-events.ts
is now the single source; the handler set, the Zod enum and the docs section
derive from it, with tests that pin each surface to the catalogue. The PATCH
webhook docs no longer tell agents to delete and recreate a webhook to rotate
its secret: POST .../rotate-secret exists.
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
Jakob Wennberg
Claude Fable 5
parent
f08fc2c274
commit
d3869e6694
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Tests for POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp through
|
||||
* the real withApiV1 wrapper (auth, scope, membership, idempotency) with the
|
||||
* Supabase client mocked per table.
|
||||
*
|
||||
* The 401 case is the regression guard: the route had no V1_ENDPOINT_SCOPES
|
||||
* entry, so the wrapper answered NOT_FOUND to every caller (valid key or not)
|
||||
* before this file existed.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const ITEM_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const JE_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const OTHER_JE_ID = '55555555-5555-4555-8555-555555555555'
|
||||
const url = (itemId = ITEM_ID) => `http://localhost/api/v1/companies/${COMPANY_ID}/inbox-items/${itemId}/stamp`
|
||||
|
||||
function req(init: { body?: unknown; idem?: boolean; itemId?: string; auth?: boolean } = {}): Request {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (init.auth !== false) headers.Authorization = 'Bearer test-fixture-not-a-real-key'
|
||||
if (init.idem !== false) headers['Idempotency-Key'] = `idem-${Math.random().toString(36).slice(2)}-aaaa-4abc-8def-1234567890ab`
|
||||
return new Request(url(init.itemId), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function authOk(scopes: string[]) {
|
||||
mockValidate.mockResolvedValue({ valid: true, userId: 'user-1', keyId: 'key-1', keyName: 'Test key', scopes, mode: 'live' })
|
||||
}
|
||||
|
||||
const params = (id = ITEM_ID) => ({ params: Promise.resolve({ companyId: COMPANY_ID, id }) })
|
||||
|
||||
function withTables(tables: Record<string, MockResult | MockResult[]>) {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { role: 'owner' } },
|
||||
idempotency_keys: { data: null },
|
||||
...tables,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
withTables({
|
||||
invoice_inbox_items: { data: { id: ITEM_ID, created_journal_entry_id: null } },
|
||||
journal_entries: { data: { id: JE_ID } },
|
||||
})
|
||||
})
|
||||
|
||||
it('401 without a bearer token and 401 with an invalid key (not 404: the endpoint is registered)', async () => {
|
||||
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
|
||||
const noAuth = await POST(req({ body: { journal_entry_id: JE_ID }, auth: false }), params())
|
||||
expect(noAuth.status).toBe(401)
|
||||
const badKey = await POST(req({ body: { journal_entry_id: JE_ID } }), params())
|
||||
expect(badKey.status).toBe(401)
|
||||
expect((await badKey.json()).error.code).not.toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('403 without documents:write', async () => {
|
||||
authOk(['documents:read'])
|
||||
const res = await POST(req({ body: { journal_entry_id: JE_ID } }), params())
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('400 without an Idempotency-Key, on a malformed body, and on a non-UUID item id', async () => {
|
||||
authOk(['documents:write'])
|
||||
expect((await POST(req({ body: { journal_entry_id: JE_ID }, idem: false }), params())).status).toBe(400)
|
||||
expect((await POST(req({ body: {} }), params())).status).toBe(400)
|
||||
expect((await POST(req({ body: { journal_entry_id: JE_ID, extra: 1 } }), params())).status).toBe(400)
|
||||
expect((await POST(req({ body: { journal_entry_id: JE_ID }, itemId: 'not-a-uuid' }), params('not-a-uuid'))).status).toBe(400)
|
||||
})
|
||||
|
||||
it('404 when the inbox item or the journal entry is not in the company', async () => {
|
||||
authOk(['documents:write'])
|
||||
withTables({ invoice_inbox_items: { data: null }, journal_entries: { data: { id: JE_ID } } })
|
||||
const noItem = await POST(req({ body: { journal_entry_id: JE_ID } }), params())
|
||||
expect(noItem.status).toBe(404)
|
||||
expect((await noItem.json()).error.details.resource).toBe('inbox_item')
|
||||
|
||||
withTables({ invoice_inbox_items: { data: { id: ITEM_ID, created_journal_entry_id: null } }, journal_entries: { data: null } })
|
||||
const noJe = await POST(req({ body: { journal_entry_id: JE_ID } }), params())
|
||||
expect(noJe.status).toBe(404)
|
||||
expect((await noJe.json()).error.details.resource).toBe('journal_entry')
|
||||
})
|
||||
|
||||
it('stamps an unstamped item and returns the new link', async () => {
|
||||
authOk(['documents:write'])
|
||||
const res = await POST(req({ body: { journal_entry_id: JE_ID } }), params())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toEqual({ id: ITEM_ID, created_journal_entry_id: JE_ID })
|
||||
})
|
||||
|
||||
it('is idempotent when the item is already stamped with the same entry', async () => {
|
||||
authOk(['documents:write'])
|
||||
withTables({ invoice_inbox_items: { data: { id: ITEM_ID, created_journal_entry_id: JE_ID } }, journal_entries: { data: { id: JE_ID } } })
|
||||
const res = await POST(req({ body: { journal_entry_id: JE_ID } }), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).data.created_journal_entry_id).toBe(JE_ID)
|
||||
})
|
||||
|
||||
it('409 when the item is already stamped with a different entry', async () => {
|
||||
authOk(['documents:write'])
|
||||
withTables({ invoice_inbox_items: { data: { id: ITEM_ID, created_journal_entry_id: OTHER_JE_ID } }, journal_entries: { data: { id: JE_ID } } })
|
||||
const res = await POST(req({ body: { journal_entry_id: JE_ID } }), params())
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('CONFLICT')
|
||||
expect(body.error.details.current_journal_entry_id).toBe(OTHER_JE_ID)
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,8 @@
|
||||
* GET : return the full webhook row (no secret).
|
||||
* PATCH : update name, description, webhook_url, active. Cannot change
|
||||
* event_type (immutable: would require re-pinning api_version).
|
||||
* Cannot rotate the secret here (separate flow, deferred to
|
||||
* Phase 6 follow-up).
|
||||
* Cannot rotate the secret here: that is POST .../rotate-secret
|
||||
* (see ./rotate-secret/route.ts).
|
||||
* DELETE: hard delete the webhook. The webhook_deliveries.webhook_id FK
|
||||
* is ON DELETE SET NULL (declared in migration 20260515170000),
|
||||
* so the delivery audit trail SURVIVES webhook deletion
|
||||
@@ -124,7 +124,8 @@ registerEndpoint({
|
||||
description:
|
||||
'Update the URL, name, description, or active flag. event_type is immutable: delete and recreate to change it. Setting active=false manually pauses delivery without deleting; setting active=true clears any disabled_at/disabled_reason set by the auto-disable on HTTP 410.',
|
||||
useWhen: 'You need to point an existing webhook at a new URL or temporarily pause delivery.',
|
||||
doNotUseFor: 'Rotating the signing secret (delete and recreate). Changing event_type.',
|
||||
doNotUseFor:
|
||||
'Rotating the signing secret: use POST /webhooks/{id}/rotate-secret, which issues a fresh secret in place and keeps the webhook id and delivery history. Changing event_type: delete and recreate.',
|
||||
pitfalls: [
|
||||
'Re-enabling a webhook (active: true) does NOT replay deliveries that went to dead status while it was disabled: those need POST /webhook-deliveries/{id}/retry.',
|
||||
],
|
||||
|
||||
@@ -21,33 +21,12 @@ import { generateWebhookSecret } from '@/lib/webhooks/signing'
|
||||
import { validateWebhookUrl } from '@/lib/webhooks/url-guard'
|
||||
import { API_V1_VERSION } from '@/lib/api/v1/version'
|
||||
import { hasScope } from '@/lib/auth/api-keys'
|
||||
import { PUBLIC_WEBHOOK_EVENTS } from '@/lib/webhooks/public-events'
|
||||
|
||||
const WEBHOOK_EVENT_TYPES = z.enum([
|
||||
'invoice.created',
|
||||
'invoice.sent',
|
||||
'invoice.paid',
|
||||
'credit_note.created',
|
||||
'customer.created',
|
||||
'supplier.created',
|
||||
'supplier_invoice.registered',
|
||||
'supplier_invoice.approved',
|
||||
'supplier_invoice.paid',
|
||||
'supplier_invoice.credited',
|
||||
'supplier_invoice.uncredited',
|
||||
'transaction.categorized',
|
||||
'transaction.reconciled',
|
||||
'journal_entry.committed',
|
||||
'journal_entry.reversed',
|
||||
'journal_entry.corrected',
|
||||
'period.locked',
|
||||
'period.unlocked',
|
||||
'period.year_closed',
|
||||
'salary_run.created',
|
||||
'salary_run.approved',
|
||||
'salary_run.booked',
|
||||
'agi.generated',
|
||||
'document.uploaded',
|
||||
])
|
||||
// Derived from the single catalogue the fan-out handler and the docs page
|
||||
// also read, so the events an agent can subscribe to are exactly the events
|
||||
// that get delivered.
|
||||
const WEBHOOK_EVENT_TYPES = z.enum(PUBLIC_WEBHOOK_EVENTS)
|
||||
|
||||
const CreateWebhookSchema = z.object({
|
||||
event_type: WEBHOOK_EVENT_TYPES,
|
||||
@@ -175,7 +154,7 @@ registerEndpoint({
|
||||
doNotUseFor:
|
||||
'Subscribing to internal MCP telemetry events (mcp.tool_called etc. are not delivered as webhooks). Replacing an existing webhook URL: use PATCH instead.',
|
||||
pitfalls: [
|
||||
'The secret is returned exactly once. If lost, delete and recreate the webhook.',
|
||||
'The secret is returned exactly once. If lost, rotate it with POST /webhooks/{id}/rotate-secret: a fresh secret is issued in place, the webhook id and delivery history are kept.',
|
||||
'Delivery is at-least-once with exponential backoff (1m / 5m / 30m / 2h / 12h / 24h / 48h). Receivers MUST be idempotent.',
|
||||
'HTTP 410 from your receiver auto-disables the webhook (sets active=false + disabled_reason).',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user