Skip to content

Commit

Permalink
refactor: simplybook webhook signature handling
Browse files Browse the repository at this point in the history
  • Loading branch information
eleanorreem committed Jun 11, 2024
1 parent d4e2d33 commit 872f49b
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 75 deletions.
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ExceptionsFilter } from './utils/exceptions.filter';

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

app.setGlobalPrefix('api');

Expand Down
5 changes: 5 additions & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export const slackWebhookUrl = getEnv(process.env.SLACK_WEBHOOK_URL, 'SLACK_WEBH

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

export const storyblokWebhookSecret = getEnv(
process.env.STORYBLOK_WEBHOOK_SECRET,
'STORYBLOK_WEBHOOK_SECRET',
);

export const simplybookCredentials = getEnv(
process.env.SIMPLYBOOK_CREDENTIALS,
'SIMPLYBOOK_CREDENTIALS',
Expand Down
43 changes: 42 additions & 1 deletion src/webhooks/webhooks.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
import { createMock } from '@golevelup/ts-jest';
import { HttpException, HttpStatus } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { mockSimplybookBodyBase, mockTherapySessionEntity } from 'test/utils/mockData';
import { createHmac } from 'crypto';
import { storyblokWebhookSecret } from 'src/utils/constants';
import {
mockSessionEntity,
mockSimplybookBodyBase,
mockStoryDto,
mockTherapySessionEntity,
} from 'test/utils/mockData';
import { mockWebhooksServiceMethods } from 'test/utils/mockedServices';
import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';

const getWebhookSignature = (body) => {
return createHmac('sha1', storyblokWebhookSecret)
.update('' + body)
.digest('hex');
};

const generateMockHeaders = (body) => {
return {
'webhook-signature': getWebhookSignature(body),
};
};

const createRequestObject = (body) => {
return {
rawBody: JSON.stringify(body),
setEncoding: () => {},
encoding: 'utf8',
};
};

describe('AppController', () => {
let webhooksController: WebhooksController;
const mockWebhooksService = createMock<WebhooksService>(mockWebhooksServiceMethods);
Expand Down Expand Up @@ -35,5 +62,19 @@ describe('AppController', () => {
webhooksController.updatePartnerAccessTherapy(mockSimplybookBodyBase),
).rejects.toThrow('Therapy session not found');
});
describe('updateStory', () => {
it('updateStory should pass if service returns true', async () => {
jest.spyOn(mockWebhooksService, 'updateStory').mockImplementationOnce(async () => {
return mockSessionEntity;
});
await expect(
webhooksController.updateStory(
createRequestObject(mockStoryDto),
mockStoryDto,
generateMockHeaders(mockStoryDto),
),
).resolves.toBe(mockSessionEntity);
});
});
});
});
32 changes: 30 additions & 2 deletions src/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { Body, Controller, Headers, Logger, Post, Request, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Headers,
HttpException,
HttpStatus,
Logger,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiBody, ApiTags } from '@nestjs/swagger';
import { createHmac } from 'crypto';
import { EventLogEntity } from 'src/entities/event-log.entity';
import { TherapySessionEntity } from 'src/entities/therapy-session.entity';
import { storyblokWebhookSecret } from 'src/utils/constants';
import { ControllerDecorator } from 'src/utils/controller.decorator';
import { WebhookCreateEventLogDto } from 'src/webhooks/dto/webhook-create-event-log.dto';
import { ZapierSimplybookBodyDto } from '../partner-access/dtos/zapier-body.dto';
Expand Down Expand Up @@ -36,6 +48,22 @@ export class WebhooksController {
@ApiBody({ type: StoryDto })
async updateStory(@Request() req, @Body() data: StoryDto, @Headers() headers) {
const signature: string | undefined = headers['webhook-signature'];
return this.webhooksService.updateStory(req, data, signature);
// Verify storyblok signature uses storyblok webhook secret - see https://www.storyblok.com/docs/guide/in-depth/webhooks#securing-a-webhook
if (!signature) {
const error = `Storyblok webhook error - no signature provided`;
this.logger.error(error);
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
}

req.rawBody = '' + data;
req.setEncoding('utf8');

const bodyHmac = createHmac('sha1', storyblokWebhookSecret).update(req.rawBody).digest('hex');
if (bodyHmac !== signature) {
const error = `Storyblok webhook error - signature mismatch`;
this.logger.error(error);
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
}
return this.webhooksService.updateStory(data);
}
}
56 changes: 7 additions & 49 deletions src/webhooks/webhooks.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { createHmac } from 'crypto';
import { SlackMessageClient } from 'src/api/slack/slack-api';
import { CoursePartnerService } from 'src/course-partner/course-partner.service';
import { CoursePartnerEntity } from 'src/entities/course-partner.entity';
Expand Down Expand Up @@ -47,21 +46,6 @@ import { ILike, Repository } from 'typeorm';
import { WebhookCreateEventLogDto } from './dto/webhook-create-event-log.dto';
import { WebhooksService } from './webhooks.service';

const webhookSecret = process.env.STORYBLOK_WEBHOOK_SECRET;

