Skip to content

Commit bdf710f

Browse files
committed
test: add missing unit tests for html, template-cache, request handlers, post-invoice controller
1 parent c1c9c7e commit bdf710f

6 files changed

Lines changed: 810 additions & 0 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
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 templateCache from '../../../../src/utils/template-cache'
11+
import * as eventUtils from '../../../../src/utils/event'
12+
import { PostInvoiceController } from '../../../../src/controllers/invoices/post-invoice-controller'
13+
14+
const VALID_PUBKEY = 'a'.repeat(64)
15+
16+
const baseSettings = {
17+
info: {
18+
name: 'Test Relay',
19+
relay_url: 'wss://relay.example.com',
20+
},
21+
payments: {
22+
enabled: true,
23+
processor: 'lnbits',
24+
feeSchedules: {
25+
admission: [{ enabled: true, amount: 21000, whitelists: {} }],
26+
},
27+
},
28+
limits: {},
29+
}
30+
31+
const makeController = (overrides: {
32+
settings?: () => any
33+
userRepository?: any
34+
paymentsService?: any
35+
rateLimiter?: any
36+
} = {}) => {
37+
return new PostInvoiceController(
38+
overrides.userRepository ?? { findByPubkey: sinon.stub().resolves(null) },
39+
overrides.paymentsService ?? {
40+
createInvoice: sinon.stub().resolves({
41+
id: 'inv-ref-123',
42+
bolt11: 'lnbc210n1...',
43+
expiresAt: new Date('2030-01-01T00:00:00Z'),
44+
}),
45+
},
46+
overrides.settings ?? (() => baseSettings),
47+
overrides.rateLimiter ?? (() => ({ hit: sinon.stub().resolves(false) })),
48+
)
49+
}
50+
51+
const makeRes = () => ({
52+
status: sinon.stub().returnsThis(),
53+
setHeader: sinon.stub().returnsThis(),
54+
send: sinon.stub().returnsThis(),
55+
locals: { nonce: 'post-inv-nonce' },
56+
})
57+
58+
const validBody = {
59+
tosAccepted: 'yes',
60+
feeSchedule: 'admission',
61+
pubkey: VALID_PUBKEY,
62+
}
63+
64+
describe('PostInvoiceController', () => {
65+
let getTemplateStub: sinon.SinonStub
66+
let getRelayPrivateKeyStub: sinon.SinonStub
67+
let getPublicKeyStub: sinon.SinonStub
68+
69+
beforeEach(() => {
70+
getTemplateStub = sinon.stub(templateCache, 'getTemplate').returns('{{name}}|{{nonce}}')
71+
getRelayPrivateKeyStub = sinon.stub(eventUtils, 'getRelayPrivateKey').returns('a'.repeat(64))
72+
getPublicKeyStub = sinon.stub(eventUtils, 'getPublicKey').returns('b'.repeat(64))
73+
})
74+
75+
afterEach(() => {
76+
getTemplateStub.restore()
77+
getRelayPrivateKeyStub.restore()
78+
getPublicKeyStub.restore()
79+
})
80+
81+
describe('rate limiting', () => {
82+
it('returns 429 when rate limited', async () => {
83+
const rateLimiter = { hit: sinon.stub().resolves(true) }
84+
const settings = () => ({
85+
...baseSettings,
86+
limits: {
87+
invoice: {
88+
rateLimits: [{ rate: 2, period: 60000 }],
89+
ipWhitelist: [],
90+
},
91+
},
92+
})
93+
const controller = makeController({ settings, rateLimiter: () => rateLimiter })
94+
const res = makeRes()
95+
const req: any = {
96+
params: {},
97+
body: validBody,
98+
headers: {},
99+
connection: { remoteAddress: '1.2.3.4' },
100+
socket: { remoteAddress: '1.2.3.4' },
101+
}
102+
103+
await controller.handleRequest(req, res)
104+
105+
expect(res.status).to.have.been.calledWith(429)
106+
})
107+
})
108+
109+
describe('request validation', () => {
110+
it('returns 400 for missing body', async () => {
111+
const controller = makeController()
112+
const res = makeRes()
113+
114+
await controller.handleRequest({ body: null } as any, res)
115+
116+
expect(res.status).to.have.been.calledWith(400)
117+
})
118+
119+
it('returns 400 for non-object body', async () => {
120+
const controller = makeController()
121+
const res = makeRes()
122+
123+
await controller.handleRequest({ body: 'string' } as any, res)
124+
125+
expect(res.status).to.have.been.calledWith(400)
126+
})
127+
128+
it('returns 400 when ToS not accepted', async () => {
129+
const controller = makeController()
130+
const res = makeRes()
131+
132+
await controller.handleRequest({ body: { ...validBody, tosAccepted: 'no' } } as any, res)
133+
134+
expect(res.status).to.have.been.calledWith(400)
135+
expect(res.send).to.have.been.calledWith('ToS agreement: not accepted')
136+
})
137+
138+
it('returns 400 for non-admission feeSchedule', async () => {
139+
const controller = makeController()
140+
const res = makeRes()
141+
142+
await controller.handleRequest({ body: { ...validBody, feeSchedule: 'subscription' } } as any, res)
143+
144+
expect(res.status).to.have.been.calledWith(400)
145+
expect(res.send).to.have.been.calledWith('Invalid fee')
146+
})
147+
148+
it('returns 400 when pubkey is missing', async () => {
149+
const controller = makeController()
150+
const res = makeRes()
151+
const { pubkey: _, ...bodyWithoutPubkey } = validBody
152+
153+
await controller.handleRequest({ body: bodyWithoutPubkey } as any, res)
154+
155+
expect(res.status).to.have.been.calledWith(400)
156+
expect(res.send).to.have.been.calledWith('Invalid pubkey: missing')
157+
})
158+
159+
it('returns 400 for an invalid npub', async () => {
160+
const controller = makeController()
161+
const res = makeRes()
162+
163+
await controller.handleRequest({
164+
body: { ...validBody, pubkey: 'npub1invalidvalue' },
165+
} as any, res)
166+
167+
expect(res.status).to.have.been.calledWith(400)
168+
expect(res.send).to.have.been.calledWith('Invalid pubkey: invalid npub')
169+
})
170+
171+
it('returns 400 for unknown pubkey format', async () => {
172+
const controller = makeController()
173+
const res = makeRes()
174+
175+
await controller.handleRequest({
176+
body: { ...validBody, pubkey: 'notahexpubkey' },
177+
} as any, res)
178+
179+
expect(res.status).to.have.been.calledWith(400)
180+
expect(res.send).to.have.been.calledWith('Invalid pubkey: unknown format')
181+
})
182+
})
183+
184+
describe('business rule validation', () => {
185+
it('returns 400 when no admission fee is enabled', async () => {
186+
const settings = () => ({
187+
...baseSettings,
188+
payments: {
189+
...baseSettings.payments,
190+
feeSchedules: {
191+
admission: [{ enabled: false, amount: 21000, whitelists: {} }],
192+
},
193+
},
194+
})
195+
const controller = makeController({ settings })
196+
const res = makeRes()
197+
198+
await controller.handleRequest({ body: validBody } as any, res)
199+
200+
expect(res.status).to.have.been.calledWith(400)
201+
expect(res.send).to.have.been.calledWith('No admission fee required')
202+
})
203+
204+
it('returns 400 when user is already admitted', async () => {
205+
const userRepository = {
206+
findByPubkey: sinon.stub().resolves({ isAdmitted: true, balance: 99999 }),
207+
}
208+
const controller = makeController({ userRepository })
209+
const res = makeRes()
210+
211+
await controller.handleRequest({ body: validBody } as any, res)
212+
213+
expect(res.status).to.have.been.calledWith(400)
214+
expect(res.send).to.have.been.calledWith('User is already admitted.')
215+
})
216+
})
217+
218+
describe('invoice creation', () => {
219+
it('returns 500 when the payments service throws', async () => {
220+
const paymentsService = {
221+
createInvoice: sinon.stub().rejects(new Error('payment gateway down')),
222+
}
223+
const controller = makeController({ paymentsService })
224+
const res = makeRes()
225+
226+
await controller.handleRequest({ body: validBody } as any, res)
227+
228+
expect(res.status).to.have.been.calledWith(500)
229+
})
230+
})
231+
232+
describe('successful response', () => {
233+
it('responds with 200 and text/html', async () => {
234+
const controller = makeController()
235+
const res = makeRes()
236+
237+
await controller.handleRequest({ body: validBody } as any, res)
238+
239+
expect(res.status).to.have.been.calledWith(200)
240+
expect(res.setHeader).to.have.been.calledWith('Content-Type', 'text/html; charset=utf8')
241+
})
242+
243+
it('loads the post-invoice.html template', async () => {
244+
const controller = makeController()
245+
const res = makeRes()
246+
247+
await controller.handleRequest({ body: validBody } as any, res)
248+
249+
expect(getTemplateStub).to.have.been.calledWith('./resources/post-invoice.html')
250+
})
251+
252+
it('HTML-escapes the relay name in the output', async () => {
253+
const settings = () => ({
254+
...baseSettings,
255+
info: { ...baseSettings.info, name: '<b>Relay</b>' },
256+
})
257+
getTemplateStub.returns('{{name}}')
258+
const controller = makeController({ settings })
259+
const res = makeRes()
260+
261+
await controller.handleRequest({ body: validBody } as any, res)
262+
263+
const sent = res.send.firstCall.args[0]
264+
expect(sent).to.not.include('<b>')
265+
expect(sent).to.include('&lt;b&gt;')
266+
})
267+
268+
it('safe-serializes processor for inline script context', async () => {
269+
getTemplateStub.returns('{{processor_json}}')
270+
const controller = makeController()
271+
const res = makeRes()
272+
273+
await controller.handleRequest({ body: validBody } as any, res)
274+
275+
const sent = res.send.firstCall.args[0]
276+
expect(sent).to.not.include('<')
277+
expect(JSON.parse(sent)).to.equal('lnbits')
278+
})
279+
280+
it('renders amount in sats (msats / 1000)', async () => {
281+
getTemplateStub.returns('{{amount}}')
282+
const controller = makeController()
283+
const res = makeRes()
284+
285+
await controller.handleRequest({ body: validBody } as any, res)
286+
287+
// 21000 msats → 21 sats
288+
expect(res.send.firstCall.args[0]).to.equal('21')
289+
})
290+
291+
it('injects the CSP nonce', async () => {
292+
getTemplateStub.returns('{{nonce}}')
293+
const controller = makeController()
294+
const res = makeRes()
295+
296+
await controller.handleRequest({ body: validBody } as any, res)
297+
298+
expect(res.send.firstCall.args[0]).to.equal('post-inv-nonce')
299+
})
300+
301+
it('leaves no unreplaced template variables in the output', async () => {
302+
getTemplateStub.returns(
303+
'{{name}}{{relay_url_html}}{{invoice_html}}{{pubkey_html}}{{amount}}' +
304+
'{{reference_json}}{{relay_url_json}}{{relay_pubkey_json}}' +
305+
'{{invoice_json}}{{pubkey_json}}{{expires_at_json}}{{processor_json}}{{nonce}}'
306+
)
307+
const controller = makeController()
308+
const res = makeRes()
309+
310+
await controller.handleRequest({ body: validBody } as any, res)
311+
312+
const sent = res.send.firstCall.args[0] as string
313+
expect(sent).to.not.match(/\{\{[^}]+\}\}/)
314+
})
315+
})
316+
})

0 commit comments

Comments
 (0)