diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index ddda9cb8..350f0631 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -1,11 +1,15 @@ +import { timingSafeEqual } from 'crypto' + import { Request, Response } from 'express' import { Invoice, InvoiceStatus } from '../../@types/invoice' import { createLogger } from '../../factories/logger-factory' -import { fromOpenNodeInvoice } from '../../utils/transform' +import { createSettings } from '../../factories/settings-factory' +import { getRemoteAddress } from '../../utils/http' +import { hmacSha256 } from '../../utils/secret' import { IController } from '../../@types/controllers' import { IPaymentsService } from '../../@types/services' -import { opennodeCallbackBodySchema } from '../../schemas/opennode-callback-schema' +import { opennodeWebhookCallbackBodySchema } from '../../schemas/opennode-callback-schema' import { validateSchema } from '../../utils/validation' const debug = createLogger('opennode-callback-controller') @@ -15,16 +19,85 @@ export class OpenNodeCallbackController implements IController { public async handleRequest(request: Request, response: Response) { debug('request headers: %o', request.headers) - debug('request body: %O', request.body) - const bodyValidation = validateSchema(opennodeCallbackBodySchema)(request.body) + const settings = createSettings() + const remoteAddress = getRemoteAddress(request, settings) + const paymentProcessor = settings.payments?.processor + + if (paymentProcessor !== 'opennode') { + debug('denied request from %s to /callbacks/opennode which is not the current payment processor', remoteAddress) + response + .status(403) + .send('Forbidden') + return + } + + const bodyValidation = validateSchema(opennodeWebhookCallbackBodySchema)(request.body) if (bodyValidation.error) { debug('opennode callback request rejected: invalid body %o', bodyValidation.error) response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Malformed body') return } - const invoice = fromOpenNodeInvoice(request.body) + const body = bodyValidation.value + debug( + 'request body metadata: hasId=%s hasHashedOrder=%s status=%s', + typeof body.id === 'string', + typeof body.hashed_order === 'string', + body.status, + ) + + const openNodeApiKey = process.env.OPENNODE_API_KEY + if (!openNodeApiKey) { + debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress) + response + .status(500) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('Internal Server Error') + return + } + + const expectedBuf = hmacSha256(openNodeApiKey, body.id) + const actualHex = body.hashed_order + const expectedHexLength = expectedBuf.length * 2 + + if ( + actualHex.length !== expectedHexLength + || !/^[0-9a-f]+$/i.test(actualHex) + ) { + debug('invalid hashed_order format from %s to /callbacks/opennode', remoteAddress) + response + .status(400) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('Bad Request') + return + } + + const actualBuf = Buffer.from(actualHex, 'hex') + + if ( + !timingSafeEqual(expectedBuf, actualBuf) + ) { + debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress) + response + .status(403) + .send('Forbidden') + return + } + + const statusMap: Record = { + expired: InvoiceStatus.EXPIRED, + refunded: InvoiceStatus.EXPIRED, + unpaid: InvoiceStatus.PENDING, + processing: InvoiceStatus.PENDING, + underpaid: InvoiceStatus.PENDING, + paid: InvoiceStatus.COMPLETED, + } + + const invoice: Pick = { + id: body.id, + status: statusMap[body.status], + } debug('invoice', invoice) @@ -37,21 +110,25 @@ export class OpenNodeCallbackController implements IController { throw error } - if (updatedInvoice.status !== InvoiceStatus.COMPLETED && !updatedInvoice.confirmedAt) { - response.status(200).send() + if (updatedInvoice.status !== InvoiceStatus.COMPLETED) { + response + .status(200) + .send() return } - invoice.amountPaid = invoice.amountRequested - updatedInvoice.amountPaid = invoice.amountRequested + if (!updatedInvoice.confirmedAt) { + updatedInvoice.confirmedAt = new Date() + } + updatedInvoice.amountPaid = updatedInvoice.amountRequested try { await this.paymentsService.confirmInvoice({ - id: invoice.id, - pubkey: invoice.pubkey, + id: updatedInvoice.id, + pubkey: updatedInvoice.pubkey, status: updatedInvoice.status, - amountPaid: updatedInvoice.amountRequested, + amountPaid: updatedInvoice.amountPaid, confirmedAt: updatedInvoice.confirmedAt, }) await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice) diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index d522e31c..86ee169d 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -1,4 +1,4 @@ -import { json, Router } from 'express' +import { json, Router, urlencoded } from 'express' import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory' import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory' @@ -20,6 +20,6 @@ router }), withController(createNodelessCallbackController), ) - .post('/opennode', json(), withController(createOpenNodeCallbackController)) + .post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) export default router diff --git a/src/schemas/opennode-callback-schema.ts b/src/schemas/opennode-callback-schema.ts index e11bdffd..4f932028 100644 --- a/src/schemas/opennode-callback-schema.ts +++ b/src/schemas/opennode-callback-schema.ts @@ -1,6 +1,16 @@ import { pubkeySchema } from './base-schema' import { z } from 'zod' +const openNodeCallbackStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid'] as const + +export const opennodeWebhookCallbackBodySchema = z + .object({ + id: z.string(), + hashed_order: z.string(), + status: z.enum(openNodeCallbackStatuses), + }) + .passthrough() + export const opennodeCallbackBodySchema = z .object({ id: z.string(), diff --git a/test/integration/features/callbacks/opennode-callback.feature b/test/integration/features/callbacks/opennode-callback.feature new file mode 100644 index 00000000..2b6b3ffa --- /dev/null +++ b/test/integration/features/callbacks/opennode-callback.feature @@ -0,0 +1,28 @@ +@opennode-callback +Feature: OpenNode callback endpoint + Scenario: rejects malformed callback body + Given OpenNode callback processing is enabled + When I post a malformed OpenNode callback + Then the OpenNode callback response status is 400 + And the OpenNode callback response body is "Malformed body" + + Scenario: rejects callback with invalid signature + Given OpenNode callback processing is enabled + When I post an OpenNode callback with an invalid signature + Then the OpenNode callback response status is 403 + And the OpenNode callback response body is "Forbidden" + + Scenario: accepts valid signed callback for pending invoice + Given OpenNode callback processing is enabled + And a pending OpenNode invoice exists + When I post a signed OpenNode callback with status "processing" + Then the OpenNode callback response status is 200 + And the OpenNode callback response body is empty + + Scenario: completes a pending invoice on paid callback + Given OpenNode callback processing is enabled + And a pending OpenNode invoice exists + When I post a signed OpenNode callback with status "paid" + Then the OpenNode callback response status is 200 + And the OpenNode callback response body is "OK" + And the OpenNode invoice is marked completed diff --git a/test/integration/features/callbacks/opennode-callback.feature.ts b/test/integration/features/callbacks/opennode-callback.feature.ts new file mode 100644 index 00000000..0678e580 --- /dev/null +++ b/test/integration/features/callbacks/opennode-callback.feature.ts @@ -0,0 +1,147 @@ +import { After, Given, Then, When } from '@cucumber/cucumber' +import axios, { AxiosResponse } from 'axios' +import { expect } from 'chai' +import { randomUUID } from 'crypto' + +import { getMasterDbClient } from '../../../../src/database/client' +import { hmacSha256 } from '../../../../src/utils/secret' +import { SettingsStatic } from '../../../../src/utils/settings' + +const CALLBACK_URL = 'http://localhost:18808/callbacks/opennode' +const OPENNODE_TEST_API_KEY = 'integration-opennode-api-key' +const TEST_PUBKEY = 'a'.repeat(64) + +const postOpenNodeCallback = async (body: Record) => { + const encodedBody = new URLSearchParams(body).toString() + + return axios.post( + CALLBACK_URL, + encodedBody, + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + validateStatus: () => true, + }, + ) +} + +Given('OpenNode callback processing is enabled', function () { + const settings = SettingsStatic._settings as any + + this.parameters.previousOpenNodeCallbackSettings = settings + this.parameters.previousOpenNodeApiKey = process.env.OPENNODE_API_KEY + + SettingsStatic._settings = { + ...settings, + payments: { + ...(settings?.payments ?? {}), + processor: 'opennode', + }, + } + + process.env.OPENNODE_API_KEY = OPENNODE_TEST_API_KEY +}) + +Given('a pending OpenNode invoice exists', async function () { + const dbClient = getMasterDbClient() + const invoiceId = `integration-opennode-${randomUUID()}` + + await dbClient('invoices').insert({ + id: invoiceId, + pubkey: Buffer.from(TEST_PUBKEY, 'hex'), + bolt11: 'lnbc210n1integration', + amount_requested: '21000', + unit: 'sats', + status: 'pending', + description: 'open node integration callback test', + expires_at: new Date(Date.now() + 15 * 60 * 1000), + updated_at: new Date(), + created_at: new Date(), + }) + + this.parameters.openNodeInvoiceId = invoiceId + this.parameters.openNodeInvoiceIds = [ + ...(this.parameters.openNodeInvoiceIds ?? []), + invoiceId, + ] +}) + +When('I post a malformed OpenNode callback', async function () { + this.parameters.openNodeResponse = await postOpenNodeCallback({ + id: 'missing-required-fields', + }) +}) + +When('I post an OpenNode callback with an invalid signature', async function () { + this.parameters.openNodeResponse = await postOpenNodeCallback({ + hashed_order: '0'.repeat(64), + id: `integration-opennode-${randomUUID()}`, + status: 'paid', + }) +}) + +When('I post a signed OpenNode callback with status {string}', async function (status: string) { + const id = this.parameters.openNodeInvoiceId + const hashedOrder = hmacSha256(OPENNODE_TEST_API_KEY, id).toString('hex') + + this.parameters.openNodeResponse = await postOpenNodeCallback({ + hashed_order: hashedOrder, + id, + status, + }) +}) + +Then('the OpenNode callback response status is {int}', function (statusCode: number) { + const response = this.parameters.openNodeResponse as AxiosResponse + + expect(response.status).to.equal(statusCode) +}) + +Then('the OpenNode callback response body is {string}', function (expectedBody: string) { + const response = this.parameters.openNodeResponse as AxiosResponse + + expect(response.data).to.equal(expectedBody) +}) + +Then('the OpenNode callback response body is empty', function () { + const response = this.parameters.openNodeResponse as AxiosResponse + + expect(['', undefined, null]).to.include(response.data) +}) + +Then('the OpenNode invoice is marked completed', async function () { + const dbClient = getMasterDbClient() + const invoiceId = this.parameters.openNodeInvoiceId + + const invoice = await dbClient('invoices') + .where('id', invoiceId) + .first('status', 'confirmed_at', 'amount_paid') + + expect(invoice).to.exist + expect(invoice.status).to.equal('completed') + expect(invoice.confirmed_at).to.not.equal(null) + expect(invoice.amount_paid).to.equal('21000') +}) + +After({ tags: '@opennode-callback' }, async function () { + SettingsStatic._settings = this.parameters.previousOpenNodeCallbackSettings + + if (typeof this.parameters.previousOpenNodeApiKey === 'undefined') { + delete process.env.OPENNODE_API_KEY + } else { + process.env.OPENNODE_API_KEY = this.parameters.previousOpenNodeApiKey + } + + const invoiceIds = this.parameters.openNodeInvoiceIds ?? [] + if (invoiceIds.length > 0) { + const dbClient = getMasterDbClient() + await dbClient('invoices').whereIn('id', invoiceIds).delete() + } + + this.parameters.openNodeInvoiceId = undefined + this.parameters.openNodeInvoiceIds = [] + this.parameters.openNodeResponse = undefined + this.parameters.previousOpenNodeApiKey = undefined + this.parameters.previousOpenNodeCallbackSettings = undefined +}) diff --git a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts index 0530c1fa..744669f2 100644 --- a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts @@ -7,21 +7,16 @@ chai.use(sinonChai) chai.use(chaiAsPromised) const { expect } = chai +import * as httpUtils from '../../../../src/utils/http' +import * as settingsFactory from '../../../../src/factories/settings-factory' import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' +import { hmacSha256 } from '../../../../src/utils/secret' import { OpenNodeCallbackController } from '../../../../src/controllers/callbacks/opennode-callback-controller' const PUBKEY = 'a'.repeat(64) -const validBody = { - id: 'opennode-invoice-id', - status: 'paid', - order_id: PUBKEY, - amount: 21, - created_at: 1672531200, - lightning_invoice: { - payreq: 'lnbc210n1test', - expires_at: 1672532200, - }, +const baseSettings: any = { + payments: { processor: 'opennode' }, } const makeRes = (): any => ({ @@ -31,7 +26,7 @@ const makeRes = (): any => ({ }) const makeInvoice = (overrides: any = {}) => ({ - id: validBody.id, + id: 'opennode-invoice-id', pubkey: PUBKEY, bolt11: 'lnbc210n1test', amountRequested: 21n, @@ -58,33 +53,145 @@ const makeController = (overrides: { paymentsService?: any } = {}) => { } } +const makeBody = (overrides: any = {}) => { + const id = overrides.id ?? 'opennode-invoice-id' + const openNodeApiKey = process.env.OPENNODE_API_KEY as string + + return { + id, + status: 'paid', + hashed_order: hmacSha256(openNodeApiKey, id).toString('hex'), + ...overrides, + } +} + const makeReq = (overrides: any = {}): any => ({ headers: {}, - body: validBody, + body: overrides.body ?? makeBody(), ...overrides, }) describe('OpenNodeCallbackController', () => { + let createSettingsStub: sinon.SinonStub + let getRemoteAddressStub: sinon.SinonStub let consoleErrorStub: sinon.SinonStub + let previousOpenNodeApiKey: string | undefined beforeEach(() => { + previousOpenNodeApiKey = process.env.OPENNODE_API_KEY + process.env.OPENNODE_API_KEY = 'test-api-key' + + createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings) + getRemoteAddressStub = sinon.stub(httpUtils, 'getRemoteAddress').returns('1.2.3.4') consoleErrorStub = sinon.stub(console, 'error') }) afterEach(() => { + if (previousOpenNodeApiKey === undefined) { + delete process.env.OPENNODE_API_KEY + } else { + process.env.OPENNODE_API_KEY = previousOpenNodeApiKey + } + + createSettingsStub.restore() + getRemoteAddressStub.restore() consoleErrorStub.restore() }) - describe('validation', () => { + describe('authorization and validation', () => { + it('returns 403 when opennode is not the configured processor', async () => { + createSettingsStub.returns({ + payments: { processor: 'lnbits' }, + }) + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + it('returns 400 for malformed request body', async () => { - const { controller } = makeController() + const { controller, paymentsService } = makeController() const res = makeRes() - await controller.handleRequest(makeReq({ body: { id: 'missing-order-id' } }), res) + await controller.handleRequest( + makeReq({ body: { id: 'missing-required-fields' } }), + res, + ) expect(res.status).to.have.been.calledWith(400) expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') expect(res.send).to.have.been.calledWith('Malformed body') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 400 for unknown status values', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: makeBody({ status: 'totally_made_up' }) }), + res, + ) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Malformed body') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 500 when OPENNODE_API_KEY is missing', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + delete process.env.OPENNODE_API_KEY + + await controller.handleRequest( + makeReq({ + body: { + hashed_order: 'some-hash', + id: 'invoice-id', + status: 'paid', + }, + }), + res, + ) + + expect(res.status).to.have.been.calledWith(500) + expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') + expect(res.send).to.have.been.calledWith('Internal Server Error') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 400 for malformed hashed_order', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: makeBody({ hashed_order: 'invalid' }) }), + res, + ) + + expect(res.status).to.have.been.calledWith(400) + expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') + expect(res.send).to.have.been.calledWith('Bad Request') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 403 for mismatched hashed_order', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: makeBody({ hashed_order: '0'.repeat(64) }) }), + res, + ) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) }) @@ -103,7 +210,10 @@ describe('OpenNodeCallbackController', () => { const { controller } = makeController({ paymentsService }) const res = makeRes() - await controller.handleRequest(makeReq({ body: { ...validBody, status: 'processing' } }), res) + await controller.handleRequest( + makeReq({ body: makeBody({ status: 'processing' }) }), + res, + ) expect(res.status).to.have.been.calledWith(200) expect(paymentsService.confirmInvoice).to.not.have.been.called @@ -112,7 +222,10 @@ describe('OpenNodeCallbackController', () => { it('confirms and notifies for completed invoices', async () => { const paymentsService = { - updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })), + updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ + confirmedAt: null, + status: InvoiceStatus.COMPLETED, + })), confirmInvoice: sinon.stub().resolves(), sendInvoiceUpdateNotification: sinon.stub().resolves(), } @@ -125,11 +238,13 @@ describe('OpenNodeCallbackController', () => { expect(paymentsService.sendInvoiceUpdateNotification).to.have.been.calledOnce expect(paymentsService.confirmInvoice).to.have.been.calledWithMatch({ - id: validBody.id, + amountPaid: 21n, + id: 'opennode-invoice-id', pubkey: PUBKEY, status: InvoiceStatus.COMPLETED, - amountPaid: 21n, }) + const confirmedAtArg = paymentsService.confirmInvoice.firstCall.args[0].confirmedAt + expect(confirmedAtArg).to.be.instanceOf(Date) expect(res.status).to.have.been.calledWith(200) expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') @@ -138,7 +253,7 @@ describe('OpenNodeCallbackController', () => { }) describe('error propagation', () => { - it('rejects when status update fails', async () => { + it('rejects when invoice status update fails', async () => { const updateError = new Error('update failed') const paymentsService = { updateInvoiceStatus: sinon.stub().rejects(updateError), diff --git a/test/unit/routes/callbacks.spec.ts b/test/unit/routes/callbacks.spec.ts new file mode 100644 index 00000000..4d5e867c --- /dev/null +++ b/test/unit/routes/callbacks.spec.ts @@ -0,0 +1,80 @@ +import axios from 'axios' +import { expect } from 'chai' +import express from 'express' +import Sinon from 'sinon' + +import * as openNodeControllerFactory from '../../../src/factories/controllers/opennode-callback-controller-factory' + +describe('callbacks router', () => { + let createOpenNodeCallbackControllerStub: Sinon.SinonStub + let receivedBody: unknown + let server: any + + beforeEach(async () => { + receivedBody = undefined + + createOpenNodeCallbackControllerStub = Sinon.stub(openNodeControllerFactory, 'createOpenNodeCallbackController').returns({ + handleRequest: async (request: any, response: any) => { + receivedBody = request.body + response.status(200).send('OK') + }, + } as any) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/callbacks')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + const router = require('../../../src/routes/callbacks').default + + const app = express() + app.use(router) + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, () => resolve(listeningServer)) + }) + }) + + afterEach(async () => { + createOpenNodeCallbackControllerStub.restore() + delete require.cache[require.resolve('../../../src/routes/callbacks')] + + if (server) { + await new Promise((resolve, reject) => { + server.close((error: Error | undefined) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + } + }) + + it('parses form-urlencoded OpenNode callbacks', async () => { + const { port } = server.address() + const response = await axios.post( + `http://127.0.0.1:${port}/opennode`, + new URLSearchParams({ + hashed_order: 'signature', + id: 'invoice-id', + order_id: 'pubkey', + status: 'paid', + }).toString(), + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + validateStatus: () => true, + }, + ) + + expect(response.status).to.equal(200) + expect(receivedBody).to.deep.equal({ + hashed_order: 'signature', + id: 'invoice-id', + order_id: 'pubkey', + status: 'paid', + }) + }) +}) \ No newline at end of file diff --git a/test/unit/schemas/opennode-callback-schema.spec.ts b/test/unit/schemas/opennode-callback-schema.spec.ts index ca5b62de..ad36e019 100644 --- a/test/unit/schemas/opennode-callback-schema.spec.ts +++ b/test/unit/schemas/opennode-callback-schema.spec.ts @@ -1,8 +1,39 @@ +import { opennodeCallbackBodySchema, opennodeWebhookCallbackBodySchema } from '../../../src/schemas/opennode-callback-schema' import { expect } from 'chai' -import { opennodeCallbackBodySchema } from '../../../src/schemas/opennode-callback-schema' import { validateSchema } from '../../../src/utils/validation' describe('OpenNode Callback Schema', () => { + describe('opennodeWebhookCallbackBodySchema', () => { + const validWebhookBody = { + hashed_order: 'a'.repeat(64), + id: 'some-id', + status: 'paid', + } + + it('returns no error if webhook body is valid', () => { + const result = validateSchema(opennodeWebhookCallbackBodySchema)(validWebhookBody) + expect(result.error).to.be.undefined + }) + + it('returns error if hashed_order is missing', () => { + const body = { ...validWebhookBody } + delete (body as any).hashed_order + const result = validateSchema(opennodeWebhookCallbackBodySchema)(body) + expect(result.error).to.exist + expect(result.error?.issues[0].path).to.deep.equal(['hashed_order']) + }) + + it('returns error if status is not in accepted values', () => { + const body = { + ...validWebhookBody, + status: 'not-a-valid-status', + } + const result = validateSchema(opennodeWebhookCallbackBodySchema)(body) + expect(result.error).to.exist + expect(result.error?.issues[0].path).to.deep.equal(['status']) + }) + }) + describe('opennodeCallbackBodySchema', () => { const validBody = { id: 'some-id',