Skip to content

Commit 872f49b

Browse files
committed
refactor: simplybook webhook signature handling
1 parent d4e2d33 commit 872f49b

7 files changed

+104
-75
lines changed

src/main.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ExceptionsFilter } from './utils/exceptions.filter';
99

1010
async function bootstrap() {
1111
const PORT = process.env.PORT || 35001;
12-
const app = await NestFactory.create(AppModule, { cors: true });
12+
const app = await NestFactory.create(AppModule, { cors: true, rawBody: true });
1313

1414
app.setGlobalPrefix('api');
1515

src/utils/constants.ts

+5
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ export const slackWebhookUrl = getEnv(process.env.SLACK_WEBHOOK_URL, 'SLACK_WEBH
111111

112112
export const storyblokToken = getEnv(process.env.STORYBLOK_PUBLIC_TOKEN, 'STORYBLOK_PUBLIC_TOKEN');
113113

114+
export const storyblokWebhookSecret = getEnv(
115+
process.env.STORYBLOK_WEBHOOK_SECRET,
116+
'STORYBLOK_WEBHOOK_SECRET',
117+
);
118+
114119
export const simplybookCredentials = getEnv(
115120
process.env.SIMPLYBOOK_CREDENTIALS,
116121
'SIMPLYBOOK_CREDENTIALS',

src/webhooks/webhooks.controller.spec.ts

+42-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,38 @@
11
import { createMock } from '@golevelup/ts-jest';
22
import { HttpException, HttpStatus } from '@nestjs/common';
33
import { Test, TestingModule } from '@nestjs/testing';
4-
import { mockSimplybookBodyBase, mockTherapySessionEntity } from 'test/utils/mockData';
4+
import { createHmac } from 'crypto';
5+
import { storyblokWebhookSecret } from 'src/utils/constants';
6+
import {
7+
mockSessionEntity,
8+
mockSimplybookBodyBase,
9+
mockStoryDto,
10+
mockTherapySessionEntity,
11+
} from 'test/utils/mockData';
512
import { mockWebhooksServiceMethods } from 'test/utils/mockedServices';
613
import { WebhooksController } from './webhooks.controller';
714
import { WebhooksService } from './webhooks.service';
815

16+
const getWebhookSignature = (body) => {
17+
return createHmac('sha1', storyblokWebhookSecret)
18+
.update('' + body)
19+
.digest('hex');
20+
};
21+
22+
const generateMockHeaders = (body) => {
23+
return {
24+
'webhook-signature': getWebhookSignature(body),
25+
};
26+
};
27+
28+
const createRequestObject = (body) => {
29+
return {
30+
rawBody: JSON.stringify(body),
31+
setEncoding: () => {},
32+
encoding: 'utf8',
33+
};
34+
};
35+
936
describe('AppController', () => {
1037
let webhooksController: WebhooksController;
1138
const mockWebhooksService = createMock<WebhooksService>(mockWebhooksServiceMethods);
@@ -35,5 +62,19 @@ describe('AppController', () => {
3562
webhooksController.updatePartnerAccessTherapy(mockSimplybookBodyBase),
3663
).rejects.toThrow('Therapy session not found');
3764
});
65+
describe('updateStory', () => {
66+
it('updateStory should pass if service returns true', async () => {
67+
jest.spyOn(mockWebhooksService, 'updateStory').mockImplementationOnce(async () => {
68+
return mockSessionEntity;
69+
});
70+
await expect(
71+
webhooksController.updateStory(
72+
createRequestObject(mockStoryDto),
73+
mockStoryDto,
74+
generateMockHeaders(mockStoryDto),
75+
),
76+
).resolves.toBe(mockSessionEntity);
77+
});
78+
});
3879
});
3980
});

src/webhooks/webhooks.controller.ts

