@open-mercato/webhooks 0.10.1-develop.7616.1.51a1f65492 → 0.10.1-develop.7619.1.97f9a4bda8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.js +191 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.js.map +7 -0
- package/dist/modules/webhooks/api/helpers.js +31 -4
- package/dist/modules/webhooks/api/helpers.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +30 -6
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/route.js +21 -4
- package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
- package/dist/modules/webhooks/lib/custom-headers.js +60 -0
- package/dist/modules/webhooks/lib/custom-headers.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.ts +231 -0
- package/src/modules/webhooks/api/helpers.ts +43 -2
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/credential-headers.test.ts +358 -0
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +2 -0
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/reactivation.test.ts +2 -0
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts +2 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +31 -6
- package/src/modules/webhooks/api/webhooks/__tests__/credential-headers.test.ts +96 -0
- package/src/modules/webhooks/api/webhooks/route.ts +21 -4
- package/src/modules/webhooks/i18n/de.json +3 -1
- package/src/modules/webhooks/i18n/en.json +3 -1
- package/src/modules/webhooks/i18n/es.json +3 -1
- package/src/modules/webhooks/i18n/ko.json +3 -1
- package/src/modules/webhooks/i18n/pl.json +3 -1
- package/src/modules/webhooks/lib/__tests__/custom-headers.test.ts +144 -0
- package/src/modules/webhooks/lib/custom-headers.ts +84 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174237'
|
|
4
|
+
const REDACTED = '••••••••'
|
|
5
|
+
|
|
6
|
+
type StoredWebhook = Record<string, unknown> & { customHeaders: Record<string, string> | null }
|
|
7
|
+
|
|
8
|
+
function makeWebhook(): StoredWebhook {
|
|
9
|
+
return {
|
|
10
|
+
id: WEBHOOK_ID,
|
|
11
|
+
name: 'Hook',
|
|
12
|
+
description: null,
|
|
13
|
+
url: 'https://example.com/hook',
|
|
14
|
+
secret: 'whsec_abcdefghijklmnop',
|
|
15
|
+
subscribedEvents: ['catalog.product.created'],
|
|
16
|
+
httpMethod: 'POST',
|
|
17
|
+
isActive: true,
|
|
18
|
+
deliveryStrategy: 'http',
|
|
19
|
+
maxRetries: 3,
|
|
20
|
+
consecutiveFailures: 0,
|
|
21
|
+
lastSuccessAt: null,
|
|
22
|
+
lastFailureAt: null,
|
|
23
|
+
createdAt: new Date('2026-06-01T10:00:00.000Z'),
|
|
24
|
+
updatedAt: new Date('2026-06-01T10:00:00.000Z'),
|
|
25
|
+
customHeaders: {
|
|
26
|
+
Authorization: 'Bearer live-destination-token',
|
|
27
|
+
'x-api-key': 'dest-api-key-123',
|
|
28
|
+
'x-mock-webhook-token': 'shared-secret',
|
|
29
|
+
'x-source': 'open-mercato',
|
|
30
|
+
},
|
|
31
|
+
strategyConfig: null,
|
|
32
|
+
timeoutMs: 5000,
|
|
33
|
+
rateLimitPerMinute: 0,
|
|
34
|
+
autoDisableThreshold: 5,
|
|
35
|
+
integrationId: null,
|
|
36
|
+
previousSecretSetAt: null,
|
|
37
|
+
organizationId: 'org-1',
|
|
38
|
+
tenantId: 'tenant-1',
|
|
39
|
+
deletedAt: null,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let webhookRecord = makeWebhook()
|
|
44
|
+
let grantedFeatures: string[] = []
|
|
45
|
+
|
|
46
|
+
const mockRbac = {
|
|
47
|
+
userHasAllFeatures: jest.fn(async (_sub: string, required: string[]) =>
|
|
48
|
+
required.every((feature) => grantedFeatures.includes(feature) || grantedFeatures.includes('webhooks.*')),
|
|
49
|
+
),
|
|
50
|
+
loadAcl: jest.fn(async () => ({ isSuperAdmin: false, features: grantedFeatures, organizations: null })),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const mockEm = {
|
|
54
|
+
fork: jest.fn(() => mockEm),
|
|
55
|
+
flush: jest.fn(async () => undefined),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const mockContainer = {
|
|
59
|
+
resolve: (token: string) => {
|
|
60
|
+
if (token === 'em') return mockEm
|
|
61
|
+
if (token === 'rbacService') return mockRbac
|
|
62
|
+
if (token === 'cache') return null
|
|
63
|
+
if (token === 'commandOptimisticLockGuardService') throw new Error('not registered')
|
|
64
|
+
return null
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const authContext = { sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1', roles: [] }
|
|
69
|
+
|
|
70
|
+
jest.mock('../../../../events', () => ({
|
|
71
|
+
emitWebhooksEvent: jest.fn(async () => undefined),
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
jest.mock('../../../../lib/subscription-cache', () => ({
|
|
75
|
+
invalidateWebhookSubscriptionCacheFor: jest.fn(async () => undefined),
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
jest.mock('../../../helpers', () => {
|
|
79
|
+
const actual = jest.requireActual('../../../helpers')
|
|
80
|
+
return {
|
|
81
|
+
...actual,
|
|
82
|
+
resolveWebhookRequestScope: jest.fn(async () => ({
|
|
83
|
+
container: mockContainer,
|
|
84
|
+
em: mockEm,
|
|
85
|
+
tenantId: 'tenant-1',
|
|
86
|
+
organizationId: 'org-1',
|
|
87
|
+
allowedOrganizationIds: null,
|
|
88
|
+
auth: authContext,
|
|
89
|
+
})),
|
|
90
|
+
findScopedWebhook: jest.fn(async () => webhookRecord),
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
95
|
+
resolveTranslations: jest.fn(async () => ({
|
|
96
|
+
translate: (_key: string, fallback?: string, params?: Record<string, string>) =>
|
|
97
|
+
(fallback ?? '').replace(/\{(\w+)\}/g, (_match, name: string) => params?.[name] ?? ''),
|
|
98
|
+
})),
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
import { GET, PUT } from '../route'
|
|
102
|
+
|
|
103
|
+
const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
|
|
104
|
+
|
|
105
|
+
function request(method: string, body?: unknown) {
|
|
106
|
+
return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
|
|
107
|
+
method,
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readHeaders(res: Response): Promise<Record<string, string> | null> {
|
|
114
|
+
const body = (await res.json()) as { customHeaders: Record<string, string> | null }
|
|
115
|
+
return body.customHeaders
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('webhook custom credential headers (issue #7237)', () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
jest.clearAllMocks()
|
|
121
|
+
webhookRecord = makeWebhook()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('GET', () => {
|
|
125
|
+
it('redacts credential-like header values for a webhooks.view-only caller', async () => {
|
|
126
|
+
grantedFeatures = ['webhooks.view']
|
|
127
|
+
|
|
128
|
+
const headers = await readHeaders(await GET(request('GET'), context))
|
|
129
|
+
|
|
130
|
+
expect(headers).toEqual({
|
|
131
|
+
Authorization: REDACTED,
|
|
132
|
+
'x-api-key': REDACTED,
|
|
133
|
+
'x-mock-webhook-token': REDACTED,
|
|
134
|
+
'x-source': 'open-mercato',
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('keeps the response shape: header names stay visible, values stay strings', async () => {
|
|
139
|
+
grantedFeatures = ['webhooks.view']
|
|
140
|
+
|
|
141
|
+
const headers = await readHeaders(await GET(request('GET'), context))
|
|
142
|
+
|
|
143
|
+
expect(Object.keys(headers ?? {}).sort()).toEqual(
|
|
144
|
+
['Authorization', 'x-api-key', 'x-mock-webhook-token', 'x-source'].sort(),
|
|
145
|
+
)
|
|
146
|
+
for (const value of Object.values(headers ?? {})) expect(typeof value).toBe('string')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('redacts for webhooks.manage without webhooks.secrets', async () => {
|
|
150
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
151
|
+
|
|
152
|
+
const headers = await readHeaders(await GET(request('GET'), context))
|
|
153
|
+
|
|
154
|
+
expect(headers?.Authorization).toBe(REDACTED)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('returns values verbatim to a webhooks.secrets holder', async () => {
|
|
158
|
+
grantedFeatures = ['webhooks.view', 'webhooks.secrets']
|
|
159
|
+
|
|
160
|
+
const headers = await readHeaders(await GET(request('GET'), context))
|
|
161
|
+
|
|
162
|
+
expect(headers).toEqual(makeWebhook().customHeaders)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('checks webhooks.secrets in the scope of the webhook being returned', async () => {
|
|
166
|
+
grantedFeatures = ['webhooks.view']
|
|
167
|
+
|
|
168
|
+
await GET(request('GET'), context)
|
|
169
|
+
|
|
170
|
+
expect(mockRbac.userHasAllFeatures).toHaveBeenCalledWith('user-1', ['webhooks.secrets'], {
|
|
171
|
+
tenantId: 'tenant-1',
|
|
172
|
+
organizationId: 'org-1',
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns null headers unchanged', async () => {
|
|
177
|
+
grantedFeatures = ['webhooks.view']
|
|
178
|
+
webhookRecord.customHeaders = null
|
|
179
|
+
|
|
180
|
+
expect(await readHeaders(await GET(request('GET'), context))).toBeNull()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('PUT', () => {
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('keeps stored credential values when the redaction marker is echoed back', async () => {
|
|
190
|
+
const res = await PUT(
|
|
191
|
+
request('PUT', {
|
|
192
|
+
name: 'Renamed',
|
|
193
|
+
customHeaders: {
|
|
194
|
+
Authorization: REDACTED,
|
|
195
|
+
'x-api-key': REDACTED,
|
|
196
|
+
'x-mock-webhook-token': REDACTED,
|
|
197
|
+
'x-source': 'erp',
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
context,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
expect(res.status).toBe(200)
|
|
204
|
+
expect(webhookRecord.customHeaders).toEqual({
|
|
205
|
+
Authorization: 'Bearer live-destination-token',
|
|
206
|
+
'x-api-key': 'dest-api-key-123',
|
|
207
|
+
'x-mock-webhook-token': 'shared-secret',
|
|
208
|
+
'x-source': 'erp',
|
|
209
|
+
})
|
|
210
|
+
expect(webhookRecord.name).toBe('Renamed')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('matches the stored header case-insensitively when the marker is echoed', async () => {
|
|
214
|
+
await PUT(request('PUT', { customHeaders: { authorization: REDACTED } }), context)
|
|
215
|
+
|
|
216
|
+
expect(webhookRecord.customHeaders).toEqual({ authorization: 'Bearer live-destination-token' })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('replaces a credential value when a new one is sent', async () => {
|
|
220
|
+
await PUT(request('PUT', { customHeaders: { Authorization: 'Bearer rotated' } }), context)
|
|
221
|
+
|
|
222
|
+
expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer rotated' })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('drops a credential header the caller omits', async () => {
|
|
226
|
+
await PUT(request('PUT', { customHeaders: { 'x-source': 'erp' } }), context)
|
|
227
|
+
|
|
228
|
+
expect(webhookRecord.customHeaders).toEqual({ 'x-source': 'erp' })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('rejects the marker for a header that has no stored value instead of saving it', async () => {
|
|
232
|
+
const res = await PUT(request('PUT', { customHeaders: { 'x-new-token': REDACTED } }), context)
|
|
233
|
+
|
|
234
|
+
expect(res.status).toBe(400)
|
|
235
|
+
expect(webhookRecord.customHeaders).toEqual(makeWebhook().customHeaders)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('redacts the PUT response the same way as GET for a caller without webhooks.secrets', async () => {
|
|
239
|
+
const res = await PUT(request('PUT', { name: 'Renamed' }), context)
|
|
240
|
+
|
|
241
|
+
expect((await readHeaders(res))?.Authorization).toBe(REDACTED)
|
|
242
|
+
})
|
|
243
|
+
it('names the header in the orphan-marker error', async () => {
|
|
244
|
+
const res = await PUT(request('PUT', { customHeaders: { 'x-new-token': REDACTED } }), context)
|
|
245
|
+
|
|
246
|
+
expect(((await res.json()) as { error: string }).error).toContain('x-new-token')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('stores a value that only starts with the marker as a new value', async () => {
|
|
250
|
+
await PUT(request('PUT', { customHeaders: { Authorization: `${REDACTED}x` } }), context)
|
|
251
|
+
|
|
252
|
+
expect(webhookRecord.customHeaders).toEqual({ Authorization: `${REDACTED}x` })
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('clears all headers when customHeaders is null', async () => {
|
|
256
|
+
await PUT(request('PUT', { customHeaders: null }), context)
|
|
257
|
+
|
|
258
|
+
expect(webhookRecord.customHeaders).toBeNull()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('rejects an ambiguous marker when two stored names differ only by case', async () => {
|
|
262
|
+
webhookRecord.customHeaders = { Authorization: 'Bearer a', authorization: 'Bearer b' }
|
|
263
|
+
|
|
264
|
+
const res = await PUT(request('PUT', { customHeaders: { AUTHORIZATION: REDACTED } }), context)
|
|
265
|
+
|
|
266
|
+
expect(res.status).toBe(400)
|
|
267
|
+
expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer a', authorization: 'Bearer b' })
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('matches a stored name that differs only by surrounding whitespace', async () => {
|
|
271
|
+
webhookRecord.customHeaders = { ' Authorization': 'Bearer padded' }
|
|
272
|
+
|
|
273
|
+
await PUT(request('PUT', { customHeaders: { Authorization: REDACTED } }), context)
|
|
274
|
+
|
|
275
|
+
expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer padded' })
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('PUT with a changed URL', () => {
|
|
280
|
+
it('refuses to keep hidden credential values for a caller without webhooks.secrets', async () => {
|
|
281
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
282
|
+
|
|
283
|
+
const res = await PUT(
|
|
284
|
+
request('PUT', {
|
|
285
|
+
url: 'https://attacker.example.net/collect',
|
|
286
|
+
customHeaders: { Authorization: REDACTED, 'x-source': 'open-mercato' },
|
|
287
|
+
}),
|
|
288
|
+
context,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
expect(res.status).toBe(400)
|
|
292
|
+
expect(((await res.json()) as { error: string }).error).toContain('Authorization')
|
|
293
|
+
expect(webhookRecord.url).toBe('https://example.com/hook')
|
|
294
|
+
expect(webhookRecord.customHeaders).toEqual(makeWebhook().customHeaders)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('refuses when customHeaders is omitted and stored credential headers would follow the new URL', async () => {
|
|
298
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
299
|
+
|
|
300
|
+
const res = await PUT(request('PUT', { url: 'https://attacker.example.net/collect' }), context)
|
|
301
|
+
|
|
302
|
+
expect(res.status).toBe(400)
|
|
303
|
+
expect(webhookRecord.url).toBe('https://example.com/hook')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('accepts the move when the caller re-enters or removes every credential header', async () => {
|
|
307
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
308
|
+
|
|
309
|
+
const res = await PUT(
|
|
310
|
+
request('PUT', {
|
|
311
|
+
url: 'https://other.example.net/hook',
|
|
312
|
+
customHeaders: { Authorization: 'Bearer re-entered', 'x-source': 'open-mercato' },
|
|
313
|
+
}),
|
|
314
|
+
context,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
expect(res.status).toBe(200)
|
|
318
|
+
expect(webhookRecord.url).toBe('https://other.example.net/hook')
|
|
319
|
+
expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer re-entered', 'x-source': 'open-mercato' })
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('refuses when only the path changes on the same host', async () => {
|
|
323
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
324
|
+
|
|
325
|
+
const res = await PUT(
|
|
326
|
+
request('PUT', { url: 'https://example.com/attacker-bin', customHeaders: { Authorization: REDACTED } }),
|
|
327
|
+
context,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
expect(res.status).toBe(400)
|
|
331
|
+
expect(webhookRecord.url).toBe('https://example.com/hook')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('keeps hidden values when the unchanged URL is sent back', async () => {
|
|
335
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
336
|
+
|
|
337
|
+
const res = await PUT(
|
|
338
|
+
request('PUT', { name: 'Renamed', url: 'https://example.com/hook', customHeaders: { Authorization: REDACTED } }),
|
|
339
|
+
context,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
expect(res.status).toBe(200)
|
|
343
|
+
expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer live-destination-token' })
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('lets a webhooks.secrets holder keep stored values on a new host', async () => {
|
|
347
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage', 'webhooks.secrets']
|
|
348
|
+
|
|
349
|
+
const res = await PUT(
|
|
350
|
+
request('PUT', { url: 'https://other.example.net/hook', customHeaders: { Authorization: REDACTED } }),
|
|
351
|
+
context,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
expect(res.status).toBe(200)
|
|
355
|
+
expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer live-destination-token' })
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
})
|
|
@@ -56,6 +56,8 @@ jest.mock('../../../helpers', () => ({
|
|
|
56
56
|
}),
|
|
57
57
|
resolveWebhookRequestScope: jest.fn(async () => ({ container: mockContainer, em: mockEm, tenantId: 'tenant-1', organizationId: 'org-1' })),
|
|
58
58
|
findScopedWebhook: jest.fn(async () => (webhookRecord.deletedAt ? null : webhookRecord)),
|
|
59
|
+
canRevealWebhookCredentialHeaders: jest.fn(async () => true),
|
|
60
|
+
translateWebhookCustomHeadersRejection: jest.fn(async () => 'rejected'),
|
|
59
61
|
serializeWebhookDetail: (item: { id: string; updatedAt: Date }) => ({
|
|
60
62
|
id: item.id,
|
|
61
63
|
updatedAt: item.updatedAt.toISOString(),
|
|
@@ -46,6 +46,8 @@ jest.mock('../../../helpers', () => ({
|
|
|
46
46
|
organizationId: 'org-1',
|
|
47
47
|
})),
|
|
48
48
|
findScopedWebhook: jest.fn(async () => webhookRecord),
|
|
49
|
+
canRevealWebhookCredentialHeaders: jest.fn(async () => true),
|
|
50
|
+
translateWebhookCustomHeadersRejection: jest.fn(async () => 'rejected'),
|
|
49
51
|
serializeWebhookDetail: (item: { id: string; isActive: boolean; consecutiveFailures: number }) => ({
|
|
50
52
|
id: item.id,
|
|
51
53
|
isActive: item.isActive,
|
package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts
CHANGED
|
@@ -55,6 +55,8 @@ jest.mock('../../../helpers', () => ({
|
|
|
55
55
|
organizationId: 'org-1',
|
|
56
56
|
})),
|
|
57
57
|
findScopedWebhook: jest.fn(async () => webhookRecord),
|
|
58
|
+
canRevealWebhookCredentialHeaders: jest.fn(async () => true),
|
|
59
|
+
translateWebhookCustomHeadersRejection: jest.fn(async () => 'rejected'),
|
|
58
60
|
serializeWebhookDetail: (item: { id: string }) => ({ id: item.id }),
|
|
59
61
|
}))
|
|
60
62
|
|
|
@@ -2,12 +2,20 @@ import { z } from 'zod'
|
|
|
2
2
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
3
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
4
4
|
import { emitWebhooksEvent } from '../../../events'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
canRevealWebhookCredentialHeaders,
|
|
7
|
+
findScopedWebhook,
|
|
8
|
+
json,
|
|
9
|
+
resolveWebhookRequestScope,
|
|
10
|
+
serializeWebhookDetail,
|
|
11
|
+
translateWebhookCustomHeadersRejection,
|
|
12
|
+
} from '../../helpers'
|
|
6
13
|
import { webhookUpdateSchema } from '../../../data/validators'
|
|
7
14
|
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
8
15
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
9
16
|
import { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'
|
|
10
17
|
import { applyWebhookActiveState } from '../../../lib/activation'
|
|
18
|
+
import { planWebhookCustomHeadersUpdate } from '../../../lib/custom-headers'
|
|
11
19
|
|
|
12
20
|
export const metadata = {
|
|
13
21
|
GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
|
|
@@ -34,7 +42,9 @@ const webhookDetailResponseSchema = z.object({
|
|
|
34
42
|
lastFailureAt: z.string().nullable(),
|
|
35
43
|
createdAt: z.string(),
|
|
36
44
|
updatedAt: z.string(),
|
|
37
|
-
customHeaders: z.record(z.string(), z.string()).nullable()
|
|
45
|
+
customHeaders: z.record(z.string(), z.string()).nullable().describe(
|
|
46
|
+
'Credential-like header values are returned as •••••••• unless the caller has webhooks.secrets. Sending •••••••• back on update keeps the stored value.',
|
|
47
|
+
),
|
|
38
48
|
strategyConfig: z.record(z.string(), z.unknown()).nullable(),
|
|
39
49
|
timeoutMs: z.number(),
|
|
40
50
|
rateLimitPerMinute: z.number(),
|
|
@@ -58,7 +68,8 @@ export async function GET(request: Request, context: RouteContext): Promise<Resp
|
|
|
58
68
|
return json({ error: 'Webhook not found' }, { status: 404 })
|
|
59
69
|
}
|
|
60
70
|
|
|
61
|
-
|
|
71
|
+
const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook)
|
|
72
|
+
return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }))
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
export async function PUT(request: Request, context: RouteContext): Promise<Response> {
|
|
@@ -91,12 +102,22 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
|
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
const input = parsed.data
|
|
105
|
+
const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook)
|
|
106
|
+
const headersUpdate = planWebhookCustomHeadersUpdate({
|
|
107
|
+
current: webhook,
|
|
108
|
+
next: input,
|
|
109
|
+
canRevealCredentials: revealCredentialHeaders,
|
|
110
|
+
})
|
|
111
|
+
if (!headersUpdate.ok) {
|
|
112
|
+
return json({ error: await translateWebhookCustomHeadersRejection(headersUpdate) }, { status: 400 })
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
if (input.name !== undefined) webhook.name = input.name
|
|
95
116
|
if (input.description !== undefined) webhook.description = input.description
|
|
96
117
|
if (input.url !== undefined) webhook.url = input.url
|
|
97
118
|
if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents
|
|
98
119
|
if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod
|
|
99
|
-
if (
|
|
120
|
+
if (headersUpdate.customHeaders !== undefined) webhook.customHeaders = headersUpdate.customHeaders
|
|
100
121
|
if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy
|
|
101
122
|
if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig
|
|
102
123
|
if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries
|
|
@@ -116,7 +137,7 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
|
|
|
116
137
|
tenantId: webhook.tenantId,
|
|
117
138
|
}, { persistent: true })
|
|
118
139
|
|
|
119
|
-
return json(serializeWebhookDetail(webhook))
|
|
140
|
+
return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }))
|
|
120
141
|
}
|
|
121
142
|
|
|
122
143
|
export async function DELETE(request: Request, context: RouteContext): Promise<Response> {
|
|
@@ -183,7 +204,11 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
183
204
|
},
|
|
184
205
|
responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],
|
|
185
206
|
errors: [
|
|
186
|
-
{
|
|
207
|
+
{
|
|
208
|
+
status: 400,
|
|
209
|
+
description: 'Invalid payload, a hidden header value with no stored value, or a URL change that keeps hidden header values for a caller without webhooks.secrets',
|
|
210
|
+
schema: errorSchema,
|
|
211
|
+
},
|
|
187
212
|
{ status: 401, description: 'Unauthorized', schema: errorSchema },
|
|
188
213
|
{ status: 404, description: 'Webhook not found', schema: errorSchema },
|
|
189
214
|
],
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
4
|
+
|
|
5
|
+
type ApplyToEntity = (entity: Record<string, unknown>, input: Record<string, unknown>, ctx: unknown) => Promise<void> | void
|
|
6
|
+
|
|
7
|
+
const captured: { applyToEntity: ApplyToEntity | null } = { applyToEntity: null }
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/crud/factory', () => ({
|
|
10
|
+
makeCrudRoute: jest.fn((options: { update?: { applyToEntity?: ApplyToEntity } }) => {
|
|
11
|
+
captured.applyToEntity = options.update?.applyToEntity ?? null
|
|
12
|
+
return { metadata: {}, GET: jest.fn(), POST: jest.fn(), PUT: jest.fn(), DELETE: jest.fn() }
|
|
13
|
+
}),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
17
|
+
resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import '../route'
|
|
21
|
+
|
|
22
|
+
const REDACTED = '••••••••'
|
|
23
|
+
|
|
24
|
+
let grantedFeatures: string[] = []
|
|
25
|
+
const userHasAllFeatures = jest.fn(async (_sub: string, required: string[]) =>
|
|
26
|
+
required.every((feature) => grantedFeatures.includes(feature)),
|
|
27
|
+
)
|
|
28
|
+
const ctx = {
|
|
29
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' },
|
|
30
|
+
container: { resolve: (token: string) => (token === 'rbacService' ? { userHasAllFeatures } : null) },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeEntity() {
|
|
34
|
+
return {
|
|
35
|
+
id: 'webhook-1',
|
|
36
|
+
name: 'Hook',
|
|
37
|
+
url: 'https://example.com/hook',
|
|
38
|
+
tenantId: 'tenant-1',
|
|
39
|
+
organizationId: 'org-1',
|
|
40
|
+
customHeaders: { Authorization: 'Bearer stored', 'x-source': 'qa' } as Record<string, string> | null,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function applyExpectingError(entity: Record<string, unknown>, input: Record<string, unknown>) {
|
|
45
|
+
try {
|
|
46
|
+
await captured.applyToEntity?.(entity, input, ctx)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return error
|
|
49
|
+
}
|
|
50
|
+
throw new Error('[internal] expected applyToEntity to throw')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('PUT /api/webhooks credential header handling (issue #7237)', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks()
|
|
56
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage']
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps the stored value when the redaction marker is echoed', async () => {
|
|
60
|
+
const entity = makeEntity()
|
|
61
|
+
|
|
62
|
+
await captured.applyToEntity?.(entity, { name: 'Renamed', customHeaders: { Authorization: REDACTED, 'x-source': 'erp' } }, ctx)
|
|
63
|
+
|
|
64
|
+
expect(entity.customHeaders).toEqual({ Authorization: 'Bearer stored', 'x-source': 'erp' })
|
|
65
|
+
expect(entity.name).toBe('Renamed')
|
|
66
|
+
expect(userHasAllFeatures).toHaveBeenCalledWith('user-1', ['webhooks.secrets'], { tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('throws a 400 and changes nothing for a marker without a stored value', async () => {
|
|
70
|
+
const entity = makeEntity()
|
|
71
|
+
|
|
72
|
+
const error = await applyExpectingError(entity, { name: 'Renamed', customHeaders: { 'x-token': REDACTED } })
|
|
73
|
+
|
|
74
|
+
expect(isCrudHttpError(error) && error.status).toBe(400)
|
|
75
|
+
expect(entity).toEqual(makeEntity())
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('throws a 400 when stored credentials would follow a new host for a caller without webhooks.secrets', async () => {
|
|
79
|
+
const entity = makeEntity()
|
|
80
|
+
|
|
81
|
+
const error = await applyExpectingError(entity, { url: 'https://attacker.example.net/collect' })
|
|
82
|
+
|
|
83
|
+
expect(isCrudHttpError(error) && error.status).toBe(400)
|
|
84
|
+
expect(entity).toEqual(makeEntity())
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('lets a webhooks.secrets holder move stored credentials to a new host', async () => {
|
|
88
|
+
grantedFeatures = ['webhooks.view', 'webhooks.manage', 'webhooks.secrets']
|
|
89
|
+
const entity = makeEntity()
|
|
90
|
+
|
|
91
|
+
await captured.applyToEntity?.(entity, { url: 'https://other.example.net/hook' }, ctx)
|
|
92
|
+
|
|
93
|
+
expect(entity.url).toBe('https://other.example.net/hook')
|
|
94
|
+
expect(entity.customHeaders).toEqual(makeEntity().customHeaders)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -12,6 +12,9 @@ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
|
12
12
|
import type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'
|
|
13
13
|
import { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'
|
|
14
14
|
import { applyWebhookActiveState } from '../../lib/activation'
|
|
15
|
+
import { planWebhookCustomHeadersUpdate } from '../../lib/custom-headers'
|
|
16
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
17
|
+
import { canRevealWebhookCredentialHeaders, translateWebhookCustomHeadersRejection } from '../helpers'
|
|
15
18
|
|
|
16
19
|
type WebhookCrudCtx = CrudCtx & {
|
|
17
20
|
__webhookSecret?: string
|
|
@@ -59,7 +62,9 @@ const webhookCreateResponseSchema = z.object({
|
|
|
59
62
|
})
|
|
60
63
|
|
|
61
64
|
const webhookDetailResponseSchema = webhookListItemSchema.extend({
|
|
62
|
-
customHeaders: z.record(z.string(), z.string()).nullable()
|
|
65
|
+
customHeaders: z.record(z.string(), z.string()).nullable().describe(
|
|
66
|
+
'Credential-like header values are returned as •••••••• unless the caller has webhooks.secrets. Sending •••••••• back on update keeps the stored value.',
|
|
67
|
+
),
|
|
63
68
|
strategyConfig: z.record(z.string(), z.unknown()).nullable(),
|
|
64
69
|
timeoutMs: z.number(),
|
|
65
70
|
rateLimitPerMinute: z.number(),
|
|
@@ -122,13 +127,21 @@ const crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeo
|
|
|
122
127
|
},
|
|
123
128
|
update: {
|
|
124
129
|
schema: webhookUpdateSchema,
|
|
125
|
-
applyToEntity: (entity, input) => {
|
|
130
|
+
applyToEntity: async (entity: WebhookEntity, input, ctx) => {
|
|
131
|
+
const headersUpdate = planWebhookCustomHeadersUpdate({
|
|
132
|
+
current: entity,
|
|
133
|
+
next: input,
|
|
134
|
+
canRevealCredentials: await canRevealWebhookCredentialHeaders(ctx.container, ctx.auth, entity),
|
|
135
|
+
})
|
|
136
|
+
if (!headersUpdate.ok) {
|
|
137
|
+
throw new CrudHttpError(400, { error: await translateWebhookCustomHeadersRejection(headersUpdate) })
|
|
138
|
+
}
|
|
126
139
|
if (input.name !== undefined) entity.name = input.name
|
|
127
140
|
if (input.description !== undefined) entity.description = input.description
|
|
128
141
|
if (input.url !== undefined) entity.url = input.url
|
|
129
142
|
if (input.subscribedEvents !== undefined) entity.subscribedEvents = input.subscribedEvents
|
|
130
143
|
if (input.httpMethod !== undefined) entity.httpMethod = input.httpMethod
|
|
131
|
-
if (
|
|
144
|
+
if (headersUpdate.customHeaders !== undefined) entity.customHeaders = headersUpdate.customHeaders
|
|
132
145
|
if (input.deliveryStrategy !== undefined) entity.deliveryStrategy = input.deliveryStrategy
|
|
133
146
|
if (input.strategyConfig !== undefined) entity.strategyConfig = input.strategyConfig
|
|
134
147
|
if (input.maxRetries !== undefined) entity.maxRetries = input.maxRetries
|
|
@@ -291,7 +304,11 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
291
304
|
requestBody: { contentType: 'application/json', schema: webhookUpdateSchema, description: 'Fields to update.' },
|
|
292
305
|
responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],
|
|
293
306
|
errors: [
|
|
294
|
-
{
|
|
307
|
+
{
|
|
308
|
+
status: 400,
|
|
309
|
+
description: 'Invalid payload, a hidden header value with no stored value, or a URL change that keeps hidden header values for a caller without webhooks.secrets',
|
|
310
|
+
schema: errorSchema,
|
|
311
|
+
},
|
|
295
312
|
{ status: 404, description: 'Not found', schema: errorSchema },
|
|
296
313
|
],
|
|
297
314
|
},
|
|
@@ -45,9 +45,11 @@
|
|
|
45
45
|
"webhooks.detail.testResult": "Latest Test Result",
|
|
46
46
|
"webhooks.detail.testSuccess": "Test delivery succeeded.",
|
|
47
47
|
"webhooks.detail.title": "Webhook Detail",
|
|
48
|
+
"webhooks.errors.credentialHeadersRequireReentry": "Der Header \"{header}\" hat einen verborgenen Wert. Geben Sie ihn erneut ein oder entfernen Sie ihn, wenn Sie die Webhook-URL ändern.",
|
|
48
49
|
"webhooks.errors.forbidden": "You do not have permission to perform this action.",
|
|
49
50
|
"webhooks.errors.notFound": "Webhook not found.",
|
|
50
51
|
"webhooks.errors.orgRequired": "Organization context required.",
|
|
52
|
+
"webhooks.errors.redactedHeaderWithoutValue": "Der Header \"{header}\" hat einen verborgenen Wert, aber keinen gespeicherten Wert, der beibehalten werden kann. Geben Sie den Header-Wert ein.",
|
|
51
53
|
"webhooks.errors.tenantRequired": "Tenant context required.",
|
|
52
54
|
"webhooks.form.advancedStrategyTip": "HTTP delivery is active today. Use custom headers for routing hints, and keep secrets out of request headers.",
|
|
53
55
|
"webhooks.form.autoDisableThreshold": "Auto-Disable Threshold",
|
|
@@ -55,7 +57,7 @@
|
|
|
55
57
|
"webhooks.form.createError": "Failed to create webhook.",
|
|
56
58
|
"webhooks.form.createSuccess": "Webhook created successfully.",
|
|
57
59
|
"webhooks.form.customHeaders": "Custom Headers",
|
|
58
|
-
"webhooks.form.customHeadersHint": "
|
|
60
|
+
"webhooks.form.customHeadersHint": "Optionales JSON-Objekt mit zusätzlichen Headern, die mit jeder Webhook-Anfrage gesendet werden. Verborgene Werte (••••••••) bleiben beim Speichern erhalten; ersetzen Sie einen Wert, um ihn zu ändern.",
|
|
59
61
|
"webhooks.form.customHeadersInvalid": "Custom headers must be a valid JSON object with string values.",
|
|
60
62
|
"webhooks.form.customHeadersTip": "Use headers for static metadata such as environment or source identifiers. Do not put access tokens or secrets here.",
|
|
61
63
|
"webhooks.form.deliveryDefaultsTip": "The defaults are a safe starting point for most consumers. Increase retries or timeout only if the receiver is known to be slow.",
|
|
@@ -45,9 +45,11 @@
|
|
|
45
45
|
"webhooks.detail.testResult": "Latest Test Result",
|
|
46
46
|
"webhooks.detail.testSuccess": "Test delivery succeeded.",
|
|
47
47
|
"webhooks.detail.title": "Webhook Detail",
|
|
48
|
+
"webhooks.errors.credentialHeadersRequireReentry": "Header \"{header}\" has a hidden value. Enter it again or remove it when you change the webhook URL.",
|
|
48
49
|
"webhooks.errors.forbidden": "You do not have permission to perform this action.",
|
|
49
50
|
"webhooks.errors.notFound": "Webhook not found.",
|
|
50
51
|
"webhooks.errors.orgRequired": "Organization context required.",
|
|
52
|
+
"webhooks.errors.redactedHeaderWithoutValue": "Header \"{header}\" has a hidden value but no saved value to keep. Enter the header value.",
|
|
51
53
|
"webhooks.errors.tenantRequired": "Tenant context required.",
|
|
52
54
|
"webhooks.form.advancedStrategyTip": "HTTP delivery is active today. Use custom headers for routing hints, and keep secrets out of request headers.",
|
|
53
55
|
"webhooks.form.autoDisableThreshold": "Auto-Disable Threshold",
|
|
@@ -55,7 +57,7 @@
|
|
|
55
57
|
"webhooks.form.createError": "Failed to create webhook.",
|
|
56
58
|
"webhooks.form.createSuccess": "Webhook created successfully.",
|
|
57
59
|
"webhooks.form.customHeaders": "Custom Headers",
|
|
58
|
-
"webhooks.form.customHeadersHint": "Optional JSON object of additional headers to send with each webhook request.",
|
|
60
|
+
"webhooks.form.customHeadersHint": "Optional JSON object of additional headers to send with each webhook request. Hidden values (••••••••) are kept when you save; replace one to change it.",
|
|
59
61
|
"webhooks.form.customHeadersInvalid": "Custom headers must be a valid JSON object with string values.",
|
|
60
62
|
"webhooks.form.customHeadersTip": "Use headers for static metadata such as environment or source identifiers. Do not put access tokens or secrets here.",
|
|
61
63
|
"webhooks.form.deliveryDefaultsTip": "The defaults are a safe starting point for most consumers. Increase retries or timeout only if the receiver is known to be slow.",
|