@bernierllc/contentful-webhook-handler 1.3.0 → 1.3.2
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/package.json +2 -2
- package/dist/ContentfulWebhookHandler.d.ts +0 -71
- package/dist/ContentfulWebhookHandler.js +0 -213
- package/dist/__tests__/ContentfulWebhookHandler.test.d.ts +0 -1
- package/dist/__tests__/ContentfulWebhookHandler.test.js +0 -576
- package/dist/__tests__/errors.test.d.ts +0 -1
- package/dist/__tests__/errors.test.js +0 -28
- package/dist/__tests__/index.test.d.ts +0 -1
- package/dist/__tests__/index.test.js +0 -62
- package/dist/errors.d.ts +0 -11
- package/dist/errors.js +0 -24
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bernierllc/contentful-webhook-handler",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "Validates Contentful webhook signatures using HMAC-SHA256, parses webhook payloads, and provides type-safe interfaces for all webhook event types",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@bernierllc/contentful-types": "1.5.0",
|
|
23
|
-
"@bernierllc/logger": "
|
|
23
|
+
"@bernierllc/logger": "2.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/jest": "^29.5.12",
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { ContentfulWebhookPayload, ContentfulWebhookTopic, ContentfulWebhookHeaders } from '@bernierllc/contentful-types';
|
|
2
|
-
import { Logger } from '@bernierllc/logger';
|
|
3
|
-
export interface WebhookValidationResult {
|
|
4
|
-
valid: boolean;
|
|
5
|
-
error?: string;
|
|
6
|
-
payload?: ContentfulWebhookPayload;
|
|
7
|
-
topic?: ContentfulWebhookTopic;
|
|
8
|
-
}
|
|
9
|
-
export interface EventType {
|
|
10
|
-
resource: 'Entry' | 'Asset' | 'ContentType';
|
|
11
|
-
action: 'create' | 'save' | 'auto_save' | 'publish' | 'unpublish' | 'delete' | 'archive' | 'unarchive';
|
|
12
|
-
}
|
|
13
|
-
export interface ContentfulWebhookHandlerOptions {
|
|
14
|
-
webhookSecret: string;
|
|
15
|
-
logger?: Logger;
|
|
16
|
-
enableTimestampValidation?: boolean;
|
|
17
|
-
maxTimestampDeltaSeconds?: number;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Validates Contentful webhook signatures using HMAC-SHA256,
|
|
21
|
-
* parses webhook payloads, and provides type-safe interfaces
|
|
22
|
-
* for all webhook event types.
|
|
23
|
-
*/
|
|
24
|
-
export declare class ContentfulWebhookHandler {
|
|
25
|
-
private webhookSecret;
|
|
26
|
-
private logger;
|
|
27
|
-
private enableTimestampValidation;
|
|
28
|
-
private maxTimestampDeltaSeconds;
|
|
29
|
-
/**
|
|
30
|
-
* Creates a new ContentfulWebhookHandler instance
|
|
31
|
-
*
|
|
32
|
-
* @param options Configuration options
|
|
33
|
-
*/
|
|
34
|
-
constructor(options: ContentfulWebhookHandlerOptions);
|
|
35
|
-
/**
|
|
36
|
-
* Validates a webhook signature using HMAC-SHA256
|
|
37
|
-
*
|
|
38
|
-
* @param body Raw webhook body as string
|
|
39
|
-
* @param signature Signature from x-contentful-signature header
|
|
40
|
-
* @returns True if signature is valid
|
|
41
|
-
*/
|
|
42
|
-
validateSignature(body: string, signature: string): boolean;
|
|
43
|
-
/**
|
|
44
|
-
* Validates webhook timestamp to prevent replay attacks
|
|
45
|
-
*
|
|
46
|
-
* @param payload Parsed webhook payload
|
|
47
|
-
* @returns True if timestamp is within acceptable range
|
|
48
|
-
*/
|
|
49
|
-
private validateTimestamp;
|
|
50
|
-
/**
|
|
51
|
-
* Parses and validates a Contentful webhook
|
|
52
|
-
*
|
|
53
|
-
* @param body Raw webhook body as string
|
|
54
|
-
* @param headers Webhook headers
|
|
55
|
-
* @returns Validation result with parsed payload if valid
|
|
56
|
-
*/
|
|
57
|
-
parseWebhook(body: string, headers: Partial<ContentfulWebhookHeaders>): WebhookValidationResult;
|
|
58
|
-
/**
|
|
59
|
-
* Extracts resource type and action from a topic string
|
|
60
|
-
*
|
|
61
|
-
* @param topic Contentful webhook topic (e.g., "Entry.publish")
|
|
62
|
-
* @returns Object with resource type and action
|
|
63
|
-
*/
|
|
64
|
-
getEventType(topic: ContentfulWebhookTopic): EventType;
|
|
65
|
-
/**
|
|
66
|
-
* Updates the webhook secret
|
|
67
|
-
*
|
|
68
|
-
* @param newSecret New webhook secret
|
|
69
|
-
*/
|
|
70
|
-
updateSecret(newSecret: string): void;
|
|
71
|
-
}
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/*
|
|
3
|
-
Copyright (c) 2025 Bernier LLC
|
|
4
|
-
|
|
5
|
-
This file is licensed to the client under a limited-use license.
|
|
6
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
7
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
-
*/
|
|
9
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
-
};
|
|
12
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.ContentfulWebhookHandler = void 0;
|
|
14
|
-
const crypto_1 = __importDefault(require("crypto"));
|
|
15
|
-
const logger_1 = require("@bernierllc/logger");
|
|
16
|
-
/**
|
|
17
|
-
* Validates Contentful webhook signatures using HMAC-SHA256,
|
|
18
|
-
* parses webhook payloads, and provides type-safe interfaces
|
|
19
|
-
* for all webhook event types.
|
|
20
|
-
*/
|
|
21
|
-
class ContentfulWebhookHandler {
|
|
22
|
-
webhookSecret;
|
|
23
|
-
logger;
|
|
24
|
-
enableTimestampValidation;
|
|
25
|
-
maxTimestampDeltaSeconds;
|
|
26
|
-
/**
|
|
27
|
-
* Creates a new ContentfulWebhookHandler instance
|
|
28
|
-
*
|
|
29
|
-
* @param options Configuration options
|
|
30
|
-
*/
|
|
31
|
-
constructor(options) {
|
|
32
|
-
this.webhookSecret = options.webhookSecret;
|
|
33
|
-
this.logger = options.logger || (0, logger_1.createLogger)({
|
|
34
|
-
context: {
|
|
35
|
-
service: 'ContentfulWebhookHandler',
|
|
36
|
-
version: '1.0.0',
|
|
37
|
-
environment: process.env.NODE_ENV || 'development'
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
this.enableTimestampValidation = options.enableTimestampValidation ?? false;
|
|
41
|
-
this.maxTimestampDeltaSeconds = options.maxTimestampDeltaSeconds ?? 300; // 5 minutes default
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Validates a webhook signature using HMAC-SHA256
|
|
45
|
-
*
|
|
46
|
-
* @param body Raw webhook body as string
|
|
47
|
-
* @param signature Signature from x-contentful-signature header
|
|
48
|
-
* @returns True if signature is valid
|
|
49
|
-
*/
|
|
50
|
-
validateSignature(body, signature) {
|
|
51
|
-
try {
|
|
52
|
-
const hmac = crypto_1.default.createHmac('sha256', this.webhookSecret);
|
|
53
|
-
hmac.update(body);
|
|
54
|
-
const computedSignature = hmac.digest('base64');
|
|
55
|
-
// Use timing-safe comparison to prevent timing attacks
|
|
56
|
-
const signatureBuffer = Buffer.from(signature);
|
|
57
|
-
const computedBuffer = Buffer.from(computedSignature);
|
|
58
|
-
// Ensure buffers are the same length for timingSafeEqual
|
|
59
|
-
if (signatureBuffer.length !== computedBuffer.length) {
|
|
60
|
-
this.logger.warn('Invalid webhook signature (length mismatch)');
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
// TypeScript requires explicit cast for Buffer to ArrayBufferView
|
|
64
|
-
const isValid = crypto_1.default.timingSafeEqual(signatureBuffer, computedBuffer);
|
|
65
|
-
if (!isValid) {
|
|
66
|
-
this.logger.warn('Invalid webhook signature');
|
|
67
|
-
}
|
|
68
|
-
return isValid;
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
// Handle buffer length mismatch or other crypto errors
|
|
72
|
-
if (err instanceof Error) {
|
|
73
|
-
this.logger.error('Signature validation error', err);
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
this.logger.error('Signature validation error', new Error(String(err)));
|
|
77
|
-
}
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Validates webhook timestamp to prevent replay attacks
|
|
83
|
-
*
|
|
84
|
-
* @param payload Parsed webhook payload
|
|
85
|
-
* @returns True if timestamp is within acceptable range
|
|
86
|
-
*/
|
|
87
|
-
validateTimestamp(payload) {
|
|
88
|
-
if (!this.enableTimestampValidation) {
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
const updatedAt = payload.sys.updatedAt;
|
|
92
|
-
if (!updatedAt) {
|
|
93
|
-
this.logger.warn('No timestamp in webhook payload');
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
const payloadTime = new Date(updatedAt).getTime();
|
|
97
|
-
const currentTime = Date.now();
|
|
98
|
-
const deltaSeconds = Math.abs(currentTime - payloadTime) / 1000;
|
|
99
|
-
if (deltaSeconds > this.maxTimestampDeltaSeconds) {
|
|
100
|
-
this.logger.warn('Webhook timestamp outside acceptable range', {
|
|
101
|
-
deltaSeconds,
|
|
102
|
-
maxAllowed: this.maxTimestampDeltaSeconds
|
|
103
|
-
});
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Parses and validates a Contentful webhook
|
|
110
|
-
*
|
|
111
|
-
* @param body Raw webhook body as string
|
|
112
|
-
* @param headers Webhook headers
|
|
113
|
-
* @returns Validation result with parsed payload if valid
|
|
114
|
-
*/
|
|
115
|
-
parseWebhook(body, headers) {
|
|
116
|
-
try {
|
|
117
|
-
// Validate required headers
|
|
118
|
-
const signature = headers['x-contentful-signature'];
|
|
119
|
-
if (!signature) {
|
|
120
|
-
return {
|
|
121
|
-
valid: false,
|
|
122
|
-
error: 'Missing signature header (x-contentful-signature)'
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const topic = headers['x-contentful-topic'];
|
|
126
|
-
if (!topic) {
|
|
127
|
-
return {
|
|
128
|
-
valid: false,
|
|
129
|
-
error: 'Missing topic header (x-contentful-topic)'
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
// Validate signature
|
|
133
|
-
if (!this.validateSignature(body, signature)) {
|
|
134
|
-
return {
|
|
135
|
-
valid: false,
|
|
136
|
-
error: 'Invalid signature'
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
// Parse payload
|
|
140
|
-
const payload = JSON.parse(body);
|
|
141
|
-
// Validate timestamp for replay attack prevention
|
|
142
|
-
if (!this.validateTimestamp(payload)) {
|
|
143
|
-
return {
|
|
144
|
-
valid: false,
|
|
145
|
-
error: 'Invalid timestamp (possible replay attack)'
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
this.logger.info('Webhook validated successfully', {
|
|
149
|
-
topic,
|
|
150
|
-
id: payload.sys.id,
|
|
151
|
-
type: payload.sys.type
|
|
152
|
-
});
|
|
153
|
-
return {
|
|
154
|
-
valid: true,
|
|
155
|
-
payload,
|
|
156
|
-
topic
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
if (err instanceof Error) {
|
|
161
|
-
this.logger.error('Failed to parse webhook', err);
|
|
162
|
-
return {
|
|
163
|
-
valid: false,
|
|
164
|
-
error: err.message
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
else {
|
|
168
|
-
const errorMessage = String(err);
|
|
169
|
-
this.logger.error('Failed to parse webhook', new Error(errorMessage));
|
|
170
|
-
return {
|
|
171
|
-
valid: false,
|
|
172
|
-
error: errorMessage
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Extracts resource type and action from a topic string
|
|
179
|
-
*
|
|
180
|
-
* @param topic Contentful webhook topic (e.g., "Entry.publish")
|
|
181
|
-
* @returns Object with resource type and action
|
|
182
|
-
*/
|
|
183
|
-
getEventType(topic) {
|
|
184
|
-
const parts = topic.split('.');
|
|
185
|
-
if (parts.length !== 2) {
|
|
186
|
-
throw new Error(`Invalid topic format: ${topic}`);
|
|
187
|
-
}
|
|
188
|
-
const [resource, action] = parts;
|
|
189
|
-
// Validate resource type
|
|
190
|
-
if (!['Entry', 'Asset', 'ContentType'].includes(resource)) {
|
|
191
|
-
throw new Error(`Invalid resource type: ${resource}`);
|
|
192
|
-
}
|
|
193
|
-
// Validate action type
|
|
194
|
-
const validActions = ['create', 'save', 'auto_save', 'publish', 'unpublish', 'delete', 'archive', 'unarchive'];
|
|
195
|
-
if (!validActions.includes(action)) {
|
|
196
|
-
throw new Error(`Invalid action type: ${action}`);
|
|
197
|
-
}
|
|
198
|
-
return {
|
|
199
|
-
resource: resource,
|
|
200
|
-
action: action
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Updates the webhook secret
|
|
205
|
-
*
|
|
206
|
-
* @param newSecret New webhook secret
|
|
207
|
-
*/
|
|
208
|
-
updateSecret(newSecret) {
|
|
209
|
-
this.webhookSecret = newSecret;
|
|
210
|
-
this.logger.info('Webhook secret updated');
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
exports.ContentfulWebhookHandler = ContentfulWebhookHandler;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,576 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/*
|
|
3
|
-
Copyright (c) 2025 Bernier LLC
|
|
4
|
-
|
|
5
|
-
This file is licensed to the client under a limited-use license.
|
|
6
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
7
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
-
*/
|
|
9
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
-
};
|
|
12
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
const crypto_1 = __importDefault(require("crypto"));
|
|
14
|
-
const ContentfulWebhookHandler_1 = require("../ContentfulWebhookHandler");
|
|
15
|
-
const logger_1 = require("@bernierllc/logger");
|
|
16
|
-
describe('ContentfulWebhookHandler', () => {
|
|
17
|
-
const TEST_SECRET = 'test-webhook-secret';
|
|
18
|
-
let handler;
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
handler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
21
|
-
webhookSecret: TEST_SECRET
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
describe('constructor', () => {
|
|
25
|
-
it('should create handler with required options', () => {
|
|
26
|
-
expect(handler).toBeInstanceOf(ContentfulWebhookHandler_1.ContentfulWebhookHandler);
|
|
27
|
-
});
|
|
28
|
-
it('should accept custom logger', () => {
|
|
29
|
-
const customLogger = new logger_1.Logger({
|
|
30
|
-
level: logger_1.LogLevel.INFO,
|
|
31
|
-
transports: [new logger_1.ConsoleTransport()],
|
|
32
|
-
context: {
|
|
33
|
-
service: 'CustomContext',
|
|
34
|
-
version: '1.0.0',
|
|
35
|
-
environment: 'test'
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
const customHandler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
39
|
-
webhookSecret: TEST_SECRET,
|
|
40
|
-
logger: customLogger
|
|
41
|
-
});
|
|
42
|
-
expect(customHandler).toBeInstanceOf(ContentfulWebhookHandler_1.ContentfulWebhookHandler);
|
|
43
|
-
});
|
|
44
|
-
it('should accept timestamp validation options', () => {
|
|
45
|
-
const timestampHandler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
46
|
-
webhookSecret: TEST_SECRET,
|
|
47
|
-
enableTimestampValidation: true,
|
|
48
|
-
maxTimestampDeltaSeconds: 600
|
|
49
|
-
});
|
|
50
|
-
expect(timestampHandler).toBeInstanceOf(ContentfulWebhookHandler_1.ContentfulWebhookHandler);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
describe('validateSignature', () => {
|
|
54
|
-
it('should validate correct signature', () => {
|
|
55
|
-
const body = '{"test":"data"}';
|
|
56
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
57
|
-
hmac.update(body);
|
|
58
|
-
const signature = hmac.digest('base64');
|
|
59
|
-
const isValid = handler.validateSignature(body, signature);
|
|
60
|
-
expect(isValid).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
it('should reject invalid signature', () => {
|
|
63
|
-
const body = '{"test":"data"}';
|
|
64
|
-
const invalidSignature = 'invalid-signature-base64';
|
|
65
|
-
const isValid = handler.validateSignature(body, invalidSignature);
|
|
66
|
-
expect(isValid).toBe(false);
|
|
67
|
-
});
|
|
68
|
-
it('should reject tampered body', () => {
|
|
69
|
-
const originalBody = '{"test":"data"}';
|
|
70
|
-
const tamperedBody = '{"test":"tampered"}';
|
|
71
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
72
|
-
hmac.update(originalBody);
|
|
73
|
-
const signature = hmac.digest('base64');
|
|
74
|
-
const isValid = handler.validateSignature(tamperedBody, signature);
|
|
75
|
-
expect(isValid).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
it('should reject signature with different secret', () => {
|
|
78
|
-
const body = '{"test":"data"}';
|
|
79
|
-
const hmac = crypto_1.default.createHmac('sha256', 'different-secret');
|
|
80
|
-
hmac.update(body);
|
|
81
|
-
const signature = hmac.digest('base64');
|
|
82
|
-
const isValid = handler.validateSignature(body, signature);
|
|
83
|
-
expect(isValid).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
it('should handle empty body', () => {
|
|
86
|
-
const body = '';
|
|
87
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
88
|
-
hmac.update(body);
|
|
89
|
-
const signature = hmac.digest('base64');
|
|
90
|
-
const isValid = handler.validateSignature(body, signature);
|
|
91
|
-
expect(isValid).toBe(true);
|
|
92
|
-
});
|
|
93
|
-
it('should return false for buffer length mismatch', () => {
|
|
94
|
-
const body = '{"test":"data"}';
|
|
95
|
-
const shortSignature = 'short';
|
|
96
|
-
const isValid = handler.validateSignature(body, shortSignature);
|
|
97
|
-
expect(isValid).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
it('should handle crypto errors gracefully', () => {
|
|
100
|
-
const body = '{"test":"data"}';
|
|
101
|
-
// Intentionally malformed base64 that will cause issues
|
|
102
|
-
const badSignature = '!!!invalid-base64!!!';
|
|
103
|
-
const isValid = handler.validateSignature(body, badSignature);
|
|
104
|
-
expect(isValid).toBe(false);
|
|
105
|
-
});
|
|
106
|
-
it('should handle non-Error exceptions in signature validation', () => {
|
|
107
|
-
const testHandler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
108
|
-
webhookSecret: TEST_SECRET
|
|
109
|
-
});
|
|
110
|
-
const body = '{"test":"data"}';
|
|
111
|
-
// Mock crypto.createHmac to throw a non-Error
|
|
112
|
-
const originalCreateHmac = crypto_1.default.createHmac;
|
|
113
|
-
crypto_1.default.createHmac = jest.fn().mockImplementationOnce(() => {
|
|
114
|
-
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
|
115
|
-
throw 'crypto error';
|
|
116
|
-
});
|
|
117
|
-
try {
|
|
118
|
-
const isValid = testHandler.validateSignature(body, 'signature');
|
|
119
|
-
expect(isValid).toBe(false);
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
// Restore original
|
|
123
|
-
crypto_1.default.createHmac = originalCreateHmac;
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
describe('parseWebhook', () => {
|
|
128
|
-
const createValidPayload = () => ({
|
|
129
|
-
sys: {
|
|
130
|
-
type: 'Entry',
|
|
131
|
-
id: 'test-entry-123',
|
|
132
|
-
space: {
|
|
133
|
-
sys: {
|
|
134
|
-
type: 'Link',
|
|
135
|
-
linkType: 'Space',
|
|
136
|
-
id: 'space-123'
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
environment: {
|
|
140
|
-
sys: {
|
|
141
|
-
type: 'Link',
|
|
142
|
-
linkType: 'Environment',
|
|
143
|
-
id: 'master'
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
contentType: {
|
|
147
|
-
sys: {
|
|
148
|
-
type: 'Link',
|
|
149
|
-
linkType: 'ContentType',
|
|
150
|
-
id: 'blogPost'
|
|
151
|
-
}
|
|
152
|
-
},
|
|
153
|
-
revision: 1,
|
|
154
|
-
createdAt: new Date().toISOString(),
|
|
155
|
-
updatedAt: new Date().toISOString()
|
|
156
|
-
},
|
|
157
|
-
fields: {
|
|
158
|
-
title: { 'en-US': 'Test Title' },
|
|
159
|
-
content: { 'en-US': 'Test content' }
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
const signBody = (body) => {
|
|
163
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
164
|
-
hmac.update(body);
|
|
165
|
-
return hmac.digest('base64');
|
|
166
|
-
};
|
|
167
|
-
it('should parse valid webhook', () => {
|
|
168
|
-
const payload = createValidPayload();
|
|
169
|
-
const body = JSON.stringify(payload);
|
|
170
|
-
const signature = signBody(body);
|
|
171
|
-
const headers = {
|
|
172
|
-
'x-contentful-signature': signature,
|
|
173
|
-
'x-contentful-topic': 'Entry.publish'
|
|
174
|
-
};
|
|
175
|
-
const result = handler.parseWebhook(body, headers);
|
|
176
|
-
expect(result.valid).toBe(true);
|
|
177
|
-
expect(result.error).toBeUndefined();
|
|
178
|
-
expect(result.payload).toEqual(payload);
|
|
179
|
-
expect(result.topic).toBe('Entry.publish');
|
|
180
|
-
});
|
|
181
|
-
it('should reject webhook without signature header', () => {
|
|
182
|
-
const payload = createValidPayload();
|
|
183
|
-
const body = JSON.stringify(payload);
|
|
184
|
-
const headers = {
|
|
185
|
-
'x-contentful-topic': 'Entry.publish'
|
|
186
|
-
};
|
|
187
|
-
const result = handler.parseWebhook(body, headers);
|
|
188
|
-
expect(result.valid).toBe(false);
|
|
189
|
-
expect(result.error).toBe('Missing signature header (x-contentful-signature)');
|
|
190
|
-
expect(result.payload).toBeUndefined();
|
|
191
|
-
});
|
|
192
|
-
it('should reject webhook without topic header', () => {
|
|
193
|
-
const payload = createValidPayload();
|
|
194
|
-
const body = JSON.stringify(payload);
|
|
195
|
-
const signature = signBody(body);
|
|
196
|
-
const headers = {
|
|
197
|
-
'x-contentful-signature': signature
|
|
198
|
-
};
|
|
199
|
-
const result = handler.parseWebhook(body, headers);
|
|
200
|
-
expect(result.valid).toBe(false);
|
|
201
|
-
expect(result.error).toBe('Missing topic header (x-contentful-topic)');
|
|
202
|
-
expect(result.payload).toBeUndefined();
|
|
203
|
-
});
|
|
204
|
-
it('should reject webhook with invalid signature', () => {
|
|
205
|
-
const payload = createValidPayload();
|
|
206
|
-
const body = JSON.stringify(payload);
|
|
207
|
-
const headers = {
|
|
208
|
-
'x-contentful-signature': 'invalid-signature',
|
|
209
|
-
'x-contentful-topic': 'Entry.publish'
|
|
210
|
-
};
|
|
211
|
-
const result = handler.parseWebhook(body, headers);
|
|
212
|
-
expect(result.valid).toBe(false);
|
|
213
|
-
expect(result.error).toBe('Invalid signature');
|
|
214
|
-
expect(result.payload).toBeUndefined();
|
|
215
|
-
});
|
|
216
|
-
it('should reject webhook with invalid JSON', () => {
|
|
217
|
-
const body = 'invalid json {';
|
|
218
|
-
const signature = signBody(body);
|
|
219
|
-
const headers = {
|
|
220
|
-
'x-contentful-signature': signature,
|
|
221
|
-
'x-contentful-topic': 'Entry.publish'
|
|
222
|
-
};
|
|
223
|
-
const result = handler.parseWebhook(body, headers);
|
|
224
|
-
expect(result.valid).toBe(false);
|
|
225
|
-
expect(result.error).toContain('Unexpected');
|
|
226
|
-
expect(result.payload).toBeUndefined();
|
|
227
|
-
});
|
|
228
|
-
it('should handle non-Error exceptions in parsing', () => {
|
|
229
|
-
// Create a new handler for this test to avoid affecting other tests
|
|
230
|
-
const testHandler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
231
|
-
webhookSecret: TEST_SECRET
|
|
232
|
-
});
|
|
233
|
-
const body = '{"test":"data"}';
|
|
234
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
235
|
-
hmac.update(body);
|
|
236
|
-
const signature = hmac.digest('base64');
|
|
237
|
-
// Mock JSON.parse to throw a non-Error
|
|
238
|
-
const originalParse = global.JSON.parse;
|
|
239
|
-
global.JSON.parse = jest.fn().mockImplementationOnce(() => {
|
|
240
|
-
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
|
241
|
-
throw 'string error';
|
|
242
|
-
});
|
|
243
|
-
try {
|
|
244
|
-
const headers = {
|
|
245
|
-
'x-contentful-signature': signature,
|
|
246
|
-
'x-contentful-topic': 'Entry.publish'
|
|
247
|
-
};
|
|
248
|
-
const result = testHandler.parseWebhook(body, headers);
|
|
249
|
-
expect(result.valid).toBe(false);
|
|
250
|
-
expect(result.error).toBe('string error');
|
|
251
|
-
}
|
|
252
|
-
finally {
|
|
253
|
-
// Restore original
|
|
254
|
-
global.JSON.parse = originalParse;
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
it('should handle all webhook topics', () => {
|
|
258
|
-
const topics = [
|
|
259
|
-
'Entry.create',
|
|
260
|
-
'Entry.publish',
|
|
261
|
-
'Asset.delete',
|
|
262
|
-
'ContentType.save'
|
|
263
|
-
];
|
|
264
|
-
topics.forEach(topic => {
|
|
265
|
-
const payload = createValidPayload();
|
|
266
|
-
const body = JSON.stringify(payload);
|
|
267
|
-
const signature = signBody(body);
|
|
268
|
-
const headers = {
|
|
269
|
-
'x-contentful-signature': signature,
|
|
270
|
-
'x-contentful-topic': topic
|
|
271
|
-
};
|
|
272
|
-
const result = handler.parseWebhook(body, headers);
|
|
273
|
-
expect(result.valid).toBe(true);
|
|
274
|
-
expect(result.topic).toBe(topic);
|
|
275
|
-
});
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
describe('timestamp validation', () => {
|
|
279
|
-
const createPayloadWithTimestamp = (timestamp) => ({
|
|
280
|
-
sys: {
|
|
281
|
-
type: 'Entry',
|
|
282
|
-
id: 'test-entry-123',
|
|
283
|
-
space: {
|
|
284
|
-
sys: {
|
|
285
|
-
type: 'Link',
|
|
286
|
-
linkType: 'Space',
|
|
287
|
-
id: 'space-123'
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
environment: {
|
|
291
|
-
sys: {
|
|
292
|
-
type: 'Link',
|
|
293
|
-
linkType: 'Environment',
|
|
294
|
-
id: 'master'
|
|
295
|
-
}
|
|
296
|
-
},
|
|
297
|
-
contentType: {
|
|
298
|
-
sys: {
|
|
299
|
-
type: 'Link',
|
|
300
|
-
linkType: 'ContentType',
|
|
301
|
-
id: 'blogPost'
|
|
302
|
-
}
|
|
303
|
-
},
|
|
304
|
-
updatedAt: timestamp
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
const signBody = (body) => {
|
|
308
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
309
|
-
hmac.update(body);
|
|
310
|
-
return hmac.digest('base64');
|
|
311
|
-
};
|
|
312
|
-
it('should accept recent timestamp when validation enabled', () => {
|
|
313
|
-
const timestampHandler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
314
|
-
webhookSecret: TEST_SECRET,
|
|
315
|
-
enableTimestampValidation: true,
|
|
316
|
-
maxTimestampDeltaSeconds: 300
|
|
317
|
-
});
|
|
318
|
-
const payload = createPayloadWithTimestamp(new Date().toISOString());
|
|
319
|
-
const body = JSON.stringify(payload);
|
|
320
|
-
const signature = signBody(body);
|
|
321
|
-
const headers = {
|
|
322
|
-
'x-contentful-signature': signature,
|
|
323
|
-
'x-contentful-topic': 'Entry.publish'
|
|
324
|
-
};
|
|
325
|
-
const result = timestampHandler.parseWebhook(body, headers);
|
|
326
|
-
expect(result.valid).toBe(true);
|
|
327
|
-
});
|
|
328
|
-
it('should reject old timestamp when validation enabled', () => {
|
|
329
|
-
const timestampHandler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
330
|
-
webhookSecret: TEST_SECRET,
|
|
331
|
-
enableTimestampValidation: true,
|
|
332
|
-
maxTimestampDeltaSeconds: 300
|
|
333
|
-
});
|
|
334
|
-
// Create timestamp 10 minutes ago (600 seconds)
|
|
335
|
-
const oldDate = new Date(Date.now() - 600000);
|
|
336
|
-
const payload = createPayloadWithTimestamp(oldDate.toISOString());
|
|
337
|
-
const body = JSON.stringify(payload);
|
|
338
|
-
const signature = signBody(body);
|
|
339
|
-
const headers = {
|
|
340
|
-
'x-contentful-signature': signature,
|
|
341
|
-
'x-contentful-topic': 'Entry.publish'
|
|
342
|
-
};
|
|
343
|
-
const result = timestampHandler.parseWebhook(body, headers);
|
|
344
|
-
expect(result.valid).toBe(false);
|
|
345
|
-
expect(result.error).toBe('Invalid timestamp (possible replay attack)');
|
|
346
|
-
});
|
|
347
|
-
it('should accept any timestamp when validation disabled', () => {
|
|
348
|
-
// Default handler has timestamp validation disabled
|
|
349
|
-
const oldDate = new Date(Date.now() - 600000);
|
|
350
|
-
const payload = createPayloadWithTimestamp(oldDate.toISOString());
|
|
351
|
-
const body = JSON.stringify(payload);
|
|
352
|
-
const signature = signBody(body);
|
|
353
|
-
const headers = {
|
|
354
|
-
'x-contentful-signature': signature,
|
|
355
|
-
'x-contentful-topic': 'Entry.publish'
|
|
356
|
-
};
|
|
357
|
-
const result = handler.parseWebhook(body, headers);
|
|
358
|
-
expect(result.valid).toBe(true);
|
|
359
|
-
});
|
|
360
|
-
it('should reject payload without timestamp when validation enabled', () => {
|
|
361
|
-
const timestampHandler = new ContentfulWebhookHandler_1.ContentfulWebhookHandler({
|
|
362
|
-
webhookSecret: TEST_SECRET,
|
|
363
|
-
enableTimestampValidation: true,
|
|
364
|
-
maxTimestampDeltaSeconds: 300
|
|
365
|
-
});
|
|
366
|
-
const payload = {
|
|
367
|
-
sys: {
|
|
368
|
-
type: 'Entry',
|
|
369
|
-
id: 'test-entry-123',
|
|
370
|
-
space: {
|
|
371
|
-
sys: {
|
|
372
|
-
type: 'Link',
|
|
373
|
-
linkType: 'Space',
|
|
374
|
-
id: 'space-123'
|
|
375
|
-
}
|
|
376
|
-
},
|
|
377
|
-
environment: {
|
|
378
|
-
sys: {
|
|
379
|
-
type: 'Link',
|
|
380
|
-
linkType: 'Environment',
|
|
381
|
-
id: 'master'
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
const body = JSON.stringify(payload);
|
|
387
|
-
const signature = signBody(body);
|
|
388
|
-
const headers = {
|
|
389
|
-
'x-contentful-signature': signature,
|
|
390
|
-
'x-contentful-topic': 'Entry.publish'
|
|
391
|
-
};
|
|
392
|
-
const result = timestampHandler.parseWebhook(body, headers);
|
|
393
|
-
expect(result.valid).toBe(false);
|
|
394
|
-
expect(result.error).toBe('Invalid timestamp (possible replay attack)');
|
|
395
|
-
});
|
|
396
|
-
});
|
|
397
|
-
describe('getEventType', () => {
|
|
398
|
-
it('should parse Entry.publish topic', () => {
|
|
399
|
-
const result = handler.getEventType('Entry.publish');
|
|
400
|
-
expect(result.resource).toBe('Entry');
|
|
401
|
-
expect(result.action).toBe('publish');
|
|
402
|
-
});
|
|
403
|
-
it('should parse Asset.delete topic', () => {
|
|
404
|
-
const result = handler.getEventType('Asset.delete');
|
|
405
|
-
expect(result.resource).toBe('Asset');
|
|
406
|
-
expect(result.action).toBe('delete');
|
|
407
|
-
});
|
|
408
|
-
it('should parse ContentType.save topic', () => {
|
|
409
|
-
const result = handler.getEventType('ContentType.save');
|
|
410
|
-
expect(result.resource).toBe('ContentType');
|
|
411
|
-
expect(result.action).toBe('save');
|
|
412
|
-
});
|
|
413
|
-
it('should parse Entry.create topic', () => {
|
|
414
|
-
const result = handler.getEventType('Entry.create');
|
|
415
|
-
expect(result.resource).toBe('Entry');
|
|
416
|
-
expect(result.action).toBe('create');
|
|
417
|
-
});
|
|
418
|
-
it('should parse Entry.auto_save topic', () => {
|
|
419
|
-
const result = handler.getEventType('Entry.auto_save');
|
|
420
|
-
expect(result.resource).toBe('Entry');
|
|
421
|
-
expect(result.action).toBe('auto_save');
|
|
422
|
-
});
|
|
423
|
-
it('should parse Entry.archive topic', () => {
|
|
424
|
-
const result = handler.getEventType('Entry.archive');
|
|
425
|
-
expect(result.resource).toBe('Entry');
|
|
426
|
-
expect(result.action).toBe('archive');
|
|
427
|
-
});
|
|
428
|
-
it('should parse Entry.unarchive topic', () => {
|
|
429
|
-
const result = handler.getEventType('Entry.unarchive');
|
|
430
|
-
expect(result.resource).toBe('Entry');
|
|
431
|
-
expect(result.action).toBe('unarchive');
|
|
432
|
-
});
|
|
433
|
-
it('should parse Entry.unpublish topic', () => {
|
|
434
|
-
const result = handler.getEventType('Entry.unpublish');
|
|
435
|
-
expect(result.resource).toBe('Entry');
|
|
436
|
-
expect(result.action).toBe('unpublish');
|
|
437
|
-
});
|
|
438
|
-
it('should throw error for invalid topic format', () => {
|
|
439
|
-
expect(() => {
|
|
440
|
-
handler.getEventType('InvalidTopic');
|
|
441
|
-
}).toThrow('Invalid topic format: InvalidTopic');
|
|
442
|
-
});
|
|
443
|
-
it('should throw error for too many parts', () => {
|
|
444
|
-
expect(() => {
|
|
445
|
-
handler.getEventType('Entry.publish.extra');
|
|
446
|
-
}).toThrow('Invalid topic format: Entry.publish.extra');
|
|
447
|
-
});
|
|
448
|
-
it('should throw error for invalid resource', () => {
|
|
449
|
-
expect(() => {
|
|
450
|
-
handler.getEventType('InvalidResource.publish');
|
|
451
|
-
}).toThrow('Invalid resource type: InvalidResource');
|
|
452
|
-
});
|
|
453
|
-
it('should throw error for invalid action', () => {
|
|
454
|
-
expect(() => {
|
|
455
|
-
handler.getEventType('Entry.invalidAction');
|
|
456
|
-
}).toThrow('Invalid action type: invalidAction');
|
|
457
|
-
});
|
|
458
|
-
});
|
|
459
|
-
describe('updateSecret', () => {
|
|
460
|
-
it('should update webhook secret', () => {
|
|
461
|
-
const newSecret = 'new-test-secret';
|
|
462
|
-
handler.updateSecret(newSecret);
|
|
463
|
-
// Verify new secret works
|
|
464
|
-
const body = '{"test":"data"}';
|
|
465
|
-
const hmac = crypto_1.default.createHmac('sha256', newSecret);
|
|
466
|
-
hmac.update(body);
|
|
467
|
-
const signature = hmac.digest('base64');
|
|
468
|
-
const isValid = handler.validateSignature(body, signature);
|
|
469
|
-
expect(isValid).toBe(true);
|
|
470
|
-
});
|
|
471
|
-
it('should invalidate old secret after update', () => {
|
|
472
|
-
const body = '{"test":"data"}';
|
|
473
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
474
|
-
hmac.update(body);
|
|
475
|
-
const oldSignature = hmac.digest('base64');
|
|
476
|
-
// Update to new secret
|
|
477
|
-
handler.updateSecret('new-test-secret');
|
|
478
|
-
// Old signature should no longer work
|
|
479
|
-
const isValid = handler.validateSignature(body, oldSignature);
|
|
480
|
-
expect(isValid).toBe(false);
|
|
481
|
-
});
|
|
482
|
-
});
|
|
483
|
-
describe('integration scenarios', () => {
|
|
484
|
-
it('should handle complete Entry.publish webhook', () => {
|
|
485
|
-
const payload = {
|
|
486
|
-
sys: {
|
|
487
|
-
type: 'Entry',
|
|
488
|
-
id: 'blog-post-123',
|
|
489
|
-
space: {
|
|
490
|
-
sys: {
|
|
491
|
-
type: 'Link',
|
|
492
|
-
linkType: 'Space',
|
|
493
|
-
id: 'my-space'
|
|
494
|
-
}
|
|
495
|
-
},
|
|
496
|
-
environment: {
|
|
497
|
-
sys: {
|
|
498
|
-
type: 'Link',
|
|
499
|
-
linkType: 'Environment',
|
|
500
|
-
id: 'master'
|
|
501
|
-
}
|
|
502
|
-
},
|
|
503
|
-
contentType: {
|
|
504
|
-
sys: {
|
|
505
|
-
type: 'Link',
|
|
506
|
-
linkType: 'ContentType',
|
|
507
|
-
id: 'blogPost'
|
|
508
|
-
}
|
|
509
|
-
},
|
|
510
|
-
revision: 5,
|
|
511
|
-
createdAt: '2025-01-01T00:00:00Z',
|
|
512
|
-
updatedAt: new Date().toISOString()
|
|
513
|
-
},
|
|
514
|
-
fields: {
|
|
515
|
-
title: { 'en-US': 'My Blog Post' },
|
|
516
|
-
content: { 'en-US': 'Blog post content here' }
|
|
517
|
-
}
|
|
518
|
-
};
|
|
519
|
-
const body = JSON.stringify(payload);
|
|
520
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
521
|
-
hmac.update(body);
|
|
522
|
-
const signature = hmac.digest('base64');
|
|
523
|
-
const headers = {
|
|
524
|
-
'x-contentful-signature': signature,
|
|
525
|
-
'x-contentful-topic': 'Entry.publish',
|
|
526
|
-
'x-contentful-webhook-name': 'test-webhook',
|
|
527
|
-
'x-contentful-crn': 'crn:contentful:::content:spaces/my-space'
|
|
528
|
-
};
|
|
529
|
-
const result = handler.parseWebhook(body, headers);
|
|
530
|
-
expect(result.valid).toBe(true);
|
|
531
|
-
expect(result.payload?.sys.id).toBe('blog-post-123');
|
|
532
|
-
expect(result.topic).toBe('Entry.publish');
|
|
533
|
-
if (result.topic) {
|
|
534
|
-
const eventType = handler.getEventType(result.topic);
|
|
535
|
-
expect(eventType.resource).toBe('Entry');
|
|
536
|
-
expect(eventType.action).toBe('publish');
|
|
537
|
-
}
|
|
538
|
-
});
|
|
539
|
-
it('should handle Asset.delete webhook', () => {
|
|
540
|
-
const payload = {
|
|
541
|
-
sys: {
|
|
542
|
-
type: 'Asset',
|
|
543
|
-
id: 'asset-456',
|
|
544
|
-
space: {
|
|
545
|
-
sys: {
|
|
546
|
-
type: 'Link',
|
|
547
|
-
linkType: 'Space',
|
|
548
|
-
id: 'my-space'
|
|
549
|
-
}
|
|
550
|
-
},
|
|
551
|
-
environment: {
|
|
552
|
-
sys: {
|
|
553
|
-
type: 'Link',
|
|
554
|
-
linkType: 'Environment',
|
|
555
|
-
id: 'master'
|
|
556
|
-
}
|
|
557
|
-
},
|
|
558
|
-
revision: 1,
|
|
559
|
-
updatedAt: new Date().toISOString()
|
|
560
|
-
}
|
|
561
|
-
};
|
|
562
|
-
const body = JSON.stringify(payload);
|
|
563
|
-
const hmac = crypto_1.default.createHmac('sha256', TEST_SECRET);
|
|
564
|
-
hmac.update(body);
|
|
565
|
-
const signature = hmac.digest('base64');
|
|
566
|
-
const headers = {
|
|
567
|
-
'x-contentful-signature': signature,
|
|
568
|
-
'x-contentful-topic': 'Asset.delete'
|
|
569
|
-
};
|
|
570
|
-
const result = handler.parseWebhook(body, headers);
|
|
571
|
-
expect(result.valid).toBe(true);
|
|
572
|
-
expect(result.payload?.sys.type).toBe('Asset');
|
|
573
|
-
expect(result.topic).toBe('Asset.delete');
|
|
574
|
-
});
|
|
575
|
-
});
|
|
576
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
/*
|
|
4
|
-
Copyright (c) 2025 Bernier LLC
|
|
5
|
-
*/
|
|
6
|
-
const errors_1 = require("../errors");
|
|
7
|
-
describe('ContentfulWebhookHandlerError', () => {
|
|
8
|
-
it('uses default code when none provided', () => {
|
|
9
|
-
const err = new errors_1.ContentfulWebhookHandlerError('boom');
|
|
10
|
-
expect(err.name).toBe('ContentfulWebhookHandlerError');
|
|
11
|
-
expect(err.code).toBe('CONTENTFUL_WEBHOOK_HANDLER_ERROR');
|
|
12
|
-
expect(err.message).toBe('boom');
|
|
13
|
-
expect(err.cause).toBeUndefined();
|
|
14
|
-
expect(err.context).toBeUndefined();
|
|
15
|
-
});
|
|
16
|
-
it('preserves cause and context', () => {
|
|
17
|
-
const cause = new Error('root');
|
|
18
|
-
const err = new errors_1.ContentfulWebhookHandlerError('wrap', { cause, code: 'OPERATION_FAILED', context: { foo: 'bar' } });
|
|
19
|
-
expect(err.cause).toBe(cause);
|
|
20
|
-
expect(err.code).toBe('OPERATION_FAILED');
|
|
21
|
-
expect(err.context).toEqual({ foo: 'bar' });
|
|
22
|
-
});
|
|
23
|
-
it('omits context when not provided', () => {
|
|
24
|
-
const err = new errors_1.ContentfulWebhookHandlerError('msg', { code: 'INVALID_INPUT' });
|
|
25
|
-
expect(err.code).toBe('INVALID_INPUT');
|
|
26
|
-
expect(err.context).toBeUndefined();
|
|
27
|
-
});
|
|
28
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/*
|
|
3
|
-
Copyright (c) 2025 Bernier LLC
|
|
4
|
-
|
|
5
|
-
This file is licensed to the client under a limited-use license.
|
|
6
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
7
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
-
*/
|
|
9
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
const index_1 = require("../index");
|
|
11
|
-
describe('index exports', () => {
|
|
12
|
-
it('should export ContentfulWebhookHandler class', () => {
|
|
13
|
-
expect(index_1.ContentfulWebhookHandler).toBeDefined();
|
|
14
|
-
expect(typeof index_1.ContentfulWebhookHandler).toBe('function');
|
|
15
|
-
});
|
|
16
|
-
it('should create a ContentfulWebhookHandler instance', () => {
|
|
17
|
-
const handler = new index_1.ContentfulWebhookHandler({
|
|
18
|
-
webhookSecret: 'test-secret'
|
|
19
|
-
});
|
|
20
|
-
expect(handler).toBeInstanceOf(index_1.ContentfulWebhookHandler);
|
|
21
|
-
});
|
|
22
|
-
it('should export type definitions', () => {
|
|
23
|
-
// Type check - these should compile
|
|
24
|
-
const payload = {
|
|
25
|
-
sys: {
|
|
26
|
-
type: 'Entry',
|
|
27
|
-
id: 'test',
|
|
28
|
-
space: {
|
|
29
|
-
sys: { type: 'Link', linkType: 'Space', id: 'space' }
|
|
30
|
-
},
|
|
31
|
-
environment: {
|
|
32
|
-
sys: { type: 'Link', linkType: 'Environment', id: 'master' }
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
const validationResult = {
|
|
37
|
-
valid: true,
|
|
38
|
-
payload,
|
|
39
|
-
topic: 'Entry.publish'
|
|
40
|
-
};
|
|
41
|
-
const eventType = {
|
|
42
|
-
resource: 'Entry',
|
|
43
|
-
action: 'publish'
|
|
44
|
-
};
|
|
45
|
-
const options = {
|
|
46
|
-
webhookSecret: 'test'
|
|
47
|
-
};
|
|
48
|
-
const topic = 'Entry.publish';
|
|
49
|
-
const headers = {
|
|
50
|
-
'x-contentful-signature': 'sig',
|
|
51
|
-
'x-contentful-topic': 'Entry.publish',
|
|
52
|
-
'x-contentful-webhook-name': 'test',
|
|
53
|
-
'x-contentful-crn': 'crn:test'
|
|
54
|
-
};
|
|
55
|
-
// If these compile, the types are exported correctly
|
|
56
|
-
expect(validationResult).toBeDefined();
|
|
57
|
-
expect(eventType).toBeDefined();
|
|
58
|
-
expect(options).toBeDefined();
|
|
59
|
-
expect(topic).toBeDefined();
|
|
60
|
-
expect(headers).toBeDefined();
|
|
61
|
-
});
|
|
62
|
-
});
|
package/dist/errors.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export type ContentfulWebhookHandlerErrorCode = 'CONTENTFUL_WEBHOOK_HANDLER_ERROR' | 'INVALID_INPUT' | 'OPERATION_FAILED';
|
|
2
|
-
export interface ContentfulWebhookHandlerErrorOptions {
|
|
3
|
-
cause?: Error;
|
|
4
|
-
code?: ContentfulWebhookHandlerErrorCode;
|
|
5
|
-
context?: Record<string, unknown>;
|
|
6
|
-
}
|
|
7
|
-
export declare class ContentfulWebhookHandlerError extends Error {
|
|
8
|
-
readonly code: ContentfulWebhookHandlerErrorCode;
|
|
9
|
-
readonly context?: Record<string, unknown>;
|
|
10
|
-
constructor(message: string, options?: ContentfulWebhookHandlerErrorOptions);
|
|
11
|
-
}
|
package/dist/errors.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/*
|
|
3
|
-
Copyright (c) 2025 Bernier LLC
|
|
4
|
-
|
|
5
|
-
This file is licensed to the client under a limited-use license.
|
|
6
|
-
The client may use and modify this code only within the scope of the project it was delivered for.
|
|
7
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
-
*/
|
|
9
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.ContentfulWebhookHandlerError = void 0;
|
|
11
|
-
class ContentfulWebhookHandlerError extends Error {
|
|
12
|
-
code;
|
|
13
|
-
context;
|
|
14
|
-
constructor(message, options) {
|
|
15
|
-
super(message, options?.cause ? { cause: options.cause } : undefined);
|
|
16
|
-
this.name = 'ContentfulWebhookHandlerError';
|
|
17
|
-
this.code = options?.code ?? 'CONTENTFUL_WEBHOOK_HANDLER_ERROR';
|
|
18
|
-
if (options?.context !== undefined) {
|
|
19
|
-
this.context = options.context;
|
|
20
|
-
}
|
|
21
|
-
Error.captureStackTrace?.(this, this.constructor);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
exports.ContentfulWebhookHandlerError = ContentfulWebhookHandlerError;
|
package/dist/index.d.ts
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
export { ContentfulWebhookHandler, WebhookValidationResult, EventType, ContentfulWebhookHandlerOptions } from './ContentfulWebhookHandler';
|
|
2
|
-
export { ContentfulWebhookHandlerError, ContentfulWebhookHandlerErrorCode, ContentfulWebhookHandlerErrorOptions, } from './errors';
|
|
3
|
-
export type { ContentfulWebhookPayload, ContentfulWebhookTopic, ContentfulWebhookHeaders } from '@bernierllc/contentful-types';
|
package/dist/index.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/*
|
|
3
|
-
Copyright (c) 2025 Bernier LLC
|
|
4
|
-
|
|
5
|
-
This file is licensed to the client under a limited-use license.
|
|
6
|
-
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
7
|
-
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
8
|
-
*/
|
|
9
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.ContentfulWebhookHandlerError = exports.ContentfulWebhookHandler = void 0;
|
|
11
|
-
var ContentfulWebhookHandler_1 = require("./ContentfulWebhookHandler");
|
|
12
|
-
Object.defineProperty(exports, "ContentfulWebhookHandler", { enumerable: true, get: function () { return ContentfulWebhookHandler_1.ContentfulWebhookHandler; } });
|
|
13
|
-
var errors_1 = require("./errors");
|
|
14
|
-
Object.defineProperty(exports, "ContentfulWebhookHandlerError", { enumerable: true, get: function () { return errors_1.ContentfulWebhookHandlerError; } });
|