Skip to content

Commit

Permalink
[Job Launcher] Exchange Oracle webhook (#849)
Browse files Browse the repository at this point in the history
* Added signature verification logic, added exchange oracle webhook processing, added unit tests

* Removed unused interface

* Updated route name

* Revert changes

* Added oracle type to endpoint
  • Loading branch information
eugenvoronov authored Sep 1, 2023
1 parent fab76cf commit b21b382
Show file tree
Hide file tree
Showing 12 changed files with 265 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export enum ErrorJob {
ResultValidationFailed = 'Result validation failed',
InvalidRequestType = 'Invalid job type',
JobParamsValidationFailed = 'Job parameters validation failed',
InvalidEventType = 'Invalid event type',
NotLaunched = 'Not launched'
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ export const MAINNET_CHAIN_IDS = [

export const SENDGRID_API_KEY_REGEX =
/^SG\.[A-Za-z0-9-_]{22}\.[A-Za-z0-9-_]{43}$/;

export const HEADER_SIGNATURE_KEY = 'human-signature';
5 changes: 5 additions & 0 deletions packages/apps/job-launcher/server/src/common/enums/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ export enum EventType {
ESCROW_CANCELED = 'escrow_canceled',
TASK_CREATION_FAILED = 'task_creation_failed',
}

export enum OracleType {
FORTUNE = 'fortune',
CVAT = 'cvat',
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './jwt.auth';
export * from './signature.auth';
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SignatureAuthGuard } from './signature.auth';
import { verifySignature } from '../utils/signature';
import { MOCK_ADDRESS } from '../../../test/constants';

jest.mock('../../common/utils/signature');

describe('SignatureAuthGuard', () => {
let guard: SignatureAuthGuard;
let mockConfigService: Partial<ConfigService>;

beforeEach(async () => {
mockConfigService = {
get: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
SignatureAuthGuard,
{ provide: ConfigService, useValue: mockConfigService }
],
}).compile();

guard = module.get<SignatureAuthGuard>(SignatureAuthGuard);
});

it('should be defined', () => {
expect(guard).toBeDefined();
});

describe('canActivate', () => {
let context: ExecutionContext;
let mockRequest: any;

beforeEach(() => {
mockRequest = {
switchToHttp: jest.fn().mockReturnThis(),
getRequest: jest.fn().mockReturnThis(),
headers: {},
body: {},
originalUrl: '',
};
context = {
switchToHttp: jest.fn().mockReturnThis(),
getRequest: jest.fn(() => mockRequest)
} as any as ExecutionContext;
});

it('should return true if signature is verified', async () => {
mockRequest.headers['header-signature-key'] = 'validSignature';
jest.spyOn(guard, 'determineAddress').mockReturnValue('someAddress');
(verifySignature as jest.Mock).mockReturnValue(true);

const result = await guard.canActivate(context as any);
expect(result).toBeTruthy();
});

it('should throw unauthorized exception if signature is not verified', async () => {
jest.spyOn(guard, 'determineAddress').mockReturnValue('someAddress');
(verifySignature as jest.Mock).mockReturnValue(false);

await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
});

it('should throw unauthorized exception for unrecognized oracle type', async () => {
mockRequest.originalUrl = '/some/random/path';
await expect(guard.canActivate(context as any)).rejects.toThrow(UnauthorizedException);
});
});

describe('determineAddress', () => {
it('should return the correct address if originalUrl contains the fortune oracle type', () => {
const mockRequest = { originalUrl: '/somepath/fortune/anotherpath' };
const expectedAddress = MOCK_ADDRESS;
mockConfigService.get = jest.fn().mockReturnValue(expectedAddress);

const result = guard.determineAddress(mockRequest);

expect(result).toEqual(expectedAddress);
});

it('should return the correct address if originalUrl contains the cvat oracle type', () => {
const mockRequest = { originalUrl: '/somepath/cvat/anotherpath' };
const expectedAddress = MOCK_ADDRESS;
mockConfigService.get = jest.fn().mockReturnValue(expectedAddress);

const result = guard.determineAddress(mockRequest);

expect(result).toEqual(expectedAddress);
});

it('should throw BadRequestException for unrecognized oracle type', () => {
const mockRequest = { originalUrl: '/some/random/path' };

expect(() => {
guard.determineAddress(mockRequest);
}).toThrow(BadRequestException);
});

});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@

import { BadRequestException, CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { verifySignature } from '../utils/signature';
import { HEADER_SIGNATURE_KEY } from '../constants';
import { ConfigService } from '@nestjs/config';
import { ConfigNames } from '../config';
import { OracleType } from '../enums/webhook';

@Injectable()
export class SignatureAuthGuard implements CanActivate {
constructor(
public readonly configService: ConfigService
) {}

public async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();

const data = request.body;
const signature = request.headers[HEADER_SIGNATURE_KEY];

try {
const address = this.determineAddress(request);
const isVerified = verifySignature(data, signature, address)

if (isVerified) {
return true;
}
} catch (error) {
console.error(error);
}

throw new UnauthorizedException('Unauthorized');
}

public determineAddress(request: any): string {
const originalUrl = request.originalUrl;
const parts = originalUrl.split('/');
const oracleType = parts[2];

if (oracleType === OracleType.FORTUNE) {
return this.configService.get<string>(
ConfigNames.FORTUNE_EXCHANGE_ORACLE_ADDRESS,
)!
} else if (oracleType === OracleType.CVAT) {
return this.configService.get<string>(
ConfigNames.CVAT_EXCHANGE_ORACLE_ADDRESS,
)!
} else {
throw new BadRequestException('Unable to determine address from origin URL');
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export interface RequestWithUser extends Request {
user: any;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function recoverSigner(
if (typeof message !== 'string') {
message = JSON.stringify(message);
}

try {
return ethers.utils.verifyMessage(message, signature);
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Controller,
DefaultValuePipe,
Get,
Headers,
Param,
Patch,
Post,
Expand All @@ -11,12 +12,13 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards';
import { JwtAuthGuard, SignatureAuthGuard } from '../../common/guards';
import { RequestWithUser } from '../../common/types';
import { JobFortuneDto, JobCvatDto, JobListDto, JobCancelDto } from './job.dto';
import { JobFortuneDto, JobCvatDto, JobListDto, JobCancelDto, EscrowFailedWebhookDto } from './job.dto';
import { JobService } from './job.service';
import { JobRequestType, JobStatusFilter } from '../../common/enums/job';
import { Public } from '../../common/decorators';
import { HEADER_SIGNATURE_KEY } from 'src/common/constants';

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
Expand Down Expand Up @@ -81,4 +83,14 @@ export class JobController {
public async cancelCronJob(): Promise<any> {
return this.jobService.cancelCronJob();
}

@Public()
@UseGuards(SignatureAuthGuard)
@Post('/:oracleType/escrow-failed-webhook')
public async (
@Headers(HEADER_SIGNATURE_KEY) _: string,
@Body() data: EscrowFailedWebhookDto,
): Promise<any> {
return this.jobService.escrowFailedWebhook(data);
}
}
22 changes: 21 additions & 1 deletion packages/apps/job-launcher/server/src/modules/job/job.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
JobStatus,
JobStatusFilter,
} from '../../common/enums/job';
import { EventType } from '../../common/enums/webhook';
import { EventType, OracleType } from '../../common/enums/webhook';

export class JobCreateDto {
public chainId: ChainId;
Expand Down Expand Up @@ -221,3 +221,23 @@ export class JobListDto {
fundAmount: number;
status: JobStatusFilter;
}

export class EscrowFailedWebhookDto {
@ApiProperty({
enum: ChainId,
})
@IsEnum(ChainId)
public chain_id: ChainId;

@ApiProperty()
@IsString()
public escrow_address: string;

@ApiProperty()
@IsEnum(EventType)
public event_type: EventType;

@ApiProperty()
@IsString()
public reason: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
EscrowStatus,
} from '@human-protocol/sdk';
import { HttpService } from '@nestjs/axios';
import { BadGatewayException, NotFoundException } from '@nestjs/common';
import { BadGatewayException, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import {
Expand Down Expand Up @@ -1190,4 +1190,38 @@ describe('JobService', () => {
);
});
});

describe('escrowFailedWebhook', () => {
it('should throw BadRequestException for invalid event type', async () => {
const dto = { event_type: 'ANOTHER_EVENT' as EventType, chain_id: 1, escrow_address: 'address', reason: 'invalid manifest' };

await expect(jobService.escrowFailedWebhook(dto)).rejects.toThrow(BadRequestException);
});

it('should throw NotFoundException if jobEntity is not found', async () => {
const dto = { event_type: EventType.TASK_CREATION_FAILED, chain_id: 1, escrow_address: 'address', reason: 'invalid manifest' };
jobRepository.findOne = jest.fn().mockResolvedValue(null);

await expect(jobService.escrowFailedWebhook(dto)).rejects.toThrow(NotFoundException);
});

it('should throw ConflictException if jobEntity status is not LAUNCHED', async () => {
const dto = { event_type: EventType.TASK_CREATION_FAILED, chain_id: 1, escrow_address: 'address', reason: 'invalid manifest' };
const mockJobEntity = { status: 'ANOTHER_STATUS' as JobStatus, save: jest.fn() };
jobRepository.findOne = jest.fn().mockResolvedValue(mockJobEntity);

await expect(jobService.escrowFailedWebhook(dto)).rejects.toThrow(ConflictException);
});

it('should update jobEntity status to FAILED and return true if all checks pass', async () => {
const dto = { event_type: EventType.TASK_CREATION_FAILED, chain_id: 1, escrow_address: 'address', reason: 'invalid manifest' };
const mockJobEntity = { status: JobStatus.LAUNCHED, save: jest.fn() };
jobRepository.findOne = jest.fn().mockResolvedValue(mockJobEntity);

const result = await jobService.escrowFailedWebhook(dto);
expect(result).toBe(true);
expect(mockJobEntity.status).toBe(JobStatus.FAILED);
expect(mockJobEntity.save).toHaveBeenCalled();
});
});
});
28 changes: 28 additions & 0 deletions packages/apps/job-launcher/server/src/modules/job/job.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { Web3Service } from '../web3/web3.service';
import {
CvatFinalResultDto,
CvatManifestDto,
EscrowFailedWebhookDto,
FortuneFinalResultDto,
FortuneManifestDto,
JobCvatDto,
Expand Down Expand Up @@ -614,4 +615,31 @@ export class JobService {

return true;
}

public async escrowFailedWebhook(dto: EscrowFailedWebhookDto): Promise<boolean> {
if (dto.event_type !== EventType.TASK_CREATION_FAILED) {
this.logger.log(ErrorJob.InvalidEventType, JobService.name);
throw new BadRequestException(ErrorJob.InvalidEventType);
}

const jobEntity = await this.jobRepository.findOne({
chainId: dto.chain_id,
escrowAddress: dto.escrow_address,
});

if (!jobEntity) {
this.logger.log(ErrorJob.NotFound, JobService.name);
throw new NotFoundException(ErrorJob.NotFound);
}

if (jobEntity.status !== JobStatus.LAUNCHED) {
this.logger.log(ErrorJob.NotLaunched, JobService.name);
throw new ConflictException(ErrorJob.NotLaunched);
}

jobEntity.status = JobStatus.FAILED
await jobEntity.save()

return true;
}
}

1 comment on commit b21b382

@vercel
Copy link

@vercel vercel bot commented on b21b382 Sep 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

job-launcher-server – ./packages/apps/job-launcher/server

job-launcher-server-git-develop-humanprotocol.vercel.app
job-launcher-server-nine.vercel.app
job-launcher-server-humanprotocol.vercel.app

Please sign in to comment.