Skip to content

Commit 5ace8e3

Browse files
Merge branch 'main' into add-tests-001
2 parents 6698e41 + 3f269a3 commit 5ace8e3

9 files changed

Lines changed: 524 additions & 36 deletions

File tree

.github/workflows/pr-title.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ jobs:
1919
with:
2020
requireScope: false
2121
subjectPattern: ^.+$
22-
subjectPatternError: PR title must follow Conventional Commits format (e.g. "feat: add feature" or "fix(scope): fix bug").
22+
subjectPatternError: 'PR title must follow Conventional Commits format (e.g. "feat: add feature" or "fix(scope): fix bug").'

src/controllers/callbacks/opennode-callback-controller.ts

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import { timingSafeEqual } from 'crypto'
2+
13
import { Request, Response } from 'express'
24

35
import { Invoice, InvoiceStatus } from '../../@types/invoice'
46
import { createLogger } from '../../factories/logger-factory'
5-
import { fromOpenNodeInvoice } from '../../utils/transform'
7+
import { createSettings } from '../../factories/settings-factory'
8+
import { getRemoteAddress } from '../../utils/http'
9+
import { hmacSha256 } from '../../utils/secret'
610
import { IController } from '../../@types/controllers'
711
import { IPaymentsService } from '../../@types/services'
8-
import { opennodeCallbackBodySchema } from '../../schemas/opennode-callback-schema'
12+
import { opennodeWebhookCallbackBodySchema } from '../../schemas/opennode-callback-schema'
913
import { validateSchema } from '../../utils/validation'
1014

1115
const debug = createLogger('opennode-callback-controller')
@@ -15,16 +19,85 @@ export class OpenNodeCallbackController implements IController {
1519

1620
public async handleRequest(request: Request, response: Response) {
1721
debug('request headers: %o', request.headers)
18-
debug('request body: %O', request.body)
1922

20-
const bodyValidation = validateSchema(opennodeCallbackBodySchema)(request.body)
23+
const settings = createSettings()
24+
const remoteAddress = getRemoteAddress(request, settings)
25+
const paymentProcessor = settings.payments?.processor
26+
27+
if (paymentProcessor !== 'opennode') {
28+
debug('denied request from %s to /callbacks/opennode which is not the current payment processor', remoteAddress)
29+
response
30+
.status(403)
31+
.send('Forbidden')
32+
return
33+
}
34+
35+
const bodyValidation = validateSchema(opennodeWebhookCallbackBodySchema)(request.body)
2136
if (bodyValidation.error) {
2237
debug('opennode callback request rejected: invalid body %o', bodyValidation.error)
2338
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Malformed body')
2439
return
2540
}
2641

27-
const invoice = fromOpenNodeInvoice(request.body)
42+
const body = bodyValidation.value
43+
debug(
44+
'request body metadata: hasId=%s hasHashedOrder=%s status=%s',
45+
typeof body.id === 'string',
46+
typeof body.hashed_order === 'string',
47+
body.status,
48+
)
49+
50+
const openNodeApiKey = process.env.OPENNODE_API_KEY
51+
if (!openNodeApiKey) {
52+
debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress)
53+
response
54+
.status(500)
55+
.setHeader('content-type', 'text/plain; charset=utf8')
56+
.send('Internal Server Error')
57+
return
58+
}
59+
60+
const expectedBuf = hmacSha256(openNodeApiKey, body.id)
61+
const actualHex = body.hashed_order
62+
const expectedHexLength = expectedBuf.length * 2
63+
64+
if (
65+
actualHex.length !== expectedHexLength
66+
|| !/^[0-9a-f]+$/i.test(actualHex)
67+
) {
68+
debug('invalid hashed_order format from %s to /callbacks/opennode', remoteAddress)
69+
response
70+
.status(400)
71+
.setHeader('content-type', 'text/plain; charset=utf8')
72+
.send('Bad Request')
73+
return
74+
}
75+
76+
const actualBuf = Buffer.from(actualHex, 'hex')
77+
78+
if (
79+
!timingSafeEqual(expectedBuf, actualBuf)
80+
) {
81+
debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress)
82+
response
83+
.status(403)
84+
.send('Forbidden')
85+
return
86+
}
87+
88+
const statusMap: Record<string, InvoiceStatus> = {
89+
expired: InvoiceStatus.EXPIRED,
90+
refunded: InvoiceStatus.EXPIRED,
91+
unpaid: InvoiceStatus.PENDING,
92+
processing: InvoiceStatus.PENDING,
93+
underpaid: InvoiceStatus.PENDING,
94+
paid: InvoiceStatus.COMPLETED,
95+
}
96+
97+
const invoice: Pick<Invoice, 'id' | 'status'> = {
98+
id: body.id,
99+
status: statusMap[body.status],
100+
}
28101