+30-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
import { Body, Controller, Headers, Logger, Post, Request, UseGuards } from '@nestjs/common';
1+
import {
2+
Body,
3+
Controller,
4+
Headers,
5+
HttpException,
6+
HttpStatus,
7+
Logger,
8+
Post,
9+
Request,
10+
UseGuards,
11+
} from '@nestjs/common';
212
import { ApiBody, ApiTags } from '@nestjs/swagger';
13+
import { createHmac } from 'crypto';
314
import { EventLogEntity } from 'src/entities/event-log.entity';
415
import { TherapySessionEntity } from 'src/entities/therapy-session.entity';
16+
import { storyblokWebhookSecret } from 'src/utils/constants';
517
import { ControllerDecorator } from 'src/utils/controller.decorator';
618
import { WebhookCreateEventLogDto } from 'src/webhooks/dto/webhook-create-event-log.dto';
719
import { ZapierSimplybookBodyDto } from '../partner-access/dtos/zapier-body.dto';
@@ -36,6 +48,22 @@ export class WebhooksController {
3648
@ApiBody({ type: StoryDto })
3749
async updateStory(@Request() req, @Body() data: StoryDto, @Headers() headers) {
3850
const signature: string | undefined = headers['webhook-signature'];
39-
return this.webhooksService.updateStory(req, data, signature);
51+
// Verify storyblok signature uses storyblok webhook secret - see https://www.storyblok.com/docs/guide/in-depth/webhooks#securing-a-webhook
52+
if (!signature) {
53+
const error = `Storyblok webhook error - no signature provided`;
54+
this.logger.error(error);
55+
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
56+
}
57+
58+
req.rawBody = '' + data;
59+
req.setEncoding('utf8');
60+
61+
const bodyHmac = createHmac('sha1', storyblokWebhookSecret).update(req.rawBody).digest('hex');
62+
if (bodyHmac !== signature) {
63+
const error = `Storyblok webhook error - signature mismatch`;
64+
this.logger.error(error);
65+
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
66+
}
67+
return this.webhooksService.updateStory(data);
4068
}
4169
}

src/webhooks/webhooks.service.spec.ts

+7-49
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createMock } from '@golevelup/ts-jest';
22
import { Test, TestingModule } from '@nestjs/testing';
33
import { getRepositoryToken } from '@nestjs/typeorm';
4-
import { createHmac } from 'crypto';
54
import { SlackMessageClient } from 'src/api/slack/slack-api';
65
import { CoursePartnerService } from 'src/course-partner/course-partner.service';
76
import { CoursePartnerEntity } from 'src/entities/course-partner.entity';
@@ -47,21 +46,6 @@ import { ILike, Repository } from 'typeorm';
4746
import { WebhookCreateEventLogDto } from './dto/webhook-create-event-log.dto';
4847
import { WebhooksService } from './webhooks.service';
4948

