Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Job Launcher] Exchange Oracle webhook #849

Merged
merged 5 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export class JobService {
};

this.storageClient = new StorageClient(
storageCredentials,
this.storageParams,
storageCredentials,
);
}

Expand Down
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('/cvat/escrow-failed-webhook')
public async (
@Headers(HEADER_SIGNATURE_KEY) _: string,
@Body() data: EscrowFailedWebhookDto,
): Promise<any> {
return this.jobService.escrowFailedWebhook(data);
}
}
20 changes: 20 additions & 0 deletions packages/apps/job-launcher/server/src/modules/job/job.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,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
@@ -1,7 +1,7 @@
import { createMock } from '@golevelup/ts-jest';
import { ChainId, EscrowClient, EscrowStatus, StorageClient } 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 @@ -1165,4 +1165,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();
});
});
});
Empty file.
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 @@ -52,6 +52,7 @@ import { Web3Service } from '../web3/web3.service';
import {
CvatFinalResultDto,
CvatManifestDto,
EscrowFailedWebhookDto,
FortuneFinalResultDto,
FortuneManifestDto,
JobCvatDto,
Expand Down Expand Up @@ -587,4 +588,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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export class WebhookService {
this.bucket = this.configService.get<string>(ConfigNames.S3_BUCKET)!;

this.storageClient = new StorageClient(
storageCredentials,
this.storageParams,
storageCredentials,
);
}

Expand Down