Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ describe('help consistency', () => {
expect(stdout).toContain('Listen for webhook events')
expect(stdout).toContain('listen')
})

test('shows listen forwarding option', () => {
const { stdout, exitCode } = run(['webhooks', 'listen', '--help'])
expect(exitCode).toBe(0)
expect(stdout).toContain('--forward-to')
})
})

describe('when operation --help is shown', () => {
Expand Down
12 changes: 10 additions & 2 deletions src/commands/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ type RootOpts = {
json?: boolean
}

type ListenOpts = {
forwardTo?: string
}

export const webhooksCommand = (program: Command) => {
const cmd = program.command('webhooks').description('Listen for webhook events')
cmd.configureHelp({ showGlobalOptions: true })
Expand All @@ -20,7 +24,8 @@ export const webhooksCommand = (program: Command) => {
cmd
.command('listen')
.description('Listen for webhook events in real time')
.action(async (_opts: unknown, actionCmd: Command) => {
.option('--forward-to <url>', 'Forward webhook events to a URL')
.action(async (opts: ListenOpts, actionCmd: Command) => {
const rootOpts = actionCmd.parent!.parent!.opts<RootOpts>()
const auth = resolveAuth(rootOpts)

Expand Down Expand Up @@ -53,7 +58,10 @@ export const webhooksCommand = (program: Command) => {
websocketUrl: session.websocket_url,
sessionId: session.id,
secret: session.secret,
handlers: createWebhookRelayHandlers({ json: rootOpts.json }),
handlers: createWebhookRelayHandlers({
json: rootOpts.json,
forwardTo: opts.forwardTo,
}),
})
} catch (err) {
caughtError = err
Expand Down
47 changes: 39 additions & 8 deletions src/lib/webhooks/__tests__/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { printJson } from '../../output.js'
import { createWebhookRelayHandlers } from '../handlers.js'

Expand All @@ -11,7 +11,11 @@ describe('webhook relay handlers', () => {
vi.clearAllMocks()
})

test('pretty-prints the full event for webhook messages', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

test('pretty-prints the full event for webhook messages', async () => {
const event = JSON.stringify({ id: 'evt_123' })
const message = {
type: 'webhook_event',
Expand All @@ -21,12 +25,12 @@ describe('webhook relay handlers', () => {
timestamp: 1_234,
} as const

createWebhookRelayHandlers({}).webhook_event!(message)
await createWebhookRelayHandlers({}).webhook_event!(message)

expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' })
})

test('prints the full relay message in JSON mode', () => {
test('prints the full relay message in JSON mode', async () => {
const message = {
type: 'webhook_event',
id: 'wem_123',
Expand All @@ -37,20 +41,47 @@ describe('webhook relay handlers', () => {
timestamp: 1_234,
} as const

createWebhookRelayHandlers({ json: true }).webhook_event!(message)
await createWebhookRelayHandlers({ json: true }).webhook_event!(message)

expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' })
})

test('forwards the raw event with the webhook signature', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
const event = JSON.stringify({ id: 'evt_123' })
const message = {
type: 'webhook_event',
event,
signature: 'test_signature',
event_type: 'payment_intent.succeeded',
timestamp: 1_234,
} as const

await createWebhookRelayHandlers({
forwardTo: 'https://example.test/webhooks',
}).webhook_event!(message)

expect(fetchMock).toHaveBeenCalledWith('https://example.test/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Fintoc-Signature': 'test_signature',
},
body: event,
})
expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' })
})

test('rejects malformed webhook messages', () => {
expect(() =>
test('rejects malformed webhook messages', async () => {
await expect(
createWebhookRelayHandlers({}).webhook_event!({
type: 'webhook_event',
event: '{}',
signature: 'test_signature',
event_type: 'payment_intent.succeeded',
timestamp: 1.2,
}),
).toThrow('Invalid webhook event message')
).rejects.toThrow('Invalid webhook event message')
})
})
33 changes: 30 additions & 3 deletions src/lib/webhooks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,46 @@ const webhookEventMessageSchema = z.object({

export type WebhookRelayHandler = (
message: HandledRelayMessage<'webhook_event'>,
options: { json?: boolean },
options: WebhookRelayOptions,
) => void | Promise<void>

export const handleWebhookEvent: WebhookRelayHandler = (message, _options) => {
type WebhookRelayOptions = {
json?: boolean
forwardTo?: string
}

const forwardWebhookEvent = async (
url: string,
data: z.infer<typeof webhookEventMessageSchema>,
) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Fintoc-Signature': data.signature,
},
body: data.event,
})

if (!response.ok) {
throw new Error(`Failed to forward webhook event: ${response.status} ${response.statusText}`)
}
}

export const handleWebhookEvent: WebhookRelayHandler = async (message, options) => {
const result = webhookEventMessageSchema.safeParse(message)
if (!result.success) {
throw new Error(`Invalid webhook event message: ${z.prettifyError(result.error)}`)
}

printJson(JSON.parse(result.data.event))

if (options.forwardTo) {
await forwardWebhookEvent(options.forwardTo, result.data)
}
}

export const createWebhookRelayHandlers = (options: { json?: boolean }) => {
export const createWebhookRelayHandlers = (options: WebhookRelayOptions) => {
return {
webhook_event: (message) => handleWebhookEvent(message, options),
} satisfies RelayMessageHandlers
Expand Down
Loading