29102
debug('invoice', invoice)
30103

@@ -37,21 +110,25 @@ export class OpenNodeCallbackController implements IController {
37110
throw error
38111
}
39112

40-
if (updatedInvoice.status !== InvoiceStatus.COMPLETED && !updatedInvoice.confirmedAt) {
41-
response.status(200).send()
113+
if (updatedInvoice.status !== InvoiceStatus.COMPLETED) {
114+
response
115+
.status(200)
116+
.send()
42117

43118
return
44119
}
45120

46-
invoice.amountPaid = invoice.amountRequested
47-
updatedInvoice.amountPaid = invoice.amountRequested
121+
if (!updatedInvoice.confirmedAt) {
122+
updatedInvoice.confirmedAt = new Date()
123+
}
124+
updatedInvoice.amountPaid = updatedInvoice.amountRequested
48125

49126
try {
50127
await this.paymentsService.confirmInvoice({
51-
id: invoice.id,
52-
pubkey: invoice.pubkey,
128+
id: updatedInvoice.id,
129+
pubkey: updatedInvoice.pubkey,
53130
status: updatedInvoice.status,
54-
amountPaid: updatedInvoice.amountRequested,
131+
amountPaid: updatedInvoice.amountPaid,
55132
confirmedAt: updatedInvoice.confirmedAt,
56133
})
57134
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)

src/routes/callbacks/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { json, Router } from 'express'
1+
import { json, Router, urlencoded } from 'express'
22

33
import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory'
44
import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory'
@@ -20,6 +20,6 @@ router
2020
}),
2121
withController(createNodelessCallbackController),
2222
)
23-
.post('/opennode', json(), withController(createOpenNodeCallbackController))
23+
.post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController))
2424

2525
export default router

src/schemas/opennode-callback-schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { pubkeySchema } from './base-schema'
22
import { z } from 'zod'
33

