@bernierllc/contentful-webhook-handler 1.0.3

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.
@@ -0,0 +1,682 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import { ContentfulWebhookHandler } from '../ContentfulWebhookHandler';
11
+ import type {
12
+ ContentfulWebhookPayload,
13
+ ContentfulWebhookTopic,
14
+ ContentfulWebhookHeaders
15
+ } from '@bernierllc/contentful-types';
16
+ import { Logger, LogLevel, ConsoleTransport } from '@bernierllc/logger';
17
+
18
+ describe('ContentfulWebhookHandler', () => {
19
+ const TEST_SECRET = 'test-webhook-secret';
20
+ let handler: ContentfulWebhookHandler;
21
+
22
+ beforeEach(() => {
23
+ handler = new ContentfulWebhookHandler({
24
+ webhookSecret: TEST_SECRET
25
+ });
26
+ });
27
+
28
+ describe('constructor', () => {
29
+ it('should create handler with required options', () => {
30
+ expect(handler).toBeInstanceOf(ContentfulWebhookHandler);
31
+ });
32
+
33
+ it('should accept custom logger', () => {
34
+ const customLogger = new Logger({
35
+ level: LogLevel.INFO,
36
+ transports: [new ConsoleTransport()],
37
+ context: {
38
+ service: 'CustomContext',
39
+ version: '1.0.0',
40
+ environment: 'test'
41
+ }
42
+ });
43
+ const customHandler = new ContentfulWebhookHandler({
44
+ webhookSecret: TEST_SECRET,
45
+ logger: customLogger
46
+ });
47
+ expect(customHandler).toBeInstanceOf(ContentfulWebhookHandler);
48
+ });
49
+
50
+ it('should accept timestamp validation options', () => {
51
+ const timestampHandler = new ContentfulWebhookHandler({
52
+ webhookSecret: TEST_SECRET,
53
+ enableTimestampValidation: true,
54
+ maxTimestampDeltaSeconds: 600
55
+ });
56
+ expect(timestampHandler).toBeInstanceOf(ContentfulWebhookHandler);
57
+ });
58
+ });
59
+
60
+ describe('validateSignature', () => {
61
+ it('should validate correct signature', () => {
62
+ const body = '{"test":"data"}';
63
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
64
+ hmac.update(body);
65
+ const signature = hmac.digest('base64');
66
+
67
+ const isValid = handler.validateSignature(body, signature);
68
+ expect(isValid).toBe(true);
69
+ });
70
+
71
+ it('should reject invalid signature', () => {
72
+ const body = '{"test":"data"}';
73
+ const invalidSignature = 'invalid-signature-base64';
74
+
75
+ const isValid = handler.validateSignature(body, invalidSignature);
76
+ expect(isValid).toBe(false);
77
+ });
78
+
79
+ it('should reject tampered body', () => {
80
+ const originalBody = '{"test":"data"}';
81
+ const tamperedBody = '{"test":"tampered"}';
82
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
83
+ hmac.update(originalBody);
84
+ const signature = hmac.digest('base64');
85
+
86
+ const isValid = handler.validateSignature(tamperedBody, signature);
87
+ expect(isValid).toBe(false);
88
+ });
89
+
90
+ it('should reject signature with different secret', () => {
91
+ const body = '{"test":"data"}';
92
+ const hmac = crypto.createHmac('sha256', 'different-secret');
93
+ hmac.update(body);
94
+ const signature = hmac.digest('base64');
95
+
96
+ const isValid = handler.validateSignature(body, signature);
97
+ expect(isValid).toBe(false);
98
+ });
99
+
100
+ it('should handle empty body', () => {
101
+ const body = '';
102
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
103
+ hmac.update(body);
104
+ const signature = hmac.digest('base64');
105
+
106
+ const isValid = handler.validateSignature(body, signature);
107
+ expect(isValid).toBe(true);
108
+ });
109
+
110
+ it('should return false for buffer length mismatch', () => {
111
+ const body = '{"test":"data"}';
112
+ const shortSignature = 'short';
113
+
114
+ const isValid = handler.validateSignature(body, shortSignature);
115
+ expect(isValid).toBe(false);
116
+ });
117
+
118
+ it('should handle crypto errors gracefully', () => {
119
+ const body = '{"test":"data"}';
120
+ // Intentionally malformed base64 that will cause issues
121
+ const badSignature = '!!!invalid-base64!!!';
122
+
123
+ const isValid = handler.validateSignature(body, badSignature);
124
+ expect(isValid).toBe(false);
125
+ });
126
+
127
+ it('should handle non-Error exceptions in signature validation', () => {
128
+ const testHandler = new ContentfulWebhookHandler({
129
+ webhookSecret: TEST_SECRET
130
+ });
131
+
132
+ const body = '{"test":"data"}';
133
+
134
+ // Mock crypto.createHmac to throw a non-Error
135
+ const originalCreateHmac = crypto.createHmac;
136
+ crypto.createHmac = jest.fn().mockImplementationOnce(() => {
137
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
138
+ throw 'crypto error';
139
+ });
140
+
141
+ try {
142
+ const isValid = testHandler.validateSignature(body, 'signature');
143
+ expect(isValid).toBe(false);
144
+ } finally {
145
+ // Restore original
146
+ crypto.createHmac = originalCreateHmac;
147
+ }
148
+ });
149
+ });
150
+
151
+ describe('parseWebhook', () => {
152
+ const createValidPayload = (): ContentfulWebhookPayload => ({
153
+ sys: {
154
+ type: 'Entry',
155
+ id: 'test-entry-123',
156
+ space: {
157
+ sys: {
158
+ type: 'Link',
159
+ linkType: 'Space',
160
+ id: 'space-123'
161
+ }
162
+ },
163
+ environment: {
164
+ sys: {
165
+ type: 'Link',
166
+ linkType: 'Environment',
167
+ id: 'master'
168
+ }
169
+ },
170
+ contentType: {
171
+ sys: {
172
+ type: 'Link',
173
+ linkType: 'ContentType',
174
+ id: 'blogPost'
175
+ }
176
+ },
177
+ revision: 1,
178
+ createdAt: new Date().toISOString(),
179
+ updatedAt: new Date().toISOString()
180
+ },
181
+ fields: {
182
+ title: { 'en-US': 'Test Title' },
183
+ content: { 'en-US': 'Test content' }
184
+ }
185
+ });
186
+
187
+ const signBody = (body: string): string => {
188
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
189
+ hmac.update(body);
190
+ return hmac.digest('base64');
191
+ };
192
+
193
+ it('should parse valid webhook', () => {
194
+ const payload = createValidPayload();
195
+ const body = JSON.stringify(payload);
196
+ const signature = signBody(body);
197
+
198
+ const headers: Partial<ContentfulWebhookHeaders> = {
199
+ 'x-contentful-signature': signature,
200
+ 'x-contentful-topic': 'Entry.publish'
201
+ };
202
+
203
+ const result = handler.parseWebhook(body, headers);
204
+
205
+ expect(result.valid).toBe(true);
206
+ expect(result.error).toBeUndefined();
207
+ expect(result.payload).toEqual(payload);
208
+ expect(result.topic).toBe('Entry.publish');
209
+ });
210
+
211
+ it('should reject webhook without signature header', () => {
212
+ const payload = createValidPayload();
213
+ const body = JSON.stringify(payload);
214
+
215
+ const headers: Partial<ContentfulWebhookHeaders> = {
216
+ 'x-contentful-topic': 'Entry.publish'
217
+ };
218
+
219
+ const result = handler.parseWebhook(body, headers);
220
+
221
+ expect(result.valid).toBe(false);
222
+ expect(result.error).toBe('Missing signature header (x-contentful-signature)');
223
+ expect(result.payload).toBeUndefined();
224
+ });
225
+
226
+ it('should reject webhook without topic header', () => {
227
+ const payload = createValidPayload();
228
+ const body = JSON.stringify(payload);
229
+ const signature = signBody(body);
230
+
231
+ const headers: Partial<ContentfulWebhookHeaders> = {
232
+ 'x-contentful-signature': signature
233
+ };
234
+
235
+ const result = handler.parseWebhook(body, headers);
236
+
237
+ expect(result.valid).toBe(false);
238
+ expect(result.error).toBe('Missing topic header (x-contentful-topic)');
239
+ expect(result.payload).toBeUndefined();
240
+ });
241
+
242
+ it('should reject webhook with invalid signature', () => {
243
+ const payload = createValidPayload();
244
+ const body = JSON.stringify(payload);
245
+
246
+ const headers: Partial<ContentfulWebhookHeaders> = {
247
+ 'x-contentful-signature': 'invalid-signature',
248
+ 'x-contentful-topic': 'Entry.publish'
249
+ };
250
+
251
+ const result = handler.parseWebhook(body, headers);
252
+
253
+ expect(result.valid).toBe(false);
254
+ expect(result.error).toBe('Invalid signature');
255
+ expect(result.payload).toBeUndefined();
256
+ });
257
+
258
+ it('should reject webhook with invalid JSON', () => {
259
+ const body = 'invalid json {';
260
+ const signature = signBody(body);
261
+
262
+ const headers: Partial<ContentfulWebhookHeaders> = {
263
+ 'x-contentful-signature': signature,
264
+ 'x-contentful-topic': 'Entry.publish'
265
+ };
266
+
267
+ const result = handler.parseWebhook(body, headers);
268
+
269
+ expect(result.valid).toBe(false);
270
+ expect(result.error).toContain('Unexpected');
271
+ expect(result.payload).toBeUndefined();
272
+ });
273
+
274
+ it('should handle non-Error exceptions in parsing', () => {
275
+ // Create a new handler for this test to avoid affecting other tests
276
+ const testHandler = new ContentfulWebhookHandler({
277
+ webhookSecret: TEST_SECRET
278
+ });
279
+
280
+ const body = '{"test":"data"}';
281
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
282
+ hmac.update(body);
283
+ const signature = hmac.digest('base64');
284
+
285
+ // Mock JSON.parse to throw a non-Error
286
+ const originalParse = global.JSON.parse;
287
+ global.JSON.parse = jest.fn().mockImplementationOnce(() => {
288
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
289
+ throw 'string error';
290
+ });
291
+
292
+ try {
293
+ const headers: Partial<ContentfulWebhookHeaders> = {
294
+ 'x-contentful-signature': signature,
295
+ 'x-contentful-topic': 'Entry.publish'
296
+ };
297
+
298
+ const result = testHandler.parseWebhook(body, headers);
299
+
300
+ expect(result.valid).toBe(false);
301
+ expect(result.error).toBe('string error');
302
+ } finally {
303
+ // Restore original
304
+ global.JSON.parse = originalParse;
305
+ }
306
+ });
307
+
308
+ it('should handle all webhook topics', () => {
309
+ const topics: ContentfulWebhookTopic[] = [
310
+ 'Entry.create',
311
+ 'Entry.publish',
312
+ 'Asset.delete',
313
+ 'ContentType.save'
314
+ ];
315
+
316
+ topics.forEach(topic => {
317
+ const payload = createValidPayload();
318
+ const body = JSON.stringify(payload);
319
+ const signature = signBody(body);
320
+
321
+ const headers: Partial<ContentfulWebhookHeaders> = {
322
+ 'x-contentful-signature': signature,
323
+ 'x-contentful-topic': topic
324
+ };
325
+
326
+ const result = handler.parseWebhook(body, headers);
327
+
328
+ expect(result.valid).toBe(true);
329
+ expect(result.topic).toBe(topic);
330
+ });
331
+ });
332
+ });
333
+
334
+ describe('timestamp validation', () => {
335
+ const createPayloadWithTimestamp = (timestamp: string): ContentfulWebhookPayload => ({
336
+ sys: {
337
+ type: 'Entry',
338
+ id: 'test-entry-123',
339
+ space: {
340
+ sys: {
341
+ type: 'Link',
342
+ linkType: 'Space',
343
+ id: 'space-123'
344
+ }
345
+ },
346
+ environment: {
347
+ sys: {
348
+ type: 'Link',
349
+ linkType: 'Environment',
350
+ id: 'master'
351
+ }
352
+ },
353
+ contentType: {
354
+ sys: {
355
+ type: 'Link',
356
+ linkType: 'ContentType',
357
+ id: 'blogPost'
358
+ }
359
+ },
360
+ updatedAt: timestamp
361
+ }
362
+ });
363
+
364
+ const signBody = (body: string): string => {
365
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
366
+ hmac.update(body);
367
+ return hmac.digest('base64');
368
+ };
369
+
370
+ it('should accept recent timestamp when validation enabled', () => {
371
+ const timestampHandler = new ContentfulWebhookHandler({
372
+ webhookSecret: TEST_SECRET,
373
+ enableTimestampValidation: true,
374
+ maxTimestampDeltaSeconds: 300
375
+ });
376
+
377
+ const payload = createPayloadWithTimestamp(new Date().toISOString());
378
+ const body = JSON.stringify(payload);
379
+ const signature = signBody(body);
380
+
381
+ const headers: Partial<ContentfulWebhookHeaders> = {
382
+ 'x-contentful-signature': signature,
383
+ 'x-contentful-topic': 'Entry.publish'
384
+ };
385
+
386
+ const result = timestampHandler.parseWebhook(body, headers);
387
+
388
+ expect(result.valid).toBe(true);
389
+ });
390
+
391
+ it('should reject old timestamp when validation enabled', () => {
392
+ const timestampHandler = new ContentfulWebhookHandler({
393
+ webhookSecret: TEST_SECRET,
394
+ enableTimestampValidation: true,
395
+ maxTimestampDeltaSeconds: 300
396
+ });
397
+
398
+ // Create timestamp 10 minutes ago (600 seconds)
399
+ const oldDate = new Date(Date.now() - 600000);
400
+ const payload = createPayloadWithTimestamp(oldDate.toISOString());
401
+ const body = JSON.stringify(payload);
402
+ const signature = signBody(body);
403
+
404
+ const headers: Partial<ContentfulWebhookHeaders> = {
405
+ 'x-contentful-signature': signature,
406
+ 'x-contentful-topic': 'Entry.publish'
407
+ };
408
+
409
+ const result = timestampHandler.parseWebhook(body, headers);
410
+
411
+ expect(result.valid).toBe(false);
412
+ expect(result.error).toBe('Invalid timestamp (possible replay attack)');
413
+ });
414
+
415
+ it('should accept any timestamp when validation disabled', () => {
416
+ // Default handler has timestamp validation disabled
417
+ const oldDate = new Date(Date.now() - 600000);
418
+ const payload = createPayloadWithTimestamp(oldDate.toISOString());
419
+ const body = JSON.stringify(payload);
420
+ const signature = signBody(body);
421
+
422
+ const headers: Partial<ContentfulWebhookHeaders> = {
423
+ 'x-contentful-signature': signature,
424
+ 'x-contentful-topic': 'Entry.publish'
425
+ };
426
+
427
+ const result = handler.parseWebhook(body, headers);
428
+
429
+ expect(result.valid).toBe(true);
430
+ });
431
+
432
+ it('should reject payload without timestamp when validation enabled', () => {
433
+ const timestampHandler = new ContentfulWebhookHandler({
434
+ webhookSecret: TEST_SECRET,
435
+ enableTimestampValidation: true,
436
+ maxTimestampDeltaSeconds: 300
437
+ });
438
+
439
+ const payload: ContentfulWebhookPayload = {
440
+ sys: {
441
+ type: 'Entry',
442
+ id: 'test-entry-123',
443
+ space: {
444
+ sys: {
445
+ type: 'Link',
446
+ linkType: 'Space',
447
+ id: 'space-123'
448
+ }
449
+ },
450
+ environment: {
451
+ sys: {
452
+ type: 'Link',
453
+ linkType: 'Environment',
454
+ id: 'master'
455
+ }
456
+ }
457
+ }
458
+ };
459
+
460
+ const body = JSON.stringify(payload);
461
+ const signature = signBody(body);
462
+
463
+ const headers: Partial<ContentfulWebhookHeaders> = {
464
+ 'x-contentful-signature': signature,
465
+ 'x-contentful-topic': 'Entry.publish'
466
+ };
467
+
468
+ const result = timestampHandler.parseWebhook(body, headers);
469
+
470
+ expect(result.valid).toBe(false);
471
+ expect(result.error).toBe('Invalid timestamp (possible replay attack)');
472
+ });
473
+ });
474
+
475
+ describe('getEventType', () => {
476
+ it('should parse Entry.publish topic', () => {
477
+ const result = handler.getEventType('Entry.publish');
478
+ expect(result.resource).toBe('Entry');
479
+ expect(result.action).toBe('publish');
480
+ });
481
+
482
+ it('should parse Asset.delete topic', () => {
483
+ const result = handler.getEventType('Asset.delete');
484
+ expect(result.resource).toBe('Asset');
485
+ expect(result.action).toBe('delete');
486
+ });
487
+
488
+ it('should parse ContentType.save topic', () => {
489
+ const result = handler.getEventType('ContentType.save');
490
+ expect(result.resource).toBe('ContentType');
491
+ expect(result.action).toBe('save');
492
+ });
493
+
494
+ it('should parse Entry.create topic', () => {
495
+ const result = handler.getEventType('Entry.create');
496
+ expect(result.resource).toBe('Entry');
497
+ expect(result.action).toBe('create');
498
+ });
499
+
500
+ it('should parse Entry.auto_save topic', () => {
501
+ const result = handler.getEventType('Entry.auto_save');
502
+ expect(result.resource).toBe('Entry');
503
+ expect(result.action).toBe('auto_save');
504
+ });
505
+
506
+ it('should parse Entry.archive topic', () => {
507
+ const result = handler.getEventType('Entry.archive');
508
+ expect(result.resource).toBe('Entry');
509
+ expect(result.action).toBe('archive');
510
+ });
511
+
512
+ it('should parse Entry.unarchive topic', () => {
513
+ const result = handler.getEventType('Entry.unarchive');
514
+ expect(result.resource).toBe('Entry');
515
+ expect(result.action).toBe('unarchive');
516
+ });
517
+
518
+ it('should parse Entry.unpublish topic', () => {
519
+ const result = handler.getEventType('Entry.unpublish');
520
+ expect(result.resource).toBe('Entry');
521
+ expect(result.action).toBe('unpublish');
522
+ });
523
+
524
+ it('should throw error for invalid topic format', () => {
525
+ expect(() => {
526
+ handler.getEventType('InvalidTopic' as ContentfulWebhookTopic);
527
+ }).toThrow('Invalid topic format: InvalidTopic');
528
+ });
529
+
530
+ it('should throw error for too many parts', () => {
531
+ expect(() => {
532
+ handler.getEventType('Entry.publish.extra' as ContentfulWebhookTopic);
533
+ }).toThrow('Invalid topic format: Entry.publish.extra');
534
+ });
535
+
536
+ it('should throw error for invalid resource', () => {
537
+ expect(() => {
538
+ handler.getEventType('InvalidResource.publish' as ContentfulWebhookTopic);
539
+ }).toThrow('Invalid resource type: InvalidResource');
540
+ });
541
+
542
+ it('should throw error for invalid action', () => {
543
+ expect(() => {
544
+ handler.getEventType('Entry.invalidAction' as ContentfulWebhookTopic);
545
+ }).toThrow('Invalid action type: invalidAction');
546
+ });
547
+ });
548
+
549
+ describe('updateSecret', () => {
550
+ it('should update webhook secret', () => {
551
+ const newSecret = 'new-test-secret';
552
+ handler.updateSecret(newSecret);
553
+
554
+ // Verify new secret works
555
+ const body = '{"test":"data"}';
556
+ const hmac = crypto.createHmac('sha256', newSecret);
557
+ hmac.update(body);
558
+ const signature = hmac.digest('base64');
559
+
560
+ const isValid = handler.validateSignature(body, signature);
561
+ expect(isValid).toBe(true);
562
+ });
563
+
564
+ it('should invalidate old secret after update', () => {
565
+ const body = '{"test":"data"}';
566
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
567
+ hmac.update(body);
568
+ const oldSignature = hmac.digest('base64');
569
+
570
+ // Update to new secret
571
+ handler.updateSecret('new-test-secret');
572
+
573
+ // Old signature should no longer work
574
+ const isValid = handler.validateSignature(body, oldSignature);
575
+ expect(isValid).toBe(false);
576
+ });
577
+ });
578
+
579
+ describe('integration scenarios', () => {
580
+ it('should handle complete Entry.publish webhook', () => {
581
+ const payload: ContentfulWebhookPayload = {
582
+ sys: {
583
+ type: 'Entry',
584
+ id: 'blog-post-123',
585
+ space: {
586
+ sys: {
587
+ type: 'Link',
588
+ linkType: 'Space',
589
+ id: 'my-space'
590
+ }
591
+ },
592
+ environment: {
593
+ sys: {
594
+ type: 'Link',
595
+ linkType: 'Environment',
596
+ id: 'master'
597
+ }
598
+ },
599
+ contentType: {
600
+ sys: {
601
+ type: 'Link',
602
+ linkType: 'ContentType',
603
+ id: 'blogPost'
604
+ }
605
+ },
606
+ revision: 5,
607
+ createdAt: '2025-01-01T00:00:00Z',
608
+ updatedAt: new Date().toISOString()
609
+ },
610
+ fields: {
611
+ title: { 'en-US': 'My Blog Post' },
612
+ content: { 'en-US': 'Blog post content here' }
613
+ }
614
+ };
615
+
616
+ const body = JSON.stringify(payload);
617
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
618
+ hmac.update(body);
619
+ const signature = hmac.digest('base64');
620
+
621
+ const headers: ContentfulWebhookHeaders = {
622
+ 'x-contentful-signature': signature,
623
+ 'x-contentful-topic': 'Entry.publish',
624
+ 'x-contentful-webhook-name': 'test-webhook',
625
+ 'x-contentful-crn': 'crn:contentful:::content:spaces/my-space'
626
+ };
627
+
628
+ const result = handler.parseWebhook(body, headers);
629
+
630
+ expect(result.valid).toBe(true);
631
+ expect(result.payload?.sys.id).toBe('blog-post-123');
632
+ expect(result.topic).toBe('Entry.publish');
633
+
634
+ if (result.topic) {
635
+ const eventType = handler.getEventType(result.topic);
636
+ expect(eventType.resource).toBe('Entry');
637
+ expect(eventType.action).toBe('publish');
638
+ }
639
+ });
640
+
641
+ it('should handle Asset.delete webhook', () => {
642
+ const payload: ContentfulWebhookPayload = {
643
+ sys: {
644
+ type: 'Asset',
645
+ id: 'asset-456',
646
+ space: {
647
+ sys: {
648
+ type: 'Link',
649
+ linkType: 'Space',
650
+ id: 'my-space'
651
+ }
652
+ },
653
+ environment: {
654
+ sys: {
655
+ type: 'Link',
656
+ linkType: 'Environment',
657
+ id: 'master'
658
+ }
659
+ },
660
+ revision: 1,
661
+ updatedAt: new Date().toISOString()
662
+ }
663
+ };
664
+
665
+ const body = JSON.stringify(payload);
666
+ const hmac = crypto.createHmac('sha256', TEST_SECRET);
667
+ hmac.update(body);
668
+ const signature = hmac.digest('base64');
669
+
670
+ const headers: Partial<ContentfulWebhookHeaders> = {
671
+ 'x-contentful-signature': signature,
672
+ 'x-contentful-topic': 'Asset.delete'
673
+ };
674
+
675
+ const result = handler.parseWebhook(body, headers);
676
+
677
+ expect(result.valid).toBe(true);
678
+ expect(result.payload?.sys.type).toBe('Asset');
679
+ expect(result.topic).toBe('Asset.delete');
680
+ });
681
+ });
682
+ });