Skip to content

Commit db0f6fc

Browse files
feat(next-centra-checkout): create initial version of package
1 parent 6a3314d commit db0f6fc

File tree

9 files changed

+658
-1
lines changed

9 files changed

+658
-1
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@noaignite/next-centra-checkout': patch
3+
---
4+
5+
Add initial version of `createGetCentraWebhookEvents`
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import nextConfig from '@noaignite/style-guide/eslint/next'
2+
3+
export default nextConfig
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"name": "@noaignite/next-centra-checkout",
3+
"version": "0.0.0",
4+
"private": false,
5+
"description": "Next.js helpers for Centra checkout api",
6+
"keywords": [
7+
"backend",
8+
"centra",
9+
"nextjs",
10+
"react",
11+
"typescript",
12+
"vercel"
13+
],
14+
"bugs": {
15+
"url": "https://github.com/noaignite/accelerator/issues"
16+
},
17+
"repository": {
18+
"type": "git",
19+
"url": "https://github.com/noaignite/accelerator.git",
20+
"directory": "packages/next-centra-checkout"
21+
},
22+
"license": "MIT",
23+
"author": "NoA Ignite",
24+
"sideEffects": false,
25+
"main": "./dist/index.js",
26+
"module": "./dist/index.mjs",
27+
"types": "./dist/index.d.ts",
28+
"files": [
29+
"dist/**",
30+
"README.md"
31+
],
32+
"scripts": {
33+
"build": "tsup",
34+
"lint": "eslint src/ --ignore-pattern '**/*.test.*'",
35+
"test:coverage": "vitest run --coverage",
36+
"test:unit": "vitest run",
37+
"test:unit:watch": "vitest"
38+
},
39+
"dependencies": {
40+
"@noaignite/utils": "workspace:*"
41+
},
42+
"devDependencies": {
43+
"@noaignite/centra-types": "workspace:*",
44+
"@noaignite/style-guide": "workspace:*",
45+
"@types/react": "^19.1.2",
46+
"next": "^15.0.0",
47+
"nock": "14.0.1",
48+
"react": "^19.1.0",
49+
"tsup": "^8.3.5",
50+
"typescript": "5.4.5"
51+
},
52+
"peerDependencies": {
53+
"next": "^14.0.0 || ^15.0.0",
54+
"react": "^18.0.0 || ^19.0.0",
55+
"react-dom": "^18.0.0 || ^19.0.0"
56+
},
57+
"engines": {
58+
"node": ">=20.0.0"
59+
},
60+
"publishConfig": {
61+
"access": "public"
62+
}
63+
}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import type { NextApiRequest } from 'next'
2+
import crypto from 'node:crypto'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import {
5+
createGetCentraWebhookEvents,
6+
nextAppRouterAdapter,
7+
nextPagesRouterAdapter,
8+
} from './createGetCentraWebhookEvents'
9+
10+
vi.mock('node:crypto', async () => {
11+
return {
12+
default: {
13+
createHmac: vi.fn().mockReturnValue({
14+
update: vi.fn(),
15+
digest: vi.fn().mockReturnValue('valid-signature'),
16+
}),
17+
},
18+
}
19+
})
20+
21+
describe('createGetCentraWebhookEvents', () => {
22+
describe('with secret', () => {
23+
const mockSecret = 'test-secret'
24+
const mockPayload = JSON.stringify({ products: ['product-1'] })
25+
const mockTimestamp = '1234567890'
26+
const mockSignature = `t=${mockTimestamp},v1=valid-signature`
27+
28+
it('validates signature correctly', async () => {
29+
// Arrange
30+
const mockRequest = {
31+
method: 'POST',
32+
headers: {
33+
'x-centra-signature': mockSignature,
34+
},
35+
body: {
36+
payload: mockPayload,
37+
},
38+
} as unknown as NextApiRequest // Only mocking the properties used internally of the function.
39+
40+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
41+
adapter: {
42+
isRequestMethodPost: () => true,
43+
getHeader: (req: NextApiRequest, key) => req.headers[key] as string,
44+
getRawBody: () => Promise.resolve(mockRequest.body),
45+
},
46+
secret: mockSecret,
47+
})
48+
49+
// Act
50+
const result = await getCentraWebhookEvents(mockRequest)
51+
52+
// Assert
53+
expect(crypto.createHmac).toHaveBeenCalledWith('sha256', mockSecret)
54+
expect(result).toEqual([undefined, JSON.parse(mockPayload)])
55+
})
56+
57+
it('returns error for invalid signature', async () => {
58+
// Arrange
59+
const mockRequest = {
60+
method: 'POST',
61+
headers: {
62+
'x-centra-signature': 't=1234567890,v1=invalid-signature',
63+
},
64+
body: {
65+
payload: mockPayload,
66+
},
67+
} as unknown as NextApiRequest // Only mocking the properties used internally of the function.
68+
69+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
70+
adapter: {
71+
isRequestMethodPost: () => true,
72+
getHeader: (req: NextApiRequest, key) => req.headers[key] as string,
73+
getRawBody: () => Promise.resolve(mockRequest.body),
74+
},
75+
secret: mockSecret,
76+
})
77+
78+
// Act
79+
const result = await getCentraWebhookEvents(mockRequest)
80+
81+
// Assert
82+
expect(result).toEqual([{ message: 'Invalid signature' }])
83+
})
84+
85+
it('returns error when no signature is provided', async () => {
86+
// Arrange
87+
const mockRequest = {
88+
method: 'POST',
89+
headers: {},
90+
body: {
91+
payload: mockPayload,
92+
},
93+
}
94+
95+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
96+
adapter: {
97+
isRequestMethodPost: () => true,
98+
getHeader: () => undefined,
99+
getRawBody: () => Promise.resolve(mockRequest.body),
100+
},
101+
secret: mockSecret,
102+
})
103+
104+
// Act
105+
const result = await getCentraWebhookEvents(mockRequest)
106+
107+
// Assert
108+
expect(result).toEqual([{ message: 'No signature' }])
109+
})
110+
})
111+
112+
describe('without secret', () => {
113+
it('skips signature validation when secret is null', async () => {
114+
// Arrange
115+
const mockPayload = JSON.stringify({ products: ['product-1'] })
116+
const mockRequest = {
117+
method: 'POST',
118+
headers: {},
119+
body: {
120+
payload: mockPayload,
121+
},
122+
}
123+
124+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
125+
adapter: {
126+
isRequestMethodPost: () => true,
127+
getHeader: () => undefined,
128+
getRawBody: () => Promise.resolve(mockRequest.body),
129+
},
130+
secret: null,
131+
})
132+
133+
// Act
134+
const result = await getCentraWebhookEvents(mockRequest)
135+
136+
// Assert
137+
expect(result).toEqual([undefined, JSON.parse(mockPayload)])
138+
})
139+
})
140+
141+
describe('request validation', () => {
142+
it('returns error for non-POST requests', async () => {
143+
// Arrange
144+
const mockRequest = {
145+
method: 'GET',
146+
headers: {},
147+
body: {},
148+
}
149+
150+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
151+
adapter: {
152+
isRequestMethodPost: () => false,
153+
getHeader: () => undefined,
154+
getRawBody: () => Promise.resolve({}),
155+
},
156+
secret: null,
157+
})
158+
159+
// Act
160+
const result = await getCentraWebhookEvents(mockRequest)
161+
162+
// Assert
163+
expect(result).toEqual([{ message: 'Invalid request method' }])
164+
})
165+
166+
it('returns error for invalid request body', async () => {
167+
// Arrange
168+
const mockRequest = {
169+
method: 'POST',
170+
headers: {},
171+
body: 'invalid-body',
172+
}
173+
174+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
175+
adapter: {
176+
isRequestMethodPost: () => true,
177+
getHeader: () => undefined,
178+
getRawBody: () => Promise.resolve('invalid-body'),
179+
},
180+
secret: null,
181+
})
182+
183+
// Act
184+
const result = await getCentraWebhookEvents(mockRequest)
185+
186+
// Assert
187+
expect(result).toEqual([{ message: 'Invalid request body' }])
188+
})
189+
})
190+
191+
describe('adapters', () => {
192+
it('nextAppRouterAdapter works correctly', async () => {
193+
// Arrange
194+
const mockFormData = new FormData()
195+
mockFormData.append('payload', JSON.stringify({ products: ['product-1'] }))
196+
197+
const mockRequest = {
198+
headers: new Headers({
199+
'x-centra-signature': 't=1234567890,v1=valid-signature',
200+
}),
201+
formData: () => Promise.resolve(mockFormData),
202+
}
203+
204+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
205+
adapter: nextAppRouterAdapter,
206+
secret: null,
207+
})
208+
209+
// Act
210+
const result = await getCentraWebhookEvents(mockRequest as unknown as Request)
211+
212+
// Assert
213+
expect(result[0]).toBeUndefined()
214+
expect(result[1]).toEqual({ products: ['product-1'] })
215+
})
216+
217+
it('nextPagesRouterAdapter works correctly', async () => {
218+
// Arrange
219+
const mockRequest = {
220+
method: 'POST',
221+
headers: {
222+
'x-centra-signature': 't=1234567890,v1=valid-signature',
223+
},
224+
body: {
225+
payload: JSON.stringify({ products: ['product-1'] }),
226+
},
227+
} as unknown as NextApiRequest // Only mocking the properties used internally of the function.
228+
229+
const getCentraWebhookEvents = createGetCentraWebhookEvents({
230+
adapter: nextPagesRouterAdapter,
231+
secret: null,
232+
})
233+
234+
// Act
235+
const result = await getCentraWebhookEvents(mockRequest)
236+
237+
// Assert
238+
expect(nextPagesRouterAdapter.getHeader(mockRequest, 'x-centra-signature')).toBe(
239+
't=1234567890,v1=valid-signature',
240+
)
241+
expect(result[0]).toBeUndefined()
242+
expect(result[1]).toEqual({ products: ['product-1'] })
243+
})
244+
})
245+
})

0 commit comments

Comments
 (0)