4+
const openNodeCallbackStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid'] as const
5+
6+
export const opennodeWebhookCallbackBodySchema = z
7+
.object({
8+
id: z.string(),
9+
hashed_order: z.string(),
10+
status: z.enum(openNodeCallbackStatuses),
11+
})
12+
.passthrough()
13+
414
export const opennodeCallbackBodySchema = z
515
.object({
616
id: z.string(),
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
@opennode-callback
2+
Feature: OpenNode callback endpoint
3+
Scenario: rejects malformed callback body
4+
Given OpenNode callback processing is enabled
5+
When I post a malformed OpenNode callback
6+
Then the OpenNode callback response status is 400
7+
And the OpenNode callback response body is "Malformed body"
8+
9+
Scenario: rejects callback with invalid signature
10+
Given OpenNode callback processing is enabled
11+
When I post an OpenNode callback with an invalid signature
12+
Then the OpenNode callback response status is 403
13+
And the OpenNode callback response body is "Forbidden"
14+
15+
Scenario: accepts valid signed callback for pending invoice
16+
Given OpenNode callback processing is enabled
17+
And a pending OpenNode invoice exists
18+
When I post a signed OpenNode callback with status "processing"
19+
Then the OpenNode callback response status is 200
20+
And the OpenNode callback response body is empty
21+
22+
Scenario: completes a pending invoice on paid callback
23+
Given OpenNode callback processing is enabled
24+
And a pending OpenNode invoice exists
25+
When I post a signed OpenNode callback with status "paid"
26+
Then the OpenNode callback response status is 200
27+
And the OpenNode callback response body is "OK"
28+
And the OpenNode invoice is marked completed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { After, Given, Then, When } from '@cucumber/cucumber'
2+
import axios, { AxiosResponse } from 'axios'
3+
import { expect } from 'chai'
4+
import { randomUUID } from 'crypto'
5+
6+
import { getMasterDbClient } from '../../../../src/database/client'
7+
import { hmacSha256 } from '../../../../src/utils/secret'
8+
import { SettingsStatic } from '../../../../src/utils/settings'
9+
10+
const CALLBACK_URL = 'http://localhost:18808/callbacks/opennode'
11+
const OPENNODE_TEST_API_KEY = 'integration-opennode-api-key'
12+
const TEST_PUBKEY = 'a'.repeat(64)
13+
14+
const postOpenNodeCallback = async (body: Record<string, string>) => {
15+
const encodedBody = new URLSearchParams(body).toString()
16+
17+
return axios.post(
18+
CALLBACK_URL,
19+
encodedBody,
20+
{
21+
headers: {
22+
'content-type': 'application/x-www-form-urlencoded',
23+
},
24+
validateStatus: () => true,
25+
},
26+
)
27+
}
28+
29+
Given('OpenNode callback processing is enabled', function () {
30+
const settings = SettingsStatic._settings as any
31+
32+
this.parameters.previousOpenNodeCallbackSettings = settings
33+
this.parameters.previousOpenNodeApiKey = process.env.OPENNODE_API_KEY
34+
35+
SettingsStatic._settings = {
36+
...settings,
37+
payments: {
38+
...(settings?.payments ?? {}),
39+
processor: 'opennode',
40+
},
41+
}
42+
43+
process.env.OPENNODE_API_KEY = OPENNODE_TEST_API_KEY
44+
})
45+
46+
Given('a pending OpenNode invoice exists', async function () {
47+
const dbClient = getMasterDbClient()
48+
const invoiceId = `integration-opennode-${randomUUID()}`
49+
50+
await dbClient('invoices').insert({
51+
id: invoiceId,
52+
pubkey: Buffer.from(TEST_PUBKEY, 'hex'),
53+
bolt11: 'lnbc210n1integration',
54+
amount_requested: '21000',
55+
unit: 'sats',
56+
status: 'pending',
57+
description: 'open node integration callback test',
58+
expires_at: new Date(Date.now() + 15 * 60 * 1000),
59+
updated_at: new Date(),
60+
created_at: new Date(),
61+
})
62+
63+
this.parameters.openNodeInvoiceId = invoiceId
64+
this.parameters.openNodeInvoiceIds = [
65+
...(this.parameters.openNodeInvoiceIds ?? []),
66+
invoiceId,
67+
]
68+
})
69+
70+
When('I post a malformed OpenNode callback', async function () {
71+
this.parameters.openNodeResponse = await postOpenNodeCallback({
72+
id: 'missing-required-fields',
73+
})
74+
})
75+
76+
When('I post an OpenNode callback with an invalid signature', async function () {
77+
this.parameters.openNodeResponse = await postOpenNodeCallback({
78+
hashed_order: '0'.repeat(64),
79+
id: `integration-opennode-${randomUUID()}`,
80+
status: 'paid',
81+
})
82+
})
83+
84+
When('I post a signed OpenNode callback with status {string}', async function (status: string) {
85+
const id = this.parameters.openNodeInvoiceId
86+
const hashedOrder = hmacSha256(OPENNODE_TEST_API_KEY, id).toString('hex')
87+
88+
this.parameters.openNodeResponse = await postOpenNodeCallback({
89+
hashed_order: hashedOrder,
90+
id,
91+
status,
92+
})
93+
})
94+
95+
Then('the OpenNode callback response status is {int}', function (statusCode: number) {
96+
const response = this.parameters.openNodeResponse as AxiosResponse
97+
98+
expect(response.status).to.equal(statusCode)
99+
})
100+
101+
Then('the OpenNode callback response body is {string}', function (expectedBody: string) {
102+
const response = this.parameters.openNodeResponse as AxiosResponse
103+
104+
expect(response.data).to.equal(expectedBody)
105+
})
106+
107+
Then('the OpenNode callback response body is empty', function () {
108+
const response = this.parameters.openNodeResponse as AxiosResponse
109+
110+
expect(['', undefined, null]).to.include(response.data)
111+
})
112+
113+
Then('the OpenNode invoice is marked completed', async function () {
114+
const dbClient = getMasterDbClient()
115+
const invoiceId = this.parameters.openNodeInvoiceId
116+
117+
const invoice = await dbClient('invoices')
118+
.where('id', invoiceId)
119+
.first('status', 'confirmed_at', 'amount_paid')
120+
121+
expect(invoice).to.exist
122+
expect(invoice.status).to.equal('completed')
123+
expect(invoice.confirmed_at).to.not.equal(null)
124+
expect(invoice.amount_paid).to.equal('21000')
125+
})
126+
127+
After({ tags: '@opennode-callback' }, async function () {
128+
SettingsStatic._settings = this.parameters.previousOpenNodeCallbackSettings
129+
130+
if (typeof this.parameters.previousOpenNodeApiKey === 'undefined') {
131+
delete process.env.OPENNODE_API_KEY
132+
} else {
133+
process.env.OPENNODE_API_KEY = this.parameters.previousOpenNodeApiKey
134+
}
135+
136+
const invoiceIds = this.parameters.openNodeInvoiceIds ?? []
137+
if (invoiceIds.length > 0) {
138+
const dbClient = getMasterDbClient()
139+
await dbClient('invoices').whereIn('id', invoiceIds).delete()
140+
}
141+
142+
this.parameters.openNodeInvoiceId = undefined
143+
this.parameters.openNodeInvoiceIds = []
144+
this.parameters.openNodeResponse = undefined
145+
this.parameters.previousOpenNodeApiKey = undefined
146+
this.parameters.previousOpenNodeCallbackSettings = undefined
147+
})

0 commit comments

Comments
 (0)