From 441a794b25379526bdaade2dbf5072639fe7fea8 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sat, 18 Apr 2026 21:39:32 +0530 Subject: [PATCH 1/4] test: add lnbits callback controller specs --- .../lnbits-callback-controller.spec.ts | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts diff --git a/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts new file mode 100644 index 00000000..e8ad0d3a --- /dev/null +++ b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts @@ -0,0 +1,318 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +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 { deriveFromSecret, hmacSha256 } from '../../../../src/utils/secret' +import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' +import { LNbitsCallbackController } from '../../../../src/controllers/callbacks/lnbits-callback-controller' + +const PAYMENT_HASH = 'a'.repeat(64) +const PUBKEY = 'b'.repeat(64) +const VALID_HMAC_EXPIRY = Date.parse('2100-01-01T00:00:00.000Z') + +const baseSettings: any = { + payments: { processor: 'lnbits' }, + network: { remoteIpHeader: 'x-forwarded-for' }, +} + +const makeRes = (): any => ({ + status: sinon.stub().returnsThis(), + setHeader: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), +}) + +const makeInvoice = (overrides: any = {}) => ({ + id: PAYMENT_HASH, + pubkey: PUBKEY, + bolt11: 'lnbc210n1test', + amountRequested: 21000n, + unit: InvoiceUnit.MSATS, + status: InvoiceStatus.COMPLETED, + description: 'test invoice', + confirmedAt: new Date('2030-01-01T00:00:00.000Z'), + expiresAt: new Date('2030-01-01T00:15:00.000Z'), + updatedAt: new Date('2030-01-01T00:00:00.000Z'), + createdAt: new Date('2030-01-01T00:00:00.000Z'), + ...overrides, +}) + +const makeController = (overrides: { + paymentsService?: any + invoiceRepository?: any +} = {}) => { + const paymentsService = overrides.paymentsService ?? { + getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice()), + updateInvoice: sinon.stub().resolves(), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const invoiceRepository = overrides.invoiceRepository ?? { + findById: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.PENDING })), + } + + return { + controller: new LNbitsCallbackController(paymentsService, invoiceRepository), + paymentsService, + invoiceRepository, + } +} + +const makeValidQuery = (expiry = VALID_HMAC_EXPIRY) => { + const expiryString = String(expiry) + const signature = hmacSha256( + deriveFromSecret('lnbits-callback-hmac-key'), + expiryString, + ).toString('hex') + + return { hmac: `${expiryString}:${signature}` } +} + +const makeReq = (overrides: any = {}): any => ({ + headers: {}, + query: makeValidQuery(), + body: { payment_hash: PAYMENT_HASH }, + socket: { remoteAddress: '1.2.3.4' }, + ...overrides, +}) + +describe('LNbitsCallbackController', () => { + let createSettingsStub: sinon.SinonStub + let getRemoteAddressStub: sinon.SinonStub + let consoleErrorStub: sinon.SinonStub + let clock: sinon.SinonFakeTimers + let previousSecret: string | undefined + + beforeEach(() => { + previousSecret = process.env.SECRET + process.env.SECRET = 'unit-test-secret' + + clock = sinon.useFakeTimers(1600000000000) + + createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings) + getRemoteAddressStub = sinon.stub(httpUtils, 'getRemoteAddress').returns('1.2.3.4') + consoleErrorStub = sinon.stub(console, 'error') + }) + + afterEach(() => { + if (previousSecret === undefined) { + delete process.env.SECRET + } else { + process.env.SECRET = previousSecret + } + + clock.restore() + createSettingsStub.restore() + getRemoteAddressStub.restore() + consoleErrorStub.restore() + }) + + describe('authorization and validation', () => { + it('returns 403 when payment processor settings are missing', async () => { + createSettingsStub.returns({ + network: { remoteIpHeader: 'x-forwarded-for' }, + }) + 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.getInvoiceFromPaymentsProcessor).to.not.have.been.called + }) + + it('returns 403 when lnbits is not the configured processor', async () => { + createSettingsStub.returns({ + ...baseSettings, + payments: { processor: 'opennode' }, + }) + 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.getInvoiceFromPaymentsProcessor).to.not.have.been.called + }) + + it('returns 403 for invalid query parameters', async () => { + const { controller } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq({ query: {} }), res) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + }) + + it('returns 403 when the hmac signature does not match', async () => { + const { controller } = makeController() + const res = makeRes() + const validQuery = makeValidQuery() + const [expiryString] = validQuery.hmac.split(':') + + await controller.handleRequest( + makeReq({ query: { hmac: `${expiryString}:${'c'.repeat(64)}` } }), + res, + ) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + }) + + it('returns 403 when the hmac expiry is not a safe integer', async () => { + const { controller } = makeController() + const res = makeRes() + const unsafeExpiry = '9007199254740993' + const signature = hmacSha256( + deriveFromSecret('lnbits-callback-hmac-key'), + unsafeExpiry, + ).toString('hex') + + await controller.handleRequest( + makeReq({ query: { hmac: `${unsafeExpiry}:${signature}` } }), + res, + ) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + }) + + it('returns 403 when the hmac has expired', async () => { + const { controller } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ query: makeValidQuery(Date.now() - 60_000) }), + res, + ) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + }) + + it('returns 400 for an invalid callback body', async () => { + const { controller } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: { invalid: true } }), + 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') + }) + }) + + describe('invoice state handling', () => { + it('returns 404 when invoice is not found in repository', async () => { + const invoiceRepository = { + findById: sinon.stub().resolves(undefined), + } + const { controller } = makeController({ invoiceRepository }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(404) + expect(res.send).to.have.been.calledWith('No such invoice') + }) + + it('returns 200 without confirmation when processor invoice is still pending', async () => { + const paymentsService = { + getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice({ + status: InvoiceStatus.PENDING, + confirmedAt: null, + })), + updateInvoice: sinon.stub().resolves(), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + expect(paymentsService.confirmInvoice).to.not.have.been.called + expect(paymentsService.sendInvoiceUpdateNotification).to.not.have.been.called + }) + + it('returns 409 when invoice is already marked completed in storage', async () => { + const invoiceRepository = { + findById: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })), + } + const { controller } = makeController({ invoiceRepository }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(409) + expect(res.send).to.have.been.calledWith('Invoice is already marked paid') + }) + + it('confirms and notifies when invoice transitions to completed', async () => { + const paymentsService = { + getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })), + updateInvoice: sinon.stub().resolves(), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const invoiceRepository = { + findById: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.PENDING })), + } + const { controller } = makeController({ paymentsService, invoiceRepository }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(paymentsService.confirmInvoice).to.have.been.calledOnce + expect(paymentsService.sendInvoiceUpdateNotification).to.have.been.calledOnce + + const invoice = paymentsService.confirmInvoice.firstCall.args[0] + expect(invoice.amountPaid).to.equal(invoice.amountRequested) + + expect(res.status).to.have.been.calledWith(200) + expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') + expect(res.send).to.have.been.calledWith('OK') + }) + }) + + describe('error propagation', () => { + it('rejects when invoice update fails', async () => { + const updateError = new Error('database unavailable') + const paymentsService = { + getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice()), + updateInvoice: sinon.stub().rejects(updateError), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(updateError) + }) + + it('rejects when invoice confirmation fails', async () => { + const confirmError = new Error('cannot confirm invoice') + const paymentsService = { + getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice()), + updateInvoice: sinon.stub().resolves(), + confirmInvoice: sinon.stub().rejects(confirmError), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(confirmError) + }) + }) +}) From 44f8139b7cd7462bdd3b492666375d97b5bcd9c9 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sat, 18 Apr 2026 21:39:57 +0530 Subject: [PATCH 2/4] test: add nodeless callback controller specs --- .../nodeless-callback-controller.spec.ts | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts diff --git a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts new file mode 100644 index 00000000..34a496ed --- /dev/null +++ b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts @@ -0,0 +1,229 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) +const { expect } = chai + +import * as settingsFactory from '../../../../src/factories/settings-factory' +import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' +import { hmacSha256 } from '../../../../src/utils/secret' +import { NodelessCallbackController } from '../../../../src/controllers/callbacks/nodeless-callback-controller' + +const PUBKEY = 'a'.repeat(64) + +const baseSettings: any = { + payments: { processor: 'nodeless' }, +} + +const validBody = { + uuid: 'nodeless-invoice-id', + status: 'paid', + amount: 42, + metadata: { + requestId: PUBKEY, + description: 'Nodeless callback', + unit: 'sats', + createdAt: '2030-01-01T00:00:00.000Z', + }, +} + +const makeRes = (): any => ({ + status: sinon.stub().returnsThis(), + setHeader: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), +}) + +const makeInvoice = (overrides: any = {}) => ({ + id: validBody.uuid, + pubkey: PUBKEY, + bolt11: 'lnbc42n1test', + amountRequested: 42n, + unit: InvoiceUnit.SATS, + status: InvoiceStatus.COMPLETED, + description: 'Nodeless callback', + confirmedAt: new Date('2030-01-01T00:01:00.000Z'), + expiresAt: new Date('2030-01-01T00:15:00.000Z'), + updatedAt: new Date('2030-01-01T00:01:00.000Z'), + createdAt: new Date('2030-01-01T00:00:00.000Z'), + ...overrides, +}) + +const makeController = (overrides: { + paymentsService?: any +} = {}) => { + const paymentsService = overrides.paymentsService ?? { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice()), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + + return { + controller: new NodelessCallbackController(paymentsService), + paymentsService, + } +} + +const makeSignature = (rawBody: Buffer) => hmacSha256( + process.env.NODELESS_WEBHOOK_SECRET as string, + rawBody, +).toString('hex') + +const makeReq = (overrides: any = {}): any => { + const body = overrides.body ?? validBody + const rawBody = overrides.rawBody ?? Buffer.from(JSON.stringify(body)) + const signature = overrides.signature ?? makeSignature(rawBody) + + return { + headers: { + 'nodeless-signature': signature, + ...(overrides.headers ?? {}), + }, + body, + rawBody, + ...overrides, + } +} + +describe('NodelessCallbackController', () => { + let createSettingsStub: sinon.SinonStub + let consoleErrorStub: sinon.SinonStub + let previousWebhookSecret: string | undefined + + beforeEach(() => { + previousWebhookSecret = process.env.NODELESS_WEBHOOK_SECRET + process.env.NODELESS_WEBHOOK_SECRET = 'nodeless-test-secret' + + createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings) + consoleErrorStub = sinon.stub(console, 'error') + }) + + afterEach(() => { + if (previousWebhookSecret === undefined) { + delete process.env.NODELESS_WEBHOOK_SECRET + } else { + process.env.NODELESS_WEBHOOK_SECRET = previousWebhookSecret + } + + createSettingsStub.restore() + consoleErrorStub.restore() + }) + + describe('authorization and validation', () => { + it('returns 400 for malformed request body', async () => { + const { controller } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: { uuid: 'missing-required-fields' } }), + res, + ) + + expect(res.status).to.have.been.calledWith(400) + expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf8') + expect(res.send).to.have.been.calledWith('{"status":"error","message":"Malformed body"}') + }) + + it('returns 403 when callback signature is invalid', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ signature: 'invalid-signature' }), + 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 403 when nodeless is not the configured processor', async () => { + createSettingsStub.returns({ payments: { processor: 'zebedee' } }) + 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 + }) + }) + + describe('invoice state handling', () => { + it('returns 200 without confirmation when invoice is not completed', async () => { + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ + status: InvoiceStatus.PENDING, + confirmedAt: null, + })), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: { ...validBody, status: 'new' } }), + res, + ) + + expect(res.status).to.have.been.calledWith(200) + expect(paymentsService.confirmInvoice).to.not.have.been.called + expect(paymentsService.sendInvoiceUpdateNotification).to.not.have.been.called + }) + + it('updates, confirms, and notifies when invoice is completed', async () => { + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + const updateArg = paymentsService.updateInvoiceStatus.firstCall.args[0] + expect(updateArg.id).to.equal(validBody.uuid) + expect(updateArg.pubkey).to.equal(PUBKEY) + expect(updateArg.amountRequested).to.equal(42n) + + expect(paymentsService.confirmInvoice).to.have.been.calledOnce + expect(paymentsService.sendInvoiceUpdateNotification).to.have.been.calledOnce + + expect(res.status).to.have.been.calledWith(200) + expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf8') + expect(res.send).to.have.been.calledWith('{"status":"ok"}') + }) + }) + + describe('error propagation', () => { + it('rejects when invoice persistence fails', async () => { + const updateError = new Error('update failed') + const paymentsService = { + updateInvoiceStatus: sinon.stub().rejects(updateError), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(updateError) + }) + + it('rejects when invoice confirmation fails', async () => { + const confirmError = new Error('confirmation failed') + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice()), + confirmInvoice: sinon.stub().rejects(confirmError), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(confirmError) + }) + }) +}) From 7d44d122a3c0c975fe83b93c09e13f2f4afa4221 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sat, 18 Apr 2026 21:40:22 +0530 Subject: [PATCH 3/4] test: add opennode callback controller specs --- .../opennode-callback-controller.spec.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 test/unit/controllers/callbacks/opennode-callback-controller.spec.ts diff --git a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts new file mode 100644 index 00000000..662f1aad --- /dev/null +++ b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts @@ -0,0 +1,171 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) +const { expect } = chai + +import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' +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 makeRes = (): any => ({ + status: sinon.stub().returnsThis(), + setHeader: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), +}) + +const makeInvoice = (overrides: any = {}) => ({ + id: validBody.id, + pubkey: PUBKEY, + bolt11: 'lnbc210n1test', + amountRequested: 21n, + unit: InvoiceUnit.SATS, + status: InvoiceStatus.COMPLETED, + description: '', + confirmedAt: new Date('2030-01-01T00:01:00.000Z'), + expiresAt: new Date('2030-01-01T00:15:00.000Z'), + updatedAt: new Date('2030-01-01T00:01:00.000Z'), + createdAt: new Date('2030-01-01T00:00:00.000Z'), + ...overrides, +}) + +const makeController = (overrides: { + paymentsService?: any +} = {}) => { + const paymentsService = overrides.paymentsService ?? { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice()), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + + return { + controller: new OpenNodeCallbackController(paymentsService), + paymentsService, + } +} + +const makeReq = (overrides: any = {}): any => ({ + headers: {}, + body: validBody, + ...overrides, +}) + +describe('OpenNodeCallbackController', () => { + let consoleErrorStub: sinon.SinonStub + + beforeEach(() => { + consoleErrorStub = sinon.stub(console, 'error') + }) + + afterEach(() => { + consoleErrorStub.restore() + }) + + describe('validation', () => { + it('returns 400 for malformed request body', async () => { + const { controller } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: { id: 'missing-order-id' } }), + 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') + }) + }) + + describe('invoice state handling', () => { + it('returns 200 without confirmation for pending invoices', async () => { + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ + status: InvoiceStatus.PENDING, + confirmedAt: null, + })), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: { ...validBody, status: 'processing' } }), + res, + ) + + expect(res.status).to.have.been.calledWith(200) + expect(paymentsService.confirmInvoice).to.not.have.been.called + expect(paymentsService.sendInvoiceUpdateNotification).to.not.have.been.called + }) + + it('confirms and notifies for completed invoices', async () => { + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(paymentsService.confirmInvoice).to.have.been.calledOnce + expect(paymentsService.sendInvoiceUpdateNotification).to.have.been.calledOnce + + expect(paymentsService.confirmInvoice).to.have.been.calledWithMatch({ + id: validBody.id, + pubkey: PUBKEY, + status: InvoiceStatus.COMPLETED, + amountPaid: 21n, + }) + + expect(res.status).to.have.been.calledWith(200) + expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') + expect(res.send).to.have.been.calledWith('OK') + }) + }) + + describe('error propagation', () => { + it('rejects when status update fails', async () => { + const updateError = new Error('update failed') + const paymentsService = { + updateInvoiceStatus: sinon.stub().rejects(updateError), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(updateError) + }) + + it('rejects when invoice confirmation fails', async () => { + const confirmError = new Error('confirm failed') + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice()), + confirmInvoice: sinon.stub().rejects(confirmError), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(confirmError) + }) + }) +}) From 1a1adb34ce703fde33ab0d2a16e4ebd10eda56f3 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sat, 18 Apr 2026 21:40:47 +0530 Subject: [PATCH 4/4] test: add zebedee callback controller specs --- .../zebedee-callback-controller.spec.ts | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts diff --git a/test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts b/test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts new file mode 100644 index 00000000..96f35dce --- /dev/null +++ b/test/unit/controllers/callbacks/zebedee-callback-controller.spec.ts @@ -0,0 +1,235 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +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 { ZebedeeCallbackController } from '../../../../src/controllers/callbacks/zebedee-callback-controller' + +const PUBKEY = 'a'.repeat(64) + +const baseSettings: any = { + payments: { processor: 'zebedee' }, + paymentsProcessors: { + zebedee: { ipWhitelist: [] }, + }, + network: { remoteIpHeader: 'x-forwarded-for' }, +} + +const validBody = { + id: 'zebedee-invoice-id', + status: 'completed', + internalId: PUBKEY, + amount: '1000', + description: 'Zebedee callback', + unit: 'msats', + confirmedAt: '2030-01-01T00:01:00.000Z', + invoice: { + request: 'lnbc1zebedeeinvoice', + }, +} + +const makeRes = (): any => ({ + status: sinon.stub().returnsThis(), + setHeader: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), +}) + +const makeInvoice = (overrides: any = {}) => ({ + id: validBody.id, + pubkey: PUBKEY, + bolt11: validBody.invoice.request, + amountRequested: 1000n, + unit: InvoiceUnit.MSATS, + status: InvoiceStatus.COMPLETED, + description: validBody.description, + confirmedAt: new Date('2030-01-01T00:01:00.000Z'), + expiresAt: new Date('2030-01-01T00:15:00.000Z'), + updatedAt: new Date('2030-01-01T00:01:00.000Z'), + createdAt: new Date('2030-01-01T00:00:00.000Z'), + ...overrides, +}) + +const makeController = (overrides: { + paymentsService?: any +} = {}) => { + const paymentsService = overrides.paymentsService ?? { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice()), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + + return { + controller: new ZebedeeCallbackController(paymentsService), + paymentsService, + } +} + +const makeReq = (overrides: any = {}): any => ({ + headers: {}, + body: validBody, + socket: { remoteAddress: '1.2.3.4' }, + ...overrides, +}) + +describe('ZebedeeCallbackController', () => { + let createSettingsStub: sinon.SinonStub + let getRemoteAddressStub: sinon.SinonStub + let consoleErrorStub: sinon.SinonStub + + beforeEach(() => { + createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings) + getRemoteAddressStub = sinon.stub(httpUtils, 'getRemoteAddress').returns('1.2.3.4') + consoleErrorStub = sinon.stub(console, 'error') + }) + + afterEach(() => { + createSettingsStub.restore() + getRemoteAddressStub.restore() + consoleErrorStub.restore() + }) + + describe('authorization and validation', () => { + it('allows request when zebedee whitelist settings are missing', async () => { + createSettingsStub.returns({ + payments: { processor: 'zebedee' }, + network: { remoteIpHeader: 'x-forwarded-for' }, + }) + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnce + expect(res.status).to.have.been.calledWith(200) + expect(res.send).to.have.been.calledWith('OK') + }) + + it('returns 400 for malformed request body', async () => { + const { controller } = makeController() + const res = makeRes() + + 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') + }) + + it('returns 403 when remote IP is not in whitelist', async () => { + createSettingsStub.returns({ + ...baseSettings, + paymentsProcessors: { + zebedee: { ipWhitelist: ['9.9.9.9'] }, + }, + }) + 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 403 when zebedee is not the configured processor', async () => { + createSettingsStub.returns({ + ...baseSettings, + 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 + }) + }) + + describe('invoice state handling', () => { + it('returns 200 without confirmation for pending invoices', async () => { + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ + status: InvoiceStatus.PENDING, + confirmedAt: null, + })), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + const res = makeRes() + + await controller.handleRequest( + makeReq({ body: { ...validBody, status: 'pending' } }), + res, + ) + + expect(res.status).to.have.been.calledWith(200) + expect(paymentsService.confirmInvoice).to.not.have.been.called + expect(paymentsService.sendInvoiceUpdateNotification).to.not.have.been.called + }) + + it('confirms and notifies for completed invoices', async () => { + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(paymentsService.confirmInvoice).to.have.been.calledOnce + expect(paymentsService.sendInvoiceUpdateNotification).to.have.been.calledOnce + expect(paymentsService.confirmInvoice).to.have.been.calledWithMatch({ + id: validBody.id, + pubkey: PUBKEY, + status: InvoiceStatus.COMPLETED, + amountPaid: 1000n, + }) + + expect(res.status).to.have.been.calledWith(200) + expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') + expect(res.send).to.have.been.calledWith('OK') + }) + }) + + describe('error propagation', () => { + it('rejects when invoice update fails', async () => { + const updateError = new Error('update failed') + const paymentsService = { + updateInvoiceStatus: sinon.stub().rejects(updateError), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(updateError) + }) + + it('rejects when invoice confirmation fails', async () => { + const confirmError = new Error('confirm failed') + const paymentsService = { + updateInvoiceStatus: sinon.stub().resolves(makeInvoice()), + confirmInvoice: sinon.stub().rejects(confirmError), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService }) + + await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(confirmError) + }) + }) +})