const getWebhookSignature = (body) => {
return createHmac('sha1', webhookSecret)
.update('' + body)
.digest('hex');
};
const createRequestObject = (body) => {
return {
rawBody: '' + body,
setEncoding: () => {},
encoding: 'utf8',
};
};

// Difficult to mock classes as well as node modules.
// This seemed the best approach
jest.mock('storyblok-js-client', () => {
Expand Down Expand Up @@ -202,9 +186,7 @@ describe('WebhooksService', () => {
text: '',
};

return expect(
service.updateStory(createRequestObject(body), body, getWebhookSignature(body)),
).rejects.toThrow('STORYBLOK STORY NOT FOUND');
return expect(service.updateStory(body)).rejects.toThrow('STORYBLOK STORY NOT FOUND');
});

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

const deletedStory = (await service.updateStory(
createRequestObject(body),
body,
getWebhookSignature(body),
)) as SessionEntity;
const deletedStory = (await service.updateStory(body)) as SessionEntity;

expect(deletedStory.status).toBe(STORYBLOK_STORY_STATUS_ENUM.DELETED);
});
Expand All @@ -230,11 +208,7 @@ describe('WebhooksService', () => {
text: '',
};

const unpublished = (await service.updateStory(
createRequestObject(body),
body,
getWebhookSignature(body),
)) as SessionEntity;
const unpublished = (await service.updateStory(body)) as SessionEntity;

expect(unpublished.status).toBe(STORYBLOK_STORY_STATUS_ENUM.UNPUBLISHED);
});
Expand Down Expand Up @@ -282,11 +256,7 @@ describe('WebhooksService', () => {
text: '',
};

const session = (await service.updateStory(
createRequestObject(body),
body,
getWebhookSignature(body),
)) as SessionEntity;
const session = (await service.updateStory(body)) as SessionEntity;

expect(courseFindOneSpy).toHaveBeenCalledWith({
storyblokUuid: 'anotherCourseUuId',
Expand Down Expand Up @@ -329,11 +299,7 @@ describe('WebhooksService', () => {
text: '',
};

const session = (await service.updateStory(
createRequestObject(body),
body,
getWebhookSignature(body),
)) as SessionEntity;
const session = (await service.updateStory(body)) as SessionEntity;

expect(session).toEqual(mockSession);
expect(courseFindOneSpy).toHaveBeenCalledWith({
Expand Down Expand Up @@ -392,11 +358,7 @@ describe('WebhooksService', () => {
text: '',
};

const session = (await service.updateStory(
createRequestObject(body),
body,
getWebhookSignature(body),
)) as SessionEntity;
const session = (await service.updateStory(body)) as SessionEntity;

expect(session).toEqual(mockSession);
expect(sessionSaveRepoSpy).toHaveBeenCalledWith({
Expand Down Expand Up @@ -430,11 +392,7 @@ describe('WebhooksService', () => {
text: '',
};

const course = (await service.updateStory(
createRequestObject(body),
body,
getWebhookSignature(body),
)) as CourseEntity;
const course = (await service.updateStory(body)) as CourseEntity;

expect(course).toEqual(mockCourse);
expect(courseFindOneRepoSpy).toHaveBeenCalledWith({
Expand Down
23 changes: 1 addition & 22 deletions src/webhooks/webhooks.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createHmac } from 'crypto';
import { SlackMessageClient } from 'src/api/slack/slack-api';
import { CourseEntity } from 'src/entities/course.entity';
import { EventLogEntity } from 'src/entities/event-log.entity';
Expand Down Expand Up @@ -350,27 +349,7 @@ export class WebhooksService {
}
}

async updateStory(req, data: StoryDto, signature: string | undefined) {
// Verify storyblok signature uses storyblok webhook secret - see https://www.storyblok.com/docs/guide/in-depth/webhooks#securing-a-webhook
if (!signature) {
const error = `Storyblok webhook error - no signature provided`;
this.logger.error(error);
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
}

const webhookSecret = process.env.STORYBLOK_WEBHOOK_SECRET;

req.rawBody = '' + data;
req.setEncoding('utf8');

const bodyHmac = createHmac('sha1', webhookSecret).update(req.rawBody).digest('hex');

if (bodyHmac !== signature) {
const error = `Storyblok webhook error - signature mismatch`;
this.logger.error(error);
throw new HttpException(error, HttpStatus.UNAUTHORIZED);
}

async updateStory(data: StoryDto) {
const action = data.action;
const story_id = data.story_id;

Expand Down
18 changes: 18 additions & 0 deletions test/utils/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,21 @@ export const mockSubscriptionUserEntity = {
userId: mockUserEntity.id,
subscription: mockSubscriptionEntity,
} as SubscriptionUserEntity;

export const mockStoryDto = {
text: 'string',
action: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED,
story_id: 1,
space_id: 123,
full_slug: 'course slug',
};

export const mockSessionEntity = {
id: 'sid',
name: 'session name',
slug: 'session_name',
status: STORYBLOK_STORY_STATUS_ENUM.PUBLISHED,
storyblokId: 123,
storyblokUuid: '1234',
courseId: '12345',
} as SessionEntity;

0 comments on commit 872f49b

Please sign in to comment.