From 93e5ccb0b72b5acbe8af5e321c3771fbd539ff11 Mon Sep 17 00:00:00 2001 From: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:56:43 +0200 Subject: [PATCH] Handle multipart/form-data webhooks --- .../webhooks/src/controller/app.controller.ts | 2 +- .../src/services/fax-event.service.ts | 4 + .../rest/v3/callbacks/callbacks-webhook.ts | 57 +++++- .../v3/webhooks/callbacks-webhook.test.ts | 193 ++++++++++++++++++ .../rest/v3/webhooks/webhooks-events.steps.ts | 71 +++++++ 5 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 packages/fax/tests/rest/v3/webhooks/callbacks-webhook.test.ts create mode 100644 packages/fax/tests/rest/v3/webhooks/webhooks-events.steps.ts diff --git a/examples/webhooks/src/controller/app.controller.ts b/examples/webhooks/src/controller/app.controller.ts index 51b11f11..8c84a449 100644 --- a/examples/webhooks/src/controller/app.controller.ts +++ b/examples/webhooks/src/controller/app.controller.ts @@ -68,7 +68,7 @@ export class AppController { try { // There is no request validation for the Fax API, so we can parse it and revive its content directly const event = faxCallbackWebhook.parseEvent(request.body); - // Once the request has been revived, delegate the event management to the SMS service + // Once the request has been revived, delegate the event management to the Fax service const contentType = request.headers['content-type']; this.faxEventService.handleEvent(event, contentType, file); res.status(200).send(); diff --git a/examples/webhooks/src/services/fax-event.service.ts b/examples/webhooks/src/services/fax-event.service.ts index 54160c91..81781c26 100644 --- a/examples/webhooks/src/services/fax-event.service.ts +++ b/examples/webhooks/src/services/fax-event.service.ts @@ -21,6 +21,8 @@ export class FaxEventService { console.log(`** multipart/form-data\n${event.event}: ${event.fax!.id} - ${event.eventTime}`); console.log('Saving file...'); const filePath = path.join('./fax-upload', file!.originalname); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, file!.buffer); console.log('File saved! ' + filePath); } @@ -29,6 +31,8 @@ export class FaxEventService { private saveBase64File(event: Fax.IncomingFaxEventJson | Fax.FaxCompletedEventJson, faxId: string) { console.log('Saving file...'); const filePath = path.join('./fax-upload', event.event + '-' + faxId + '.' + event.fileType!.toLowerCase()); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); const buffer = Buffer.from(event.file!, 'base64'); fs.writeFileSync(filePath, buffer); console.log('File saved! ' + filePath); diff --git a/packages/fax/src/rest/v3/callbacks/callbacks-webhook.ts b/packages/fax/src/rest/v3/callbacks/callbacks-webhook.ts index cf3a2825..af31bbf0 100644 --- a/packages/fax/src/rest/v3/callbacks/callbacks-webhook.ts +++ b/packages/fax/src/rest/v3/callbacks/callbacks-webhook.ts @@ -8,11 +8,19 @@ export class FaxCallbackWebhooks implements CallbackProcessor} - An object with field names as keys and field values as strings. + */ + private parseMultipartFormData(body: string): Record { + const result: Record = {}; + // The first line is the boundary delimiter (e.g. "--") + const boundaryMatch = body.match(/^(--[^\r\n]+)/); + if (!boundaryMatch) { + throw new Error('Unable to detect multipart boundary from the body'); + } + const boundary = boundaryMatch[1]; + // Split the body by the boundary; the first part is empty and the last ends with "--" + const parts = body.split(boundary).filter((part) => part && part.trim() !== '--' && part.trim() !== ''); + for (const part of parts) { + const nameMatch = part.match(/Content-Disposition:\s*form-data;\s*name="([^"]+)"/i); + if (!nameMatch) {continue;} + const fieldName = nameMatch[1]; + // The value comes after the first blank line (header/body separator) + const headerBodySeparator = part.indexOf('\r\n\r\n') !== -1 ? '\r\n\r\n' : '\n\n'; + const separatorIndex = part.indexOf(headerBodySeparator); + if (separatorIndex === -1) {continue;} + let value = part.substring(separatorIndex + headerBodySeparator.length); + // Remove trailing boundary markers: a closing boundary (--boundary--) may remain + // if its dash count differs slightly from the opening boundary + const trailingBoundaryIndex = value.search(/\r?\n--[-]+[^\r\n]*--\s*$/); + if (trailingBoundaryIndex !== -1) { + value = value.substring(0, trailingBoundaryIndex); + } + value = value.replace(/\r?\n$/, ''); // Trim trailing newline + result[fieldName] = value; + } + return result; + } + + private reviveFax(faxInput: string | Fax): Fax { + const fax: Fax = typeof faxInput === 'string' ? JSON.parse(faxInput) : faxInput; if (fax.createTime) { fax.createTime = new Date(fax.createTime); } diff --git a/packages/fax/tests/rest/v3/webhooks/callbacks-webhook.test.ts b/packages/fax/tests/rest/v3/webhooks/callbacks-webhook.test.ts new file mode 100644 index 00000000..7e34876b --- /dev/null +++ b/packages/fax/tests/rest/v3/webhooks/callbacks-webhook.test.ts @@ -0,0 +1,193 @@ +import { Fax, FaxCallbackWebhooks } from '../../../../src'; + +describe('Fax Callback Webhook', () => { + let callbackWebhooks: FaxCallbackWebhooks; + + const EVENT_TIME_AS_STRING = '2026-04-09T21:33:46Z'; + const EVENT_TIME_AS_DATE = new Date(EVENT_TIME_AS_STRING); + const CREATE_TIME_AS_STRING = '2026-04-08T14:23:48Z'; + const CREATE_TIME_AS_DATE = new Date(CREATE_TIME_AS_STRING); + const COMPLETED_TIME_AS_STRING = '2026-04-08T14:25:00Z'; + const COMPLETED_TIME_AS_DATE = new Date(COMPLETED_TIME_AS_STRING); + + const FAX_OBJECT = { + id: '01KNPQW6SGZKAZEX8ZX7MCN559', + direction: 'INBOUND', + from: '+16179216545', + to: '+17818510006', + numberOfPages: 1, + status: 'COMPLETED', + createTime: CREATE_TIME_AS_STRING, + completedTime: COMPLETED_TIME_AS_STRING, + projectId: '37b62a7b-0177-429a-bb0b-e10f848de0b8', + serviceId: '01HQK89SYWBCJK1DMYD3QAW2DW', + }; + + beforeEach(() => { + callbackWebhooks = new FaxCallbackWebhooks(); + }); + + it('should parse an INCOMING_FAX event from an object payload', () => { + const payload = { + event: 'INCOMING_FAX', + eventTime: EVENT_TIME_AS_STRING, + fax: { ...FAX_OBJECT }, + }; + const result = callbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent; + expect(result.event).toBe('INCOMING_FAX'); + expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE); + expect(result.fax).toBeDefined(); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE); + }); + + it('should parse a FAX_COMPLETED event from an object payload', () => { + const payload = { + event: 'FAX_COMPLETED', + eventTime: EVENT_TIME_AS_STRING, + fax: { ...FAX_OBJECT }, + }; + const result = callbackWebhooks.parseEvent(payload) as Fax.FaxCompletedEvent; + expect(result.event).toBe('FAX_COMPLETED'); + expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE); + expect(result.fax).toBeDefined(); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE); + }); + + it('should parse an INCOMING_FAX event from a JSON string', () => { + const payload = JSON.stringify({ + event: 'INCOMING_FAX', + eventTime: EVENT_TIME_AS_STRING, + fax: { ...FAX_OBJECT }, + }); + const result = callbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent; + expect(result.event).toBe('INCOMING_FAX'); + expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE); + }); + + it('should parse a FAX_COMPLETED event from a JSON string', () => { + const payload = JSON.stringify({ + event: 'FAX_COMPLETED', + eventTime: EVENT_TIME_AS_STRING, + fax: { ...FAX_OBJECT }, + }); + const result = callbackWebhooks.parseEvent(payload) as Fax.FaxCompletedEvent; + expect(result.event).toBe('FAX_COMPLETED'); + expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE); + }); + + it('should parse an INCOMING_FAX event from a multipart/form-data body with CRLF', () => { + const boundary = '----Boundary123'; + const faxJson = JSON.stringify(FAX_OBJECT); + const multipartBody = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="event"', + '', + 'INCOMING_FAX', + `--${boundary}`, + 'Content-Disposition: form-data; name="eventTime"', + '', + EVENT_TIME_AS_STRING, + `--${boundary}`, + 'Content-Disposition: form-data; name="fax"', + 'Content-Type: application/json', + '', + faxJson, + `--${boundary}--`, + ].join('\r\n'); + + const result = callbackWebhooks.parseEvent(multipartBody) as Fax.IncomingFaxEvent; + expect(result.event).toBe('INCOMING_FAX'); + expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE); + expect(result.fax).toBeDefined(); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE); + expect(result.fax!.completedTime).toStrictEqual(COMPLETED_TIME_AS_DATE); + }); + + it('should parse a FAX_COMPLETED event from a multipart/form-data body with CRLF', () => { + const boundary = '----Boundary123'; + const faxJson = JSON.stringify(FAX_OBJECT); + const multipartBody = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="event"', + '', + 'FAX_COMPLETED', + `--${boundary}`, + 'Content-Disposition: form-data; name="eventTime"', + '', + EVENT_TIME_AS_STRING, + `--${boundary}`, + 'Content-Disposition: form-data; name="fax"', + 'Content-Type: application/json', + '', + faxJson, + `--${boundary}--`, + ].join('\r\n'); + + const result = callbackWebhooks.parseEvent(multipartBody) as Fax.FaxCompletedEvent; + expect(result.event).toBe('FAX_COMPLETED'); + expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE); + expect(result.fax).toBeDefined(); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + expect(result.fax!.createTime).toStrictEqual(CREATE_TIME_AS_DATE); + expect(result.fax!.completedTime).toStrictEqual(COMPLETED_TIME_AS_DATE); + }); + + it('should parse an event without eventTime', () => { + const payload = { + event: 'INCOMING_FAX', + fax: { ...FAX_OBJECT }, + }; + const result = callbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent; + expect(result.event).toBe('INCOMING_FAX'); + expect(result.eventTime).toBeUndefined(); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + }); + + it('should parse an event using the static method', () => { + const payload = { + event: 'INCOMING_FAX', + eventTime: EVENT_TIME_AS_STRING, + fax: { ...FAX_OBJECT }, + }; + const result = FaxCallbackWebhooks.parseEvent(payload) as Fax.IncomingFaxEvent; + expect(result.event).toBe('INCOMING_FAX'); + expect(result.eventTime).toStrictEqual(EVENT_TIME_AS_DATE); + expect(result.fax!.id).toBe('01KNPQW6SGZKAZEX8ZX7MCN559'); + }); + + it('should always return true for authentication validation', () => { + const result = callbackWebhooks.validateAuthenticationHeader({}, '', '/fax', 'POST'); + expect(result).toBeTruthy(); + }); + + it('should throw an error when the event type is unknown', () => { + const payload = { + event: 'UNKNOWN_EVENT', + eventTime: EVENT_TIME_AS_STRING, + }; + expect(() => callbackWebhooks.parseEvent(payload)).toThrow('Unknown Fax event: UNKNOWN_EVENT'); + }); + + it('should throw an error when the event property is missing', () => { + const payload = { + eventTime: EVENT_TIME_AS_STRING, + }; + expect(() => callbackWebhooks.parseEvent(payload)).toThrow('Unknown Fax event'); + }); + + it('should throw an error when parsing an invalid JSON string', () => { + expect(() => callbackWebhooks.parseEvent('not valid json')).toThrow(); + }); + + it('should throw an error when parsing a multipart body with no detectable boundary', () => { + // A string starting with "--" but with no actual content + expect(() => callbackWebhooks.parseEvent('--\r\n')).toThrow('Unable to detect multipart boundary from the body'); + }); +}); diff --git a/packages/fax/tests/rest/v3/webhooks/webhooks-events.steps.ts b/packages/fax/tests/rest/v3/webhooks/webhooks-events.steps.ts new file mode 100644 index 00000000..f932df77 --- /dev/null +++ b/packages/fax/tests/rest/v3/webhooks/webhooks-events.steps.ts @@ -0,0 +1,71 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { FaxCallbackWebhooks, Fax } from '../../../../src'; +import assert from 'assert'; + +let faxCallbackWebhook: FaxCallbackWebhooks; +let rawEvent: string; +let event: Fax.FaxWebhookEventParsed; + +Given('the Fax Webhooks handler is available', function () { + faxCallbackWebhook = new FaxCallbackWebhooks(); +}); + +When('I send a request to trigger the INCOMING_FAX event with the "{}" content type', async (contentType) => { + const response = await fetch(`http://localhost:3012/webhooks/fax/incoming-fax/${contentType}`); + rawEvent = await response.text(); + event = faxCallbackWebhook.parseEvent(rawEvent); +}); + +Then('the event describes an INCOMING_FAX event with the application-json content type', () => { + assert.equal(event.event, 'INCOMING_FAX'); + assert.deepEqual(event.eventTime, new Date('2024-06-06T14:42:35.000Z')); + const fax = event.fax!; + assert.equal(fax.id, '01W4FFL35P4NC4K35CR3P35TOP1'); + assert.equal(fax.direction, 'INBOUND'); + assert.equal(fax.from, '+16179216545'); + assert.equal(fax.to, '+17818510000'); + assert.equal(fax.numberOfPages, 1); + assert.equal(fax.status, 'COMPLETED'); + assert.equal(fax.headerTimeZone, 'UTC'); + assert.equal(fax.retryDelaySeconds, 60); + assert.equal(fax.resolution, 'FINE'); + assert.equal(fax.callbackUrl, 'https://my.callback.url/fax'); + assert.equal(fax.callbackUrlContentType, 'application/json'); + assert.equal(fax.projectId, '123coffee-dada-beef-cafe-baadc0de5678'); + assert.equal(fax.serviceId, '01W4FFL35P4NC4K35FAXSERVICE'); + assert.equal(fax.price?.amount, '0.045'); + assert.equal(fax.price?.currencyCode, 'USD'); + assert.equal(fax.maxRetries, 0); + assert.deepEqual(fax.createTime, new Date('2026-06-06T14:42:22Z')); + assert.deepEqual(fax.completedTime, new Date('2026-06-06T14:42:22Z')); + assert.equal(fax.headerText, ''); + assert.equal(fax.headerPageNumbers, true); + assert.equal(fax.hasFile, true); +}); + +Then('the event describes an INCOMING_FAX event with the multipart-formdata content type', () => { + assert.equal(event.event, 'INCOMING_FAX'); + assert.deepEqual(event.eventTime, new Date('2024-06-06T14:42:46.000Z')); + const fax = event.fax!; + assert.equal(fax.id, '01W4FFL35P4NC4K35CR3P35TOP2'); + assert.equal(fax.from, '+16179216545'); + assert.equal(fax.to, '+17818510000'); + assert.equal(fax.numberOfPages, 0); + assert.equal(fax.status, 'FAILURE'); + assert.equal(fax.headerTimeZone, 'UTC'); + assert.equal(fax.retryDelaySeconds, 60); + assert.equal(fax.resolution, 'FINE'); + assert.equal(fax.callbackUrl, 'https://my.callback.url/fax'); + assert.equal(fax.callbackUrlContentType, 'multipart/form-data'); + assert.equal(fax.errorType, 'FAX_ERROR'); + assert.equal(fax.errorMessage, 'No fax tone detected'); + assert.equal(fax.errorCode, 132); + assert.equal(fax.projectId, '123coffee-dada-beef-cafe-baadc0de5678'); + assert.equal(fax.serviceId, '01W4FFL35P4NC4K35FAXSERVICE'); + assert.equal(fax.maxRetries, 0); + assert.deepEqual(fax.createTime, new Date('2026-06-06T14:42:48Z')); + assert.deepEqual(fax.completedTime, new Date('2026-06-06T14:42:48Z')); + assert.equal(fax.headerText, ''); + assert.equal(fax.headerPageNumbers, true); + assert.equal(fax.hasFile, false); +});