Skip to content

Commit 26bcdd5

Browse files
test: add callback controller specs (#494)
* test: add lnbits callback controller specs * test: add nodeless callback controller specs * test: add opennode callback controller specs * test: add zebedee callback controller specs --------- Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent 27d8f8a commit 26bcdd5

4 files changed

Lines changed: 953 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
import chai from 'chai'
2+
import chaiAsPromised from 'chai-as-promised'
3+
import sinon from 'sinon'
4+
import sinonChai from 'sinon-chai'
5+
6+
chai.use(sinonChai)
7+
chai.use(chaiAsPromised)
8+
const { expect } = chai
9+
10+
import * as httpUtils from '../../../../src/utils/http'
11+
import * as settingsFactory from '../../../../src/factories/settings-factory'
12+
import { deriveFromSecret, hmacSha256 } from '../../../../src/utils/secret'
13+
import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice'
14+
import { LNbitsCallbackController } from '../../../../src/controllers/callbacks/lnbits-callback-controller'
15+
16+
const PAYMENT_HASH = 'a'.repeat(64)
17+
const PUBKEY = 'b'.repeat(64)
18+
const VALID_HMAC_EXPIRY = Date.parse('2100-01-01T00:00:00.000Z')
19+
20+
const baseSettings: any = {
21+
payments: { processor: 'lnbits' },
22+
network: { remoteIpHeader: 'x-forwarded-for' },
23+
}
24+
25+
const makeRes = (): any => ({
26+
status: sinon.stub().returnsThis(),
27+
setHeader: sinon.stub().returnsThis(),
28+
send: sinon.stub().returnsThis(),
29+
})
30+
31+
const makeInvoice = (overrides: any = {}) => ({
32+
id: PAYMENT_HASH,
33+
pubkey: PUBKEY,
34+
bolt11: 'lnbc210n1test',
35+
amountRequested: 21000n,
36+
unit: InvoiceUnit.MSATS,
37+
status: InvoiceStatus.COMPLETED,
38+
description: 'test invoice',
39+
confirmedAt: new Date('2030-01-01T00:00:00.000Z'),
40+
expiresAt: new Date('2030-01-01T00:15:00.000Z'),
41+
updatedAt: new Date('2030-01-01T00:00:00.000Z'),
42+
createdAt: new Date('2030-01-01T00:00:00.000Z'),
43+
...overrides,
44+
})
45+
46+
const makeController = (overrides: {
47+
paymentsService?: any
48+
invoiceRepository?: any
49+
} = {}) => {
50+
const paymentsService = overrides.paymentsService ?? {
51+
getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice()),
52+
updateInvoice: sinon.stub().resolves(),
53+
confirmInvoice: sinon.stub().resolves(),
54+
sendInvoiceUpdateNotification: sinon.stub().resolves(),
55+
}
56+
const invoiceRepository = overrides.invoiceRepository ?? {
57+
findById: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.PENDING })),
58+
}
59+
60+
return {
61+
controller: new LNbitsCallbackController(paymentsService, invoiceRepository),
62+
paymentsService,
63+
invoiceRepository,
64+
}
65+
}
66+
67+
const makeValidQuery = (expiry = VALID_HMAC_EXPIRY) => {
68+
const expiryString = String(expiry)
69+
const signature = hmacSha256(
70+
deriveFromSecret('lnbits-callback-hmac-key'),
71+
expiryString,
72+
).toString('hex')
73+
74+
return { hmac: `${expiryString}:${signature}` }
75+
}
76+
77+
const makeReq = (overrides: any = {}): any => ({
78+
headers: {},
79+
query: makeValidQuery(),
80+
body: { payment_hash: PAYMENT_HASH },
81+
socket: { remoteAddress: '1.2.3.4' },
82+
...overrides,
83+
})
84+
85+
describe('LNbitsCallbackController', () => {
86+
let createSettingsStub: sinon.SinonStub
87+
let getRemoteAddressStub: sinon.SinonStub
88+
let consoleErrorStub: sinon.SinonStub
89+
let clock: sinon.SinonFakeTimers
90+
let previousSecret: string | undefined
91+
92+
beforeEach(() => {
93+
previousSecret = process.env.SECRET
94+
process.env.SECRET = 'unit-test-secret'
95+
96+
clock = sinon.useFakeTimers(1600000000000)
97+
98+
createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings)
99+
getRemoteAddressStub = sinon.stub(httpUtils, 'getRemoteAddress').returns('1.2.3.4')
100+
consoleErrorStub = sinon.stub(console, 'error')
101+
})
102+
103+
afterEach(() => {
104+
if (previousSecret === undefined) {
105+
delete process.env.SECRET
106+
} else {
107+
process.env.SECRET = previousSecret
108+
}
109+
110+
clock.restore()
111+
createSettingsStub.restore()
112+
getRemoteAddressStub.restore()
113+
consoleErrorStub.restore()
114+
})
115+
116+
describe('authorization and validation', () => {
117+
it('returns 403 when payment processor settings are missing', async () => {
118+
createSettingsStub.returns({
119+
network: { remoteIpHeader: 'x-forwarded-for' },
120+
})
121+
const { controller, paymentsService } = makeController()
122+
const res = makeRes()
123+
124+
await controller.handleRequest(makeReq(), res)
125+
126+
expect(res.status).to.have.been.calledWith(403)
127+
expect(res.send).to.have.been.calledWith('Forbidden')
128+
expect(paymentsService.getInvoiceFromPaymentsProcessor).to.not.have.been.called
129+
})
130+
131+
it('returns 403 when lnbits is not the configured processor', async () => {
132+
createSettingsStub.returns({
133+
...baseSettings,
134+
payments: { processor: 'opennode' },
135+
})
136+
const { controller, paymentsService } = makeController()
137+
const res = makeRes()
138+
139+
await controller.handleRequest(makeReq(), res)
140+
141+
expect(res.status).to.have.been.calledWith(403)
142+
expect(res.send).to.have.been.calledWith('Forbidden')
143+
expect(paymentsService.getInvoiceFromPaymentsProcessor).to.not.have.been.called
144+
})
145+
146+
it('returns 403 for invalid query parameters', async () => {
147+
const { controller } = makeController()
148+
const res = makeRes()
149+
150+
await controller.handleRequest(makeReq({ query: {} }), res)
151+
152+
expect(res.status).to.have.been.calledWith(403)
153+
expect(res.send).to.have.been.calledWith('Forbidden')
154+
})
155+
156+
it('returns 403 when the hmac signature does not match', async () => {
157+
const { controller } = makeController()
158+
const res = makeRes()
159+
const validQuery = makeValidQuery()
160+
const [expiryString] = validQuery.hmac.split(':')
161+
162+
await controller.handleRequest(
163+
makeReq({ query: { hmac: `${expiryString}:${'c'.repeat(64)}` } }),
164+
res,
165+
)
166+
167+
expect(res.status).to.have.been.calledWith(403)
168+
expect(res.send).to.have.been.calledWith('Forbidden')
169+
})
170+
171+
it('returns 403 when the hmac expiry is not a safe integer', async () => {
172+
const { controller } = makeController()
173+
const res = makeRes()
174+
const unsafeExpiry = '9007199254740993'
175+
const signature = hmacSha256(
176+
deriveFromSecret('lnbits-callback-hmac-key'),
177+
unsafeExpiry,
178+
).toString('hex')
179+
180+
await controller.handleRequest(
181+
makeReq({ query: { hmac: `${unsafeExpiry}:${signature}` } }),
182+
res,
183+
)
184+
185+
expect(res.status).to.have.been.calledWith(403)
186+
expect(res.send).to.have.been.calledWith('Forbidden')
187+
})
188+
189+
it('returns 403 when the hmac has expired', async () => {
190+
const { controller } = makeController()
191+
const res = makeRes()
192+
193+
await controller.handleRequest(
194+
makeReq({ query: makeValidQuery(Date.now() - 60_000) }),
195+
res,
196+
)
197+
198+
expect(res.status).to.have.been.calledWith(403)
199+
expect(res.send).to.have.been.calledWith('Forbidden')
200+
})
201+
202+
it('returns 400 for an invalid callback body', async () => {
203+
const { controller } = makeController()
204+
const res = makeRes()
205+
206+
await controller.handleRequest(
207+
makeReq({ body: { invalid: true } }),
208+
res,
209+
)
210+
211+
expect(res.status).to.have.been.calledWith(400)
212+
expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8')
213+
expect(res.send).to.have.been.calledWith('Malformed body')
214+
})
215+
})
216+
217+
describe('invoice state handling', () => {
218+
it('returns 404 when invoice is not found in repository', async () => {
219+
const invoiceRepository = {
220+
findById: sinon.stub().resolves(undefined),
221+
}
222+
const { controller } = makeController({ invoiceRepository })
223+
const res = makeRes()
224+
225+
await controller.handleRequest(makeReq(), res)
226+
227+
expect(res.status).to.have.been.calledWith(404)
228+
expect(res.send).to.have.been.calledWith('No such invoice')
229+
})
230+
231+
it('returns 200 without confirmation when processor invoice is still pending', async () => {
232+
const paymentsService = {
233+
getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice({
234+
status: InvoiceStatus.PENDING,
235+
confirmedAt: null,
236+
})),
237+
updateInvoice: sinon.stub().resolves(),
238+
confirmInvoice: sinon.stub().resolves(),
239+
sendInvoiceUpdateNotification: sinon.stub().resolves(),
240+
}
241+
const { controller } = makeController({ paymentsService })
242+
const res = makeRes()
243+
244+
await controller.handleRequest(makeReq(), res)
245+
246+
expect(res.status).to.have.been.calledWith(200)
247+
expect(paymentsService.confirmInvoice).to.not.have.been.called
248+
expect(paymentsService.sendInvoiceUpdateNotification).to.not.have.been.called
249+
})
250+
251+
it('returns 409 when invoice is already marked completed in storage', async () => {
252+
const invoiceRepository = {
253+
findById: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })),
254+
}
255+
const { controller } = makeController({ invoiceRepository })
256+
const res = makeRes()
257+
258+
await controller.handleRequest(makeReq(), res)
259+
260+
expect(res.status).to.have.been.calledWith(409)
261+
expect(res.send).to.have.been.calledWith('Invoice is already marked paid')
262+
})
263+
264+
it('confirms and notifies when invoice transitions to completed', async () => {
265+
const paymentsService = {
266+
getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })),
267+
updateInvoice: sinon.stub().resolves(),
268+
confirmInvoice: sinon.stub().resolves(),
269+
sendInvoiceUpdateNotification: sinon.stub().resolves(),
270+
}
271+
const invoiceRepository = {
272+
findById: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.PENDING })),
273+
}
274+
const { controller } = makeController({ paymentsService, invoiceRepository })
275+
const res = makeRes()
276+
277+
await controller.handleRequest(makeReq(), res)
278+
279+
expect(paymentsService.confirmInvoice).to.have.been.calledOnce
280+
expect(paymentsService.sendInvoiceUpdateNotification).to.have.been.calledOnce
281+
282+
const invoice = paymentsService.confirmInvoice.firstCall.args[0]
283+
expect(invoice.amountPaid).to.equal(invoice.amountRequested)
284+
285+
expect(res.status).to.have.been.calledWith(200)
286+
expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8')
287+
expect(res.send).to.have.been.calledWith('OK')
288+
})
289+
})
290+
291+
describe('error propagation', () => {
292+
it('rejects when invoice update fails', async () => {
293+
const updateError = new Error('database unavailable')
294+
const paymentsService = {
295+
getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice()),
296+
updateInvoice: sinon.stub().rejects(updateError),
297+
confirmInvoice: sinon.stub().resolves(),
298+
sendInvoiceUpdateNotification: sinon.stub().resolves(),
299+
}
300+
const { controller } = makeController({ paymentsService })
301+
302+
await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(updateError)
303+
})
304+
305+
it('rejects when invoice confirmation fails', async () => {
306+
const confirmError = new Error('cannot confirm invoice')
307+
const paymentsService = {
308+
getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice()),
309+
updateInvoice: sinon.stub().resolves(),
310+
confirmInvoice: sinon.stub().rejects(confirmError),
311+
sendInvoiceUpdateNotification: sinon.stub().resolves(),
312+
}
313+
const { controller } = makeController({ paymentsService })
314+
315+
await expect(controller.handleRequest(makeReq(), makeRes())).to.eventually.be.rejectedWith(confirmError)
316+
})
317+
})
318+
})

0 commit comments

Comments
 (0)