Skip to content

Commit

Permalink
fix: change webhook validation according to specs (#1916)
Browse files Browse the repository at this point in the history
* fix: change webhook validation according to specs

* fix: format

* test: move validation tests to controller
  • Loading branch information
dragosp1011 authored Jan 30, 2025
1 parent aeb870e commit 6e27610
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 160 deletions.
46 changes: 2 additions & 44 deletions packages/wallet/backend/src/rafiki/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,7 @@ import MessageType from '@/socket/messageType'
import { BadRequest } from '@shared/backend'
import { GateHubClient } from '@/gatehub/client'
import { TransactionTypeEnum } from '@/gatehub/consts'
import {
WebhookType,
incomingPaymentWebhookSchema,
incomingPaymentCompletedWebhookSchema,
outgoingPaymentWebhookSchema,
walletAddressWebhookSchema,
validateInput
} from './validation'
import { WebhookType } from './validation'

export enum EventType {
IncomingPaymentCreated = 'incoming_payment.created',
Expand Down Expand Up @@ -77,9 +70,6 @@ type Fee = {

export type Fees = Record<string, Fee>

const isValidEventType = (value: string): value is EventType => {
return Object.values(EventType).includes(value as EventType)
}
interface IRafikiService {
onWebHook: (wh: WebhookType) => Promise<void>
}
Expand All @@ -101,13 +91,7 @@ export class RafikiService implements IRafikiService {
wh.type === EventType.WalletAddressNotFound ? '' : `${wh.id}}`
}`
)
if (!isValidEventType(wh.type)) {
throw new BadRequest(`unknown event type, ${wh.type}`)
}
const isValid = await this.isValidInput(wh)
if (!isValid) {
throw new BadRequest(`Invalid Input for ${wh.type}`)
}

switch (wh.type) {
case EventType.OutgoingPaymentCreated:
await this.handleOutgoingPaymentCreated(wh)
Expand All @@ -133,32 +117,6 @@ export class RafikiService implements IRafikiService {
}
}

private async isValidInput(wh: WebhookType) {
let validInput = false

switch (wh.type) {
case EventType.OutgoingPaymentCreated:
case EventType.OutgoingPaymentCompleted:
case EventType.OutgoingPaymentFailed:
validInput = await validateInput(outgoingPaymentWebhookSchema, wh)
break
case EventType.IncomingPaymentCompleted:
validInput = await validateInput(
incomingPaymentCompletedWebhookSchema,
wh
)
break
case EventType.IncomingPaymentCreated:
case EventType.IncomingPaymentExpired:
validInput = await validateInput(incomingPaymentWebhookSchema, wh)
break
case EventType.WalletAddressNotFound:
validInput = await validateInput(walletAddressWebhookSchema, wh)
break
}
return validInput
}

private parseAmount(amount: AmountJSON): Amount {
return { ...amount, value: BigInt(amount.value) }
}
Expand Down
75 changes: 21 additions & 54 deletions packages/wallet/backend/src/rafiki/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,26 @@ const amountSchema = z.object({
assetScale: z.number()
})

const incomingPaymentCompletedSchema = z.object({
id: z.string(),
walletAddressId: z.string(),
client: z.string().optional(),
createdAt: z.string(),
expiresAt: z.string(),
incomingAmount: amountSchema.optional(),
receivedAmount: amountSchema,
completed: z.boolean(),
updatedAt: z.string(),
metadata: z.object({
description: z.string().optional()
})
})
const incomingPaymentSchema = z.object({
id: z.string(),
walletAddressId: z.string(),
client: z.string().nullable().optional(),
createdAt: z.string(),
expiresAt: z.string(),
receivedAmount: amountSchema,
completed: z.boolean(),
updatedAt: z.string(),
metadata: z.object({
description: z.string().optional()
})
completed: z.boolean(),
receivedAmount: amountSchema,
incomingAmount: amountSchema.optional(),
metadata: z
.object({
description: z.string().optional()
})
.optional()
})

const outgoingPaymentSchema = z.object({
id: z.string(),
walletAddressId: z.string(),
client: z.string().nullable(),
state: z.string(),
receiver: z.string(),
debitAmount: amountSchema,
Expand All @@ -69,22 +59,23 @@ const outgoingPaymentSchema = z.object({
createdAt: z.string(),
updatedAt: z.string(),
balance: z.string(),
metadata: z.object({
description: z.string().optional()
})
})

export const incomingPaymentCompletedWebhookSchema = z.object({
id: z.string({ required_error: 'id is required' }),
type: z.literal(EventType.IncomingPaymentCompleted),
data: incomingPaymentCompletedSchema
client: z.string().nullable().optional(),
metadata: z
.object({
description: z.string().optional()
})
.optional(),
peerId: z.string().optional(),
error: z.string().optional(),
expiresAt: z.string().optional()
})

export const incomingPaymentWebhookSchema = z.object({
id: z.string({ required_error: 'id is required' }),
type: z.enum([
EventType.IncomingPaymentCreated,
EventType.IncomingPaymentExpired
EventType.IncomingPaymentExpired,
EventType.IncomingPaymentCompleted
]),
data: incomingPaymentSchema
})
Expand All @@ -106,7 +97,6 @@ export const walletAddressWebhookSchema = z.object({
})
})
export const webhookSchema = z.discriminatedUnion('type', [
incomingPaymentCompletedWebhookSchema,
incomingPaymentWebhookSchema,
outgoingPaymentWebhookSchema,
walletAddressWebhookSchema
Expand All @@ -116,26 +106,3 @@ export const webhookBodySchema = z.object({
body: webhookSchema
})
export type WebhookType = z.infer<typeof webhookSchema>

export async function validateInput<Z extends z.AnyZodObject>(
schema: Z,
input: WebhookType
): Promise<boolean> {
try {
const res = await schema.safeParseAsync(input)
if (!res.success) {
const errors: Record<string, string> = {}
res.error.issues.forEach((i) => {
if (i.path.length > 1) {
errors[i.path[1]] = i.message
} else {
errors[i.path[0]] = i.message
}
})
return false
}
} catch (error) {
return false
}
return true
}
96 changes: 89 additions & 7 deletions packages/wallet/backend/tests/rafiki/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ import { Logger } from 'winston'
import {
mockGetRatesRequest,
mockOnWebhookRequest,
mockOutgoingPaymentCompletedEvent,
mockOutgoingPaymentCreatedEvent,
mockOutgoingPaymentFailedEvent,
mockRafikiService,
mockRatesService
} from '../mocks'
import { AwilixContainer } from 'awilix'
import { errorHandler } from '@/tests/helpers'
import { EventType } from '@/rafiki/service'

describe('Rafiki controller', () => {
let bindings: AwilixContainer<Cradle>
Expand Down Expand Up @@ -44,15 +48,15 @@ describe('Rafiki controller', () => {
)
}

describe('Get Rates', () => {
beforeAll(async () => {
bindings = await createContainer(env)
rafikiController = await bindings.resolve('rafikiController')
logger = await bindings.resolve('logger')
beforeAll(async () => {
bindings = await createContainer(env)
rafikiController = await bindings.resolve('rafikiController')
logger = await bindings.resolve('logger')

createRafikiControllerDepsMocked()
})
createRafikiControllerDepsMocked()
})

describe('Get Rates', () => {
beforeEach(async (): Promise<void> => {
req = createRequest()
res = createResponse()
Expand Down Expand Up @@ -111,6 +115,11 @@ describe('Rafiki controller', () => {
})

describe('On Webhook', () => {
beforeEach(async (): Promise<void> => {
req = createRequest()
res = createResponse()
})

it('should call onWebHook in rafikiService.', async () => {
req.body = mockOnWebhookRequest().body
const onWebHookSpy = jest.spyOn(mockRafikiService, 'onWebHook')
Expand Down Expand Up @@ -167,5 +176,78 @@ describe('Rafiki controller', () => {
expect(loggerSpy).toBeCalled()
expect(loggerSpy).toBeCalledTimes(1)
})

it('call outgoing payment should fail because invalid input', async () => {
req.body = mockOutgoingPaymentCreatedEvent({
data: { debitAmount: {} }
})

await rafikiController.onWebHook(req, res, (err) => {
next()
errorHandler(err, req, res, next)
})

expect(next).toBeCalled()
expect(next).toBeCalledTimes(1)
expect(res.statusCode).toBe(400)
})

it('call outgoing payment should fail because because invalid input', async () => {
req.body = mockOutgoingPaymentCreatedEvent({
data: { debitAmount: { value: '' } }
})

await rafikiController.onWebHook(req, res, (err) => {
next()
errorHandler(err, req, res, next)
})

expect(next).toBeCalled()
expect(next).toBeCalledTimes(1)
expect(res.statusCode).toBe(400)
})

it('call outgoing payment completed should fail because invalid input', async () => {
req.body = mockOutgoingPaymentCompletedEvent({ data: {} })

await rafikiController.onWebHook(req, res, (err) => {
next()
errorHandler(err, req, res, next)
})

expect(next).toBeCalled()
expect(next).toBeCalledTimes(1)
expect(res.statusCode).toBe(400)
})

it('call outgoing payment failed should fail because invalid data', async () => {
req.body = mockOutgoingPaymentFailedEvent({
data: { debitAmount: {} }
})

await rafikiController.onWebHook(req, res, (err) => {
next()
errorHandler(err, req, res, next)
})

expect(next).toBeCalled()
expect(next).toBeCalledTimes(1)
expect(res.statusCode).toBe(400)
})

it('should throw an error unknow event type mock-event', async () => {
req.body = mockOutgoingPaymentCreatedEvent({
type: 'mock-event' as EventType
})

await rafikiController.onWebHook(req, res, (err) => {
next()
errorHandler(err, req, res, next)
})

expect(next).toBeCalled()
expect(next).toBeCalledTimes(1)
expect(res.statusCode).toBe(400)
})
})
})
Loading

0 comments on commit 6e27610

Please sign in to comment.