Skip to content

Commit

Permalink
implement webhook token (#487)
Browse files Browse the repository at this point in the history
* implement webhook token

* add test for webhook token

* add token validator

* fix lint error

* allow null for token in CreateWebhookRequestDto

* feat: add webhook token in web (#534)

* feat: add webhook token in web

* fix: remove console lkog

* fix: ci

* add test for token-validator

* implement retry logic

* fix: i18n

---------

Co-authored-by: Miller <[email protected]>
  • Loading branch information
jihun and chiol authored Aug 1, 2024
1 parent 0f19fe7 commit fd77f5a
Show file tree
Hide file tree
Showing 23 changed files with 480 additions and 104 deletions.
61 changes: 61 additions & 0 deletions apps/api/src/common/validators/token-validator.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Copyright 2023 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { IsNotEmpty, validate } from 'class-validator';

import { TokenValidator } from './token-validator';

class TokenDto {
@IsNotEmpty()
@TokenValidator({ message: 'Invalid token format' })
token: string;
}

describe('TokenValidator', () => {
it('should validate a correct token', async () => {
const dto = new TokenDto();
dto.token = 'validToken123456';

const errors = await validate(dto);
expect(errors.length).toBe(0);
});

it('should invalidate a token with invalid characters', async () => {
const dto = new TokenDto();
dto.token = 'invalidToken$123';

const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
});

it('should invalidate a token that is too short', async () => {
const dto = new TokenDto();
dto.token = 'short';

const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
});

it('should invalidate an empty token', async () => {
const dto = new TokenDto();
dto.token = '';

const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});
});
48 changes: 48 additions & 0 deletions apps/api/src/common/validators/token-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Copyright 2023 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';

@ValidatorConstraint({ async: false })
export class TokenValidatorConstraint implements ValidatorConstraintInterface {
validate(token: string | null) {
const regex = /^[a-zA-Z0-9._-]+$/;
return (
!token ||
(typeof token === 'string' && regex.test(token) && token.length >= 16)
);
}

defaultMessage() {
return 'Token must be at least 16 characters long and contain only alphanumeric characters, dots, hyphens, and underscores.';
}
}

export function TokenValidator(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: TokenValidatorConstraint,
});
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Copyright 2023 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddTokenOnWebhook1720760282371 implements MigrationInterface {
name = 'AddTokenOnWebhook1720760282371';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE \`webhooks\` ADD \`token\` varchar(255) NULL DEFAULT NULL AFTER \`url\``,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`webhooks\` DROP COLUMN \`token\``);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export class CreateWebhookDto {
@Expose()
events: EventDto[];

@Expose()
token: string | null;

public static from(params: any): CreateWebhookDto {
return plainToInstance(CreateWebhookDto, params, {
excludeExtraneousValues: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum, IsString } from 'class-validator';

import { WebhookStatusEnum } from '@/common/enums';
import { TokenValidator } from '@/common/validators/token-validator';
import { IsNullable } from '@/domains/admin/user/decorators';
import { EventDto } from '..';

export class CreateWebhookRequestDto {
Expand All @@ -35,4 +37,9 @@ export class CreateWebhookRequestDto {
@ApiProperty({ type: [EventDto] })
@IsArray()
events: EventDto[];

@ApiProperty({ nullable: true })
@IsNullable()
@TokenValidator()
token: string | null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export class GetWebhookByIdResponseDto {
@ApiProperty()
url: string;

@Expose()
@ApiProperty()
token: string;

@Expose()
@ApiProperty({ type: WebhookStatusEnum, enum: WebhookStatusEnum })
status: WebhookStatusEnum;
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/domains/admin/project/webhook/webhook.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export class WebhookEntity extends CommonEntity {
@Column('varchar')
url: string;

@Column('varchar')
token: string | null;

@Column('enum', {
enum: WebhookStatusEnum,
default: WebhookStatusEnum.ACTIVE,
Expand All @@ -45,12 +48,13 @@ export class WebhookEntity extends CommonEntity {
})
events: Relation<EventEntity>[];

static from({ projectId, name, url, status, events }) {
static from({ projectId, name, url, token, status, events }) {
const webhook = new WebhookEntity();
webhook.project = new ProjectEntity();
webhook.project.id = projectId;
webhook.name = name;
webhook.url = url;
webhook.token = token;
webhook.status = status;
webhook.events = events;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('webhook listener', () => {
{
headers: {
'Content-Type': 'application/json',
'x-webhook-token': 'TEST-TOKEN',
},
},
);
Expand Down Expand Up @@ -87,6 +88,7 @@ describe('webhook listener', () => {
{
headers: {
'Content-Type': 'application/json',
'x-webhook-token': 'TEST-TOKEN',
},
},
);
Expand Down Expand Up @@ -114,6 +116,7 @@ describe('webhook listener', () => {
{
headers: {
'Content-Type': 'application/json',
'x-webhook-token': 'TEST-TOKEN',
},
},
);
Expand Down Expand Up @@ -142,6 +145,7 @@ describe('webhook listener', () => {
{
headers: {
'Content-Type': 'application/json',
'x-webhook-token': 'TEST-TOKEN',
},
},
);
Expand Down
12 changes: 11 additions & 1 deletion apps/api/src/domains/admin/project/webhook/webhook.listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import type { AxiosError } from 'axios';
import { catchError, lastValueFrom, of } from 'rxjs';
import { catchError, lastValueFrom, of, retry, timer } from 'rxjs';
import { Repository } from 'typeorm';

import type { IssueStatusEnum } from '@/common/enums';
Expand Down Expand Up @@ -63,10 +63,20 @@ export class WebhookListener {
{
headers: {
'Content-Type': 'application/json',
'x-webhook-token': webhook.token,
},
},
)
.pipe(
retry({
count: 3,
delay: (error, retryCount) => {
this.logger.warn(
`Retrying webhook... Attempt #${retryCount + 1}`,
);
return timer(3000);
},
}),
catchError((error: AxiosError) => {
this.logger.error({
message: 'Failed to send webhook',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function createCreateWebhookDto(overrides = {}): CreateWebhookDto {
projectId: webhookFixture.project.id,
name: faker.string.sample(),
url: faker.internet.url(),
token: faker.string.sample(),
status: WebhookStatusEnum.ACTIVE,
events: [
{
Expand Down Expand Up @@ -72,6 +73,7 @@ function createUpdateWebhookDto(overrides = {}): UpdateWebhookDto {
projectId: webhookFixture.project.id,
name: faker.string.sample(),
url: faker.internet.url(),
token: faker.string.sample(),
status: getRandomEnumValue(WebhookStatusEnum),
events: [
{
Expand Down Expand Up @@ -124,6 +126,7 @@ describe('webhook service', () => {
expect(webhook.project.id).toBe(dto.projectId);
expect(webhook.name).toBe(dto.name);
expect(webhook.url).toBe(dto.url);
expect(webhook.token).toBe(dto.token);
expect(webhook.status).toBe(dto.status);
expect(webhook.events.length).toBe(dto.events.length);
for (let i = 0; i < webhook.events.length; i++) {
Expand Down Expand Up @@ -163,6 +166,7 @@ describe('webhook service', () => {
expect(webhook.project.id).toBe(dto.projectId);
expect(webhook.name).toBe(dto.name);
expect(webhook.url).toBe(dto.url);
expect(webhook.token).toBe(dto.token);
expect(webhook.status).toBe(dto.status);
expect(webhook.events.length).toBe(dto.events.length);
expect(webhook.events[0].channels.length).toBe(
Expand Down Expand Up @@ -273,6 +277,7 @@ describe('webhook service', () => {
expect(webhook.project.id).toBe(dto.projectId);
expect(webhook.name).toBe(dto.name);
expect(webhook.url).toBe(dto.url);
expect(webhook.token).toBe(dto.token);
expect(webhook.status).toBe(dto.status);
expect(webhook.events.length).toBe(dto.events.length);
for (let i = 0; i < webhook.events.length; i++) {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/domains/admin/project/webhook/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export class WebhookService {
projectId: dto.projectId,
name: dto.name,
url: dto.url,
token: dto.token,
status: dto.status,
events,
});
Expand Down Expand Up @@ -137,6 +138,7 @@ export class WebhookService {

webhook.name = dto.name;
webhook.url = dto.url;
webhook.token = dto.token;
webhook.status = dto.status;
webhook.events = (
await Promise.all(
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/test-utils/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ export const webhookFixture = {
id: faker.number.int(),
name: faker.string.sample(),
url: faker.internet.url(),
token: 'TEST-TOKEN',
status: WebhookStatusEnum.ACTIVE,
project: projectFixture,
events: getAllEvents(),
Expand Down
1 change: 1 addition & 0 deletions apps/web/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = [
'jest.setup.ts',
'next-env.d.ts',
'jest.polyfills.js',
'**/api.type.ts',
],
},
...baseConfig,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/public/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@
"next": "Nächste",
"complete": "Abschließen",
"next-time": "Später",
"start": "Starten"
"start": "Starten",
"generate": "Generieren"
},
"input": {
"label": {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@
"next": "Next",
"complete": "Complete",
"next-time": "Later",
"start": "Start"
"start": "Start",
"generate": "Generate"
},
"input": {
"label": {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/public/locales/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@
"next": "次へ",
"complete": "完了",
"next-time": "次回",
"start": "はじまり"
"start": "はじまり",
"generate": "生成"
},
"input": {
"label": {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/public/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@
"next": "다음",
"complete": "완료",
"next-time": "다음에",
"start": "시작하기"
"start": "시작하기",
"generate": "생성"
},
"input": {
"label": {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@
"next": "下一个",
"complete": "完成",
"next-time": "下次",
"start": "开始"
"start": "开始",
"generate": "產生"
},
"input": {
"label": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const defaultValues: WebhookInfo = {
name: '',
status: 'ACTIVE',
url: '',
token: null,
events: [
{ type: 'FEEDBACK_CREATION', channelIds: [], status: 'INACTIVE' },
{ type: 'ISSUE_ADDITION', channelIds: [], status: 'INACTIVE' },
Expand Down
Loading

0 comments on commit fd77f5a

Please sign in to comment.