50-
const webhookSecret = process.env.STORYBLOK_WEBHOOK_SECRET;
51-
52-
const getWebhookSignature = (body) => {
53-
return createHmac('sha1', webhookSecret)
54-
.update('' + body)
55-
.digest('hex');
56-
};
57-
const createRequestObject = (body) => {
58-
return {
59-
rawBody: '' + body,
60-
setEncoding: () => {},
61-
encoding: 'utf8',
62-
};
63-
};
64-
6549
// Difficult to mock classes as well as node modules.
6650
// This seemed the best approach
6751
jest.mock('storyblok-js-client', () => {
@@ -202,9 +186,7 @@ describe('WebhooksService', () => {
202186
text: '',
203187
};
204188

205-
return expect(
206-
service.updateStory(createRequestObject(body), body, getWebhookSignature(body)),
207-
).rejects.toThrow('STORYBLOK STORY NOT FOUND');
189+
return expect(service.updateStory(body)).rejects.toThrow('STORYBLOK STORY NOT FOUND');
208190
});
209191

210192
it('when action is deleted, story should be set as deleted in database', async () => {
@@ -214,11 +196,7 @@ describe('WebhooksService', () => {
214196
text: '',
215197
};
216198

217-
const deletedStory = (await service.updateStory(
218-
createRequestObject(body),
219-
body,
220-
getWebhookSignature(body),
221-
)) as SessionEntity;
199+
const deletedStory = (await service.updateStory(body)) as SessionEntity;
222200

223201
expect(deletedStory.status).toBe(STORYBLOK_STORY_STATUS_ENUM.DELETED);
224202
});
@@ -230,11 +208,7 @@ describe('WebhooksService', () => {
230208
text: '',
231209
};
232210

233-
const unpublished = (await service.updateStory(
234-
createRequestObject(body),
235-
body,
236-
getWebhookSignature(body),
237-
)) as SessionEntity;
211+
const unpublished = (await service.updateStory(body)) as SessionEntity;
238212

239213
expect(unpublished.status).toBe(STORYBLOK_STORY_STATUS_ENUM.UNPUBLISHED);
240214
});
@@ -282,11 +256,7 @@ describe('WebhooksService', () => {
282256
text: '',
283257
};
284258

285-
const session = (await service.updateStory(
286-
createRequestObject(body),
287-
body,
288-
getWebhookSignature(body),
289-
)) as SessionEntity;
259+
const session = (await service.updateStory(body)) as SessionEntity;
290260

291261
expect(courseFindOneSpy).toHaveBeenCalledWith({
292262
storyblokUuid: 'anotherCourseUuId',
@@ -329,11 +299,7 @@ describe('WebhooksService', () => {
329299
text: '',
330300
};
331301

332-
const session = (await service.updateStory(
333-
createRequestObject(body),
334-
body,
335-
getWebhookSignature(body),
336-
)) as SessionEntity;
302+
const session = (await service.updateStory(body)) as SessionEntity;
337303

338304
expect(session).toEqual(mockSession);
339305
expect(courseFindOneSpy).toHaveBeenCalledWith({
@@ -392,11 +358,7 @@ describe('WebhooksService', () => {
392358
text: '',
393359
};
394360

395-
const session = (await service.updateStory(
396-
createRequestObject(body),
397-
body,
398-
getWebhookSignature(body),
399-
)) as SessionEntity;
361+
const session = (await service.updateStory(body)) as SessionEntity;
400362

401363
expect(session).toEqual(mockSession);
402364
expect(sessionSaveRepoSpy).toHaveBeenCalledWith({
@@ -430,11 +392,7 @@ describe('WebhooksService', () => {
430392
text: '',
431393
};
432394

433-
const course = (await service.updateStory(
434-
createRequestObject(body),
435-
body,
436-
getWebhookSignature(body),
437-
)) as CourseEntity;
395+
const course = (await service.updateStory(body)) as CourseEntity;
438396

439397
expect(course).toEqual(mockCourse);
440398
expect(courseFindOneRepoSpy).toHaveBeenCalledWith({

src/webhooks/webhooks.service.ts

+1-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
3-
import { createHmac } from 'crypto';
43
import { SlackMessageClient } from 'src/api/slack/slack-api';
54
import { CourseEntity } from 'src/entities/course.entity';
65
import { EventLogEntity } from 'src/entities/event-log.entity';
@@ -350,27 +349,7 @@ export class WebhooksService {
350349
}
351350
}
352351

353-
async updateStory(req, data: StoryDto, signature: string | undefined) {
354-
// Verify storyblok signature uses storyblok webhook secret - see https://www.storyblok.com/docs/guide/in-depth/webhooks#securing-a-webhook
355-
if (!signature) {
356-
const error = `Storyblok webhook error - no signature provided`;
357-
this.logger.error(error);
358-
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
359-
}
360-
361-
const webhookSecret = process.env.STORYBLOK_WEBHOOK_SECRET;
362-
363-
req.rawBody = '' + data;
364-
req.setEncoding('utf8');
365-
366-
const bodyHmac = createHmac('sha1', webhookSecret).update(req.rawBody).digest('hex');
367-
368-
if (bodyHmac !== signature) {
369-
const error = `Storyblok webhook error - signature mismatch`;
370-
this.logger.error(error);
371-
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
372-
}
373-
352+
async updateStory(data: StoryDto) {
374353
const action = data.action;
375354
const story_id = data.story_id;
376355

test/utils/mockData.ts

+18
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,21 @@ export const mockSubscriptionUserEntity = {
384384
userId: mockUserEntity.id,
385385
subscription: mockSubscriptionEntity,
386386
} as SubscriptionUserEntity;
387+
388+
export const mockStoryDto = {
389+
text: 'string',
390+
action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED,
391+
story_id: 1,
392+
space_id: 123,
393+
full_slug: 'course slug',
394+
};
395+
396+
export const mockSessionEntity = {
397+
id: 'sid',
398+
name: 'session name',
399+
slug: 'session_name',
400+
status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED,
401+
storyblokId: 123,
402+
storyblokUuid: '1234',
403+
courseId: '12345',
404+
} as SessionEntity;

0 commit comments

Comments
 (0)