diff --git a/.github/workflows/ci-test-subgraph.yaml b/.github/workflows/ci-test-subgraph.yaml index f9b572bbb5..a84c675469 100644 --- a/.github/workflows/ci-test-subgraph.yaml +++ b/.github/workflows/ci-test-subgraph.yaml @@ -22,6 +22,6 @@ jobs: - name: Build core package run: yarn build:core - name: Generate manifest for Polygon for tests - - run: NETWORK=polygon yarn workspace @human-protocol/subgraph generate + run: NETWORK=polygon yarn workspace @human-protocol/subgraph generate - name: Run subgraph test run: yarn workspace @human-protocol/subgraph test diff --git a/packages/apps/fortune/exchange-oracle/server/package.json b/packages/apps/fortune/exchange-oracle/server/package.json index 528db0565c..2f71d2c961 100644 --- a/packages/apps/fortune/exchange-oracle/server/package.json +++ b/packages/apps/fortune/exchange-oracle/server/package.json @@ -38,7 +38,9 @@ "axios": "^1.3.1", "class-transformer": "^0.5.1", "class-validator": "0.14.1", + "ethers": "~6.13.5", "joi": "^17.13.3", + "pg": "8.13.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.2.0", "typeorm": "^0.3.23", @@ -60,7 +62,6 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "jest": "29.7.0", - "pg": "8.13.1", "prettier": "^3.4.2", "source-map-support": "^0.5.20", "supertest": "^7.0.0", diff --git a/packages/apps/fortune/exchange-oracle/server/src/app.module.ts b/packages/apps/fortune/exchange-oracle/server/src/app.module.ts index 37f1e71ddc..b2bc81423f 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/app.module.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/app.module.ts @@ -1,25 +1,31 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { JobModule } from './modules/job/job.module'; import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AppController } from './app.controller'; import { envValidator } from './common/config'; -import { APP_INTERCEPTOR } from '@nestjs/core'; +import { EnvConfigModule } from './common/config/config.module'; +import { ExceptionFilter } from './common/exceptions/exception.filter'; +import { JwtHttpStrategy } from './common/guards/strategy'; import { SnakeCaseInterceptor } from './common/interceptors/snake-case'; +import { TransformEnumInterceptor } from './common/interceptors/transform-enum.interceptor'; import { DatabaseModule } from './database/database.module'; -import { WebhookModule } from './modules/webhook/webhook.module'; -import { JwtHttpStrategy } from './common/guards/strategy'; -import { Web3Module } from './modules/web3/web3.module'; -import { UserModule } from './modules/user/user.module'; -import { StatsModule } from './modules/stats/stats.module'; import { AssignmentModule } from './modules/assignment/assignment.module'; import { CronJobModule } from './modules/cron-job/cron-job.module'; import { HealthModule } from './modules/health/health.module'; -import { EnvConfigModule } from './common/config/config.module'; -import { ScheduleModule } from '@nestjs/schedule'; -import { TransformEnumInterceptor } from './common/interceptors/transform-enum.interceptor'; +import { JobModule } from './modules/job/job.module'; +import { StatsModule } from './modules/stats/stats.module'; +import { UserModule } from './modules/user/user.module'; +import { Web3Module } from './modules/web3/web3.module'; +import { WebhookModule } from './modules/webhook/webhook.module'; +import { HttpValidationPipe } from './common/pipes'; @Module({ providers: [ + { + provide: APP_PIPE, + useClass: HttpValidationPipe, + }, { provide: APP_INTERCEPTOR, useClass: SnakeCaseInterceptor, @@ -28,6 +34,10 @@ import { TransformEnumInterceptor } from './common/interceptors/transform-enum.i provide: APP_INTERCEPTOR, useClass: TransformEnumInterceptor, }, + { + provide: APP_FILTER, + useClass: ExceptionFilter, + }, JwtHttpStrategy, ], imports: [ diff --git a/packages/apps/fortune/exchange-oracle/server/src/database/database.enum.ts b/packages/apps/fortune/exchange-oracle/server/src/common/enums/database.ts similarity index 100% rename from packages/apps/fortune/exchange-oracle/server/src/database/database.enum.ts rename to packages/apps/fortune/exchange-oracle/server/src/common/enums/database.ts diff --git a/packages/apps/fortune/exchange-oracle/server/src/database/database.error.ts b/packages/apps/fortune/exchange-oracle/server/src/common/errors/database.ts similarity index 72% rename from packages/apps/fortune/exchange-oracle/server/src/database/database.error.ts rename to packages/apps/fortune/exchange-oracle/server/src/common/errors/database.ts index c45ed94452..de5201b9e1 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/database/database.error.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/errors/database.ts @@ -1,12 +1,6 @@ import { QueryFailedError } from 'typeorm'; -import { PostgresErrorCodes } from './database.enum'; - -export class DatabaseError extends Error { - constructor(message: string, stack: string) { - super(message); - this.stack = stack; - } -} +import { DatabaseError } from '.'; +import { PostgresErrorCodes } from '../enums/database'; export function handleQueryFailedError(error: QueryFailedError): DatabaseError { const stack = error.stack || ''; diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/errors/index.ts b/packages/apps/fortune/exchange-oracle/server/src/common/errors/index.ts new file mode 100644 index 0000000000..6747ce6861 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/common/errors/index.ts @@ -0,0 +1,53 @@ +export * from './database'; + +export class BaseError extends Error { + constructor(message: string, stack?: string) { + super(message); + this.name = this.constructor.name; + if (stack) { + this.stack = stack; + } + } +} + +export class ValidationError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class AuthError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ForbiddenError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class NotFoundError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ConflictError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ServerError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class DatabaseError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/exceptions/exception.filter.ts b/packages/apps/fortune/exchange-oracle/server/src/common/exceptions/exception.filter.ts new file mode 100644 index 0000000000..875fbb43f7 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/common/exceptions/exception.filter.ts @@ -0,0 +1,64 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter as IExceptionFilter, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { + ValidationError, + AuthError, + ForbiddenError, + NotFoundError, + ConflictError, + ServerError, + DatabaseError, +} from '../errors'; + +@Catch() +export class ExceptionFilter implements IExceptionFilter { + private logger = new Logger(ExceptionFilter.name); + + private getStatus(exception: any): number { + if (exception instanceof ValidationError) { + return HttpStatus.BAD_REQUEST; + } else if (exception instanceof AuthError) { + return HttpStatus.UNAUTHORIZED; + } else if (exception instanceof ForbiddenError) { + return HttpStatus.FORBIDDEN; + } else if (exception instanceof NotFoundError) { + return HttpStatus.NOT_FOUND; + } else if (exception instanceof ConflictError) { + return HttpStatus.CONFLICT; + } else if (exception instanceof ServerError) { + return HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception instanceof DatabaseError) { + return HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception.statusCode) { + return exception.statusCode; + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } + + catch(exception: any, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = this.getStatus(exception); + const message = exception.message || 'Internal server error'; + + this.logger.error( + `Exception caught: ${message}`, + exception.stack || 'No stack trace available', + ); + + response.status(status).json({ + status_code: status, + timestamp: new Date().toISOString(), + message: message, + path: request.url, + }); + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/guards/jwt.auth.ts b/packages/apps/fortune/exchange-oracle/server/src/common/guards/jwt.auth.ts index 626469672f..bac1f900fd 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/guards/jwt.auth.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/guards/jwt.auth.ts @@ -1,12 +1,8 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { Role } from '../enums/role'; +import { AuthError } from '../errors'; import { JwtUser } from '../types/jwt'; @Injectable() @@ -27,10 +23,7 @@ export class JwtAuthGuard extends AuthGuard('jwt-http') implements CanActivate { } // Try to authenticate with JWT - const canActivate = (await super.canActivate(context)) as boolean; - if (!canActivate) { - throw new UnauthorizedException('JWT authentication failed'); - } + await super.canActivate(context); // Roles verification let roles = this.reflector.get('roles', context.getHandler()); @@ -39,11 +32,11 @@ export class JwtAuthGuard extends AuthGuard('jwt-http') implements CanActivate { const request = context.switchToHttp().getRequest(); const user = request.user as JwtUser; if (!user) { - throw new UnauthorizedException('User not found in request'); + throw new AuthError('User not found in request'); } if (!roles.includes(user.role)) { - throw new UnauthorizedException('Invalid role'); + throw new AuthError('Invalid role'); } return true; diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.spec.ts b/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.spec.ts index 2526286b65..7741d6d655 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.spec.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.spec.ts @@ -1,12 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { SignatureAuthGuard } from './signature.auth'; -import { verifySignature } from '../utils/signature'; import { ChainId, EscrowUtils } from '@human-protocol/sdk'; -import { AuthSignatureRole } from '../enums/role'; -import { HEADER_SIGNATURE_KEY } from '../constant'; -import { AssignmentRepository } from '../../modules/assignment/assignment.repository'; +import { ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AssignmentRepository } from '../../modules/assignment/assignment.repository'; +import { HEADER_SIGNATURE_KEY } from '../constant'; +import { AuthSignatureRole } from '../enums/role'; +import { AuthError, NotFoundError } from '../errors'; +import { verifySignature } from '../utils/signature'; +import { SignatureAuthGuard } from './signature.auth'; jest.mock('../utils/signature'); jest.mock('@human-protocol/sdk', () => ({ @@ -84,7 +85,7 @@ describe('SignatureAuthGuard', () => { ); }); - it('should throw UnauthorizedException if signature is not verified', async () => { + it('should throw AuthError if signature is not verified', async () => { reflector.get = jest .fn() .mockReturnValue([AuthSignatureRole.JobLauncher]); @@ -96,9 +97,7 @@ describe('SignatureAuthGuard', () => { }; (verifySignature as jest.Mock).mockReturnValue(false); - await expect(guard.canActivate(context)).rejects.toThrow( - UnauthorizedException, - ); + await expect(guard.canActivate(context)).rejects.toThrow(AuthError); }); it('should handle Worker role and verify signature', async () => { @@ -118,7 +117,7 @@ describe('SignatureAuthGuard', () => { expect(assignmentRepository.findOneById).toHaveBeenCalledWith('1'); }); - it('should throw UnauthorizedException if assignment is not found for Worker role', async () => { + it('should throw AuthError if assignment is not found for Worker role', async () => { reflector.get = jest.fn().mockReturnValue([AuthSignatureRole.Worker]); mockRequest.headers[HEADER_SIGNATURE_KEY] = 'validSignature'; @@ -128,9 +127,7 @@ describe('SignatureAuthGuard', () => { (verifySignature as jest.Mock).mockReturnValue(true); assignmentRepository.findOneById.mockResolvedValue(null); - await expect(guard.canActivate(context)).rejects.toThrow( - UnauthorizedException, - ); + await expect(guard.canActivate(context)).rejects.toThrow(NotFoundError); }); it('should handle multiple roles and verify signature', async () => { diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.ts b/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.ts index 320fe38e59..97782a8d59 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/guards/signature.auth.ts @@ -1,20 +1,21 @@ +import { EscrowUtils } from '@human-protocol/sdk'; import { CanActivate, ExecutionContext, Injectable, - NotImplementedException, - UnauthorizedException, + Logger, } from '@nestjs/common'; -import { verifySignature } from '../utils/signature'; -import { HEADER_SIGNATURE_KEY } from '../constant'; -import { EscrowUtils } from '@human-protocol/sdk'; -import { AuthSignatureRole } from '../enums/role'; import { Reflector } from '@nestjs/core'; import { AssignmentRepository } from '../../modules/assignment/assignment.repository'; +import { HEADER_SIGNATURE_KEY } from '../constant'; import { ErrorAssignment, ErrorSignature } from '../constant/errors'; +import { AuthSignatureRole } from '../enums/role'; +import { AuthError, NotFoundError } from '../errors'; +import { verifySignature } from '../utils/signature'; @Injectable() export class SignatureAuthGuard implements CanActivate { + private readonly logger = new Logger(SignatureAuthGuard.name); constructor( private reflector: Reflector, private readonly assignmentRepository: AssignmentRepository, @@ -25,7 +26,7 @@ export class SignatureAuthGuard implements CanActivate { 'roles', context.getHandler(), ); - if (!roles) throw new NotImplementedException(ErrorSignature.MissingRoles); + if (!roles) throw new Error(ErrorSignature.MissingRoles); const request = context.switchToHttp().getRequest(); const data = request.body; const signature = request.headers[HEADER_SIGNATURE_KEY]; @@ -38,7 +39,7 @@ export class SignatureAuthGuard implements CanActivate { if (assignment) { oracleAdresses.push(assignment.workerAddress); } else { - throw new UnauthorizedException(ErrorAssignment.NotFound); + throw new NotFoundError(ErrorAssignment.NotFound); } } else { const escrowData = await EscrowUtils.getEscrow( @@ -64,9 +65,9 @@ export class SignatureAuthGuard implements CanActivate { return true; } } catch (error) { - console.error(error); + this.logger.error(error); } - throw new UnauthorizedException('Unauthorized'); + throw new AuthError('Unauthorized'); } } diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/guards/strategy/jwt.http.ts b/packages/apps/fortune/exchange-oracle/server/src/common/guards/strategy/jwt.http.ts index 5bd6bce9f1..b7df3e905d 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/guards/strategy/jwt.http.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/guards/strategy/jwt.http.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - Injectable, - Req, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable, Req } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; @@ -15,6 +10,7 @@ import { JWT_KVSTORE_KEY, KYC_APPROVED } from '../../../common/constant'; import { Role } from '../../../common/enums/role'; import { JwtUser } from '../../../common/types/jwt'; import { Web3Service } from '../../../modules/web3/web3.service'; +import { AuthError, ValidationError } from '../../errors'; @Injectable() export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { @@ -62,32 +58,30 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { }, ): Promise { if (!payload.email) { - throw new UnauthorizedException('Invalid token: missing email'); + throw new AuthError('Invalid token: missing email'); } if (!payload.role) { - throw new UnauthorizedException('Invalid token: missing role'); + throw new AuthError('Invalid token: missing role'); } if (!Object.values(Role).includes(payload.role as Role)) { - throw new UnauthorizedException( - `Invalid token: unrecognized role "${payload.role}"`, - ); + throw new AuthError(`Invalid token: unrecognized role "${payload.role}"`); } const role: Role = payload.role as Role; if (role !== Role.HumanApp) { if (!payload.kyc_status) { - throw new UnauthorizedException('Invalid token: missing KYC status'); + throw new AuthError('Invalid token: missing KYC status'); } if (!payload.wallet_address) { - throw new UnauthorizedException('Invalid token: missing address'); + throw new AuthError('Invalid token: missing address'); } if (payload.kyc_status !== KYC_APPROVED) { - throw new UnauthorizedException( + throw new AuthError( `Invalid token: expected KYC status "${KYC_APPROVED}", but received "${payload.kyc_status}"`, ); } @@ -110,7 +104,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { urlKey = 'url', ): Promise { if (!ethers.isAddress(address)) { - throw new BadRequestException('Invalid address'); + throw new ValidationError('Invalid address'); } const hashKey = urlKey + '_hash'; @@ -125,9 +119,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { try { url = await contract.get(address, urlKey); } catch (e) { - if (e instanceof Error) { - throw new Error(`Failed to get URL: ${e.message}`); - } + throw new Error(`Failed to get URL: ${e.message}`); } if (!url?.length) { @@ -137,9 +129,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { try { hash = await contract.get(address, hashKey); } catch (e) { - if (e instanceof Error) { - throw new Error(`Failed to get Hash: ${e.message}`); - } + throw new Error(`Failed to get Hash: ${e.message}`); } const content = await fetch(url).then((res) => res.text()); @@ -149,7 +139,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { const formattedContentHash = contentHash?.replace(/^0x/, ''); if (formattedHash !== formattedContentHash) { - throw new BadRequestException('Invalid hash'); + throw new ValidationError('Invalid hash'); } return url; diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/interceptors/transform-enum.interceptor.ts b/packages/apps/fortune/exchange-oracle/server/src/common/interceptors/transform-enum.interceptor.ts index d24d146f34..5f0948bc5b 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/interceptors/transform-enum.interceptor.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/interceptors/transform-enum.interceptor.ts @@ -1,15 +1,15 @@ import { + CallHandler, + ExecutionContext, Injectable, NestInterceptor, - ExecutionContext, - CallHandler, - BadRequestException, } from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { plainToInstance, ClassConstructor } from 'class-transformer'; +import { ClassConstructor, plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import 'reflect-metadata'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ValidationError } from '../errors'; @Injectable() export class TransformEnumInterceptor implements NestInterceptor { @@ -72,7 +72,7 @@ export class TransformEnumInterceptor implements NestInterceptor { // Validate the transformed data const validationErrors = validateSync(transformedInstance); if (validationErrors.length > 0) { - throw new BadRequestException('Validation failed'); + throw new ValidationError('Validation failed'); } return bodyOrQuery; diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/pipes/index.ts b/packages/apps/fortune/exchange-oracle/server/src/common/pipes/index.ts new file mode 100644 index 0000000000..4d5ffa36ab --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/common/pipes/index.ts @@ -0,0 +1 @@ +export * from './validation'; diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/pipes/validation.ts b/packages/apps/fortune/exchange-oracle/server/src/common/pipes/validation.ts new file mode 100644 index 0000000000..fd42f291f5 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/common/pipes/validation.ts @@ -0,0 +1,22 @@ +import { + Injectable, + ValidationError as ValidError, + ValidationPipe, + ValidationPipeOptions, +} from '@nestjs/common'; +import { ValidationError } from '../errors'; + +@Injectable() +export class HttpValidationPipe extends ValidationPipe { + constructor(options?: ValidationPipeOptions) { + super({ + exceptionFactory: (errors: ValidError[]): ValidationError => { + const flattenErrors = this.flattenValidationErrors(errors); + throw new ValidationError(flattenErrors.join(', ')); + }, + transform: true, + whitelist: true, + ...options, + }); + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/database/database.utils.ts b/packages/apps/fortune/exchange-oracle/server/src/common/utils/database.ts similarity index 73% rename from packages/apps/fortune/exchange-oracle/server/src/database/database.utils.ts rename to packages/apps/fortune/exchange-oracle/server/src/common/utils/database.ts index 4e1d75bf9d..127207e0c1 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/database/database.utils.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/utils/database.ts @@ -1,5 +1,5 @@ -import { SortDirection } from '../common/enums/collection'; -import { SortDirectionDb } from './database.enum'; +import { SortDirection } from '../enums/collection'; +import { SortDirectionDb } from '../enums/database'; export function convertToDatabaseSortDirection( value?: SortDirection, diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/utils/http.ts b/packages/apps/fortune/exchange-oracle/server/src/common/utils/http.ts new file mode 100644 index 0000000000..6c4fb2b45b --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/common/utils/http.ts @@ -0,0 +1,10 @@ +import { AxiosError } from 'axios'; + +export function formatAxiosError(error: AxiosError) { + return { + name: error.name, + stack: error.stack, + cause: error.cause, + message: error.message, + }; +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/utils/signature.ts b/packages/apps/fortune/exchange-oracle/server/src/common/utils/signature.ts index 20cc25b726..d5e78a413d 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/utils/signature.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/utils/signature.ts @@ -1,5 +1,5 @@ -import { ConflictException } from '@nestjs/common'; import { ethers } from 'ethers'; +import { ValidationError } from '../errors'; export function verifySignature( message: object | string, @@ -11,7 +11,7 @@ export function verifySignature( if ( !addresses.some((address) => address.toLowerCase() === signer.toLowerCase()) ) { - throw new ConflictException('Signature not verified'); + throw new ValidationError('Signature not verified'); } return true; @@ -42,6 +42,6 @@ export function recoverSigner( try { return ethers.verifyMessage(message, signature); } catch (e) { - throw new ConflictException('Invalid signature'); + throw new ValidationError('Invalid signature'); } } diff --git a/packages/apps/fortune/exchange-oracle/server/src/database/base.repository.ts b/packages/apps/fortune/exchange-oracle/server/src/database/base.repository.ts index b00937d7e2..d07285a84e 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/database/base.repository.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/database/base.repository.ts @@ -1,3 +1,4 @@ +import { handleQueryFailedError } from '../common/errors'; import { DataSource, EntityTarget, @@ -5,7 +6,6 @@ import { QueryFailedError, Repository, } from 'typeorm'; -import { handleQueryFailedError } from './database.error'; export class BaseRepository extends Repository { constructor(target: EntityTarget, dataSource: DataSource) { diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts index 509a13d96e..1375292250 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.dto.ts @@ -1,7 +1,13 @@ import { ChainId } from '@human-protocol/sdk'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsNumber, IsOptional, IsString, IsDate } from 'class-validator'; +import { + IsNumber, + IsOptional, + IsString, + IsDate, + IsEthereumAddress, +} from 'class-validator'; import { AssignmentSortField, AssignmentStatus, @@ -20,7 +26,7 @@ export class CreateAssignmentDto { chainId: ChainId; @ApiProperty({ name: 'escrow_address' }) - @IsString() + @IsEthereumAddress() escrowAddress: string; } @@ -47,7 +53,7 @@ export class GetAssignmentsDto extends PageOptionsDto { @ApiPropertyOptional({ name: 'escrow_address' }) @IsOptional() - @IsString() + @IsEthereumAddress() escrowAddress?: string; @ApiPropertyOptional({ enum: AssignmentStatus }) diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts index b1998877bb..2dd6e86d7e 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.repository.ts @@ -7,7 +7,7 @@ import { AssignmentStatus } from '../../common/enums/job'; import { ChainId } from '@human-protocol/sdk'; import { AssignmentFilterData, ListResult } from './assignment.interface'; import { AssignmentSortField } from '../../common/enums/job'; -import { convertToDatabaseSortDirection } from '../../database/database.utils'; +import { convertToDatabaseSortDirection } from '../../common/utils/database'; @Injectable() export class AssignmentRepository extends BaseRepository { diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts index ccb12c602f..55cffad2b7 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts @@ -1,7 +1,18 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Escrow__factory } from '@human-protocol/core/typechain-types'; +import { Injectable, Logger } from '@nestjs/common'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { ErrorAssignment, ErrorJob } from '../../common/constant/errors'; import { AssignmentStatus, JobStatus, JobType } from '../../common/enums/job'; +import { + ConflictError, + ServerError, + ValidationError, +} from '../../common/errors'; +import { PageDto } from '../../common/pagination/pagination.dto'; import { JwtUser } from '../../common/types/jwt'; import { JobRepository } from '../job/job.repository'; +import { JobService } from '../job/job.service'; +import { Web3Service } from '../web3/web3.service'; import { AssignmentDto, CreateAssignmentDto, @@ -9,12 +20,6 @@ import { } from './assignment.dto'; import { AssignmentEntity } from './assignment.entity'; import { AssignmentRepository } from './assignment.repository'; -import { PageDto } from '../../common/pagination/pagination.dto'; -import { JobService } from '../job/job.service'; -import { Escrow__factory } from '@human-protocol/core/typechain-types'; -import { Web3Service } from '../web3/web3.service'; -import { ErrorAssignment, ErrorJob } from '../../common/constant/errors'; -import { ServerConfigService } from '../../common/config/server-config.service'; @Injectable() export class AssignmentService { @@ -39,16 +44,16 @@ export class AssignmentService { if (!jobEntity) { this.logger.log(ErrorAssignment.JobNotFound, AssignmentService.name); - throw new BadRequestException(ErrorAssignment.JobNotFound); + throw new ServerError(ErrorAssignment.JobNotFound); } else if (jobEntity.status !== JobStatus.ACTIVE) { this.logger.log(ErrorJob.InvalidStatus, AssignmentService.name); - throw new BadRequestException(ErrorJob.InvalidStatus); + throw new ConflictError(ErrorJob.InvalidStatus); } else if (jobEntity.reputationNetwork !== jwtUser.reputationNetwork) { this.logger.log( ErrorAssignment.ReputationNetworkMismatch, AssignmentService.name, ); - throw new BadRequestException(ErrorAssignment.ReputationNetworkMismatch); + throw new ValidationError(ErrorAssignment.ReputationNetworkMismatch); } const assignmentEntity = @@ -62,7 +67,7 @@ export class AssignmentService { assignmentEntity.status !== AssignmentStatus.CANCELED ) { this.logger.log(ErrorAssignment.AlreadyExists, AssignmentService.name); - throw new BadRequestException(ErrorAssignment.AlreadyExists); + throw new ConflictError(ErrorAssignment.AlreadyExists); } const currentAssignments = await this.assignmentRepository.countByJobId( @@ -81,14 +86,12 @@ export class AssignmentService { (qualification) => !userQualificationsSet.has(qualification), ); if (missingQualifications && missingQualifications.length > 0) { - throw new BadRequestException( - ErrorAssignment.InvalidAssignmentQualification, - ); + throw new ValidationError(ErrorAssignment.InvalidAssignmentQualification); } if (currentAssignments >= manifest.submissionsRequired) { this.logger.log(ErrorAssignment.FullyAssigned, AssignmentService.name); - throw new BadRequestException(ErrorAssignment.FullyAssigned); + throw new ValidationError(ErrorAssignment.FullyAssigned); } const signer = this.web3Service.getSigner(data.chainId); @@ -96,7 +99,7 @@ export class AssignmentService { const expirationDate = new Date(Number(await escrow.duration()) * 1000); if (expirationDate < new Date()) { this.logger.log(ErrorAssignment.ExpiredEscrow, AssignmentService.name); - throw new BadRequestException(ErrorAssignment.ExpiredEscrow); + throw new ValidationError(ErrorAssignment.ExpiredEscrow); } // Allow reassignation when status is Canceled @@ -161,14 +164,14 @@ export class AssignmentService { await this.assignmentRepository.findOneById(assignmentId); if (!assignment) { - throw new BadRequestException(ErrorAssignment.NotFound); + throw new ServerError(ErrorAssignment.NotFound); } if (assignment.workerAddress !== workerAddress) { - throw new BadRequestException(ErrorAssignment.InvalidAssignment); + throw new ConflictError(ErrorAssignment.InvalidAssignment); } if (assignment.status !== AssignmentStatus.ACTIVE) { - throw new BadRequestException(ErrorAssignment.InvalidStatus); + throw new ConflictError(ErrorAssignment.InvalidStatus); } assignment.status = AssignmentStatus.CANCELED; diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts index de012ed314..9b1d31631b 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/cron-job/cron-job.service.ts @@ -1,5 +1,6 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; import { ErrorCronJob } from '../../common/constant/errors'; import { CronJobType } from '../../common/enums/cron-job'; import { WebhookStatus } from '../../common/enums/webhook'; @@ -7,7 +8,6 @@ import { WebhookRepository } from '../webhook/webhook.repository'; import { WebhookService } from '../webhook/webhook.service'; import { CronJobEntity } from './cron-job.entity'; import { CronJobRepository } from './cron-job.repository'; -import { Cron } from '@nestjs/schedule'; @Injectable() export class CronJobService { @@ -48,7 +48,7 @@ export class CronJobService { ): Promise { if (cronJobEntity.completedAt) { this.logger.error(ErrorCronJob.Completed, CronJobService.name); - throw new BadRequestException(ErrorCronJob.Completed); + throw new Error(ErrorCronJob.Completed); } cronJobEntity.completedAt = new Date(); diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.dto.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.dto.ts index 75dc79097f..106c506564 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.dto.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, IsNumber, IsDate } from 'class-validator'; +import { + IsOptional, + IsString, + IsNumber, + IsDate, + IsEthereumAddress, +} from 'class-validator'; import { Type } from 'class-transformer'; import { JobFieldName, @@ -56,7 +62,7 @@ export class GetJobsDto extends PageOptionsDto { @ApiPropertyOptional({ name: 'escrow_address' }) @IsOptional() - @IsString() + @IsEthereumAddress() escrowAddress: string; @ApiPropertyOptional({ enum: JobStatus }) diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts index 67899e2d94..a953af967c 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.repository.ts @@ -6,7 +6,7 @@ import { BaseRepository } from '../../database/base.repository'; import { JobEntity } from './job.entity'; import { JobSortField, JobStatus } from '../../common/enums/job'; import { JobFilterData, ListResult } from './job.interface'; -import { convertToDatabaseSortDirection } from '../../database/database.utils'; +import { convertToDatabaseSortDirection } from '../../common/utils/database'; @Injectable() export class JobRepository extends BaseRepository { diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts index caf475da50..ff17236276 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts @@ -1,3 +1,7 @@ +import { + HMToken, + HMToken__factory, +} from '@human-protocol/core/typechain-types'; import { ChainId, Encryption, @@ -5,17 +9,10 @@ import { EscrowClient, StorageClient, } from '@human-protocol/sdk'; -import { - HMToken, - HMToken__factory, -} from '@human-protocol/core/typechain-types'; -import { - BadRequestException, - Inject, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { PGPConfigService } from '../../common/config/pgp-config.service'; +import { ErrorAssignment, ErrorJob } from '../../common/constant/errors'; +import { SortDirection } from '../../common/enums/collection'; import { AssignmentStatus, JobFieldName, @@ -24,8 +21,16 @@ import { JobType, } from '../../common/enums/job'; import { EventType } from '../../common/enums/webhook'; +import { + ConflictError, + NotFoundError, + ServerError, + ValidationError, +} from '../../common/errors'; import { ISolution } from '../../common/interfaces/job'; import { PageDto } from '../../common/pagination/pagination.dto'; +import { AssignmentEntity } from '../assignment/assignment.entity'; +import { AssignmentRepository } from '../assignment/assignment.repository'; import { StorageService } from '../storage/storage.service'; import { Web3Service } from '../web3/web3.service'; import { RejectionEventData, WebhookDto } from '../webhook/webhook.dto'; @@ -34,11 +39,6 @@ import { WebhookRepository } from '../webhook/webhook.repository'; import { GetJobsDto, JobDto, ManifestDto } from './job.dto'; import { JobEntity } from './job.entity'; import { JobRepository } from './job.repository'; -import { AssignmentRepository } from '../assignment/assignment.repository'; -import { PGPConfigService } from '../../common/config/pgp-config.service'; -import { ErrorJob, ErrorAssignment } from '../../common/constant/errors'; -import { SortDirection } from '../../common/enums/collection'; -import { AssignmentEntity } from '../assignment/assignment.entity'; @Injectable() export class JobService { @@ -64,7 +64,7 @@ export class JobService { if (jobEntity) { this.logger.log(ErrorJob.AlreadyExists, JobService.name); - throw new BadRequestException(ErrorJob.AlreadyExists); + throw new ConflictError(ErrorJob.AlreadyExists); } const signer = this.web3Service.getSigner(chainId); @@ -98,11 +98,11 @@ export class JobService { ); if (!jobEntity) { - throw new NotFoundException(ErrorJob.NotFound); + throw new ServerError(ErrorJob.NotFound); } if (jobEntity.status === JobStatus.COMPLETED) { - throw new BadRequestException(ErrorJob.AlreadyCompleted); + throw new ConflictError(ErrorJob.AlreadyCompleted); } jobEntity.status = JobStatus.COMPLETED; @@ -123,11 +123,11 @@ export class JobService { ); if (!jobEntity) { - throw new NotFoundException(ErrorJob.NotFound); + throw new ServerError(ErrorJob.NotFound); } if (jobEntity.status === JobStatus.CANCELED) { - throw new BadRequestException(ErrorJob.AlreadyCanceled); + throw new ConflictError(ErrorJob.AlreadyCanceled); } jobEntity.status = JobStatus.CANCELED; @@ -220,13 +220,13 @@ export class JobService { const assignment = await this.assignmentRepository.findOneById(assignmentId); if (!assignment) { - throw new BadRequestException(ErrorAssignment.NotFound); + throw new ServerError(ErrorAssignment.NotFound); } if (assignment.status !== AssignmentStatus.ACTIVE) { - throw new BadRequestException(ErrorAssignment.InvalidStatus); + throw new ConflictError(ErrorAssignment.InvalidStatus); } else if (assignment.job.status !== JobStatus.ACTIVE) { - throw new BadRequestException(ErrorJob.InvalidStatus); + throw new ConflictError(ErrorJob.InvalidStatus); } await this.addSolution( @@ -277,7 +277,7 @@ export class JobService { this.assignmentRepository.updateOne(assignment); } } else { - throw new BadRequestException( + throw new ServerError( `Solution not found in Escrow: ${invalidJobSolution.escrowAddress}`, ); } @@ -308,7 +308,7 @@ export class JobService { (solution) => solution.workerAddress === workerAddress, ) ) { - throw new BadRequestException(ErrorJob.SolutionAlreadySubmitted); + throw new ValidationError(ErrorJob.SolutionAlreadySubmitted); } const manifest = await this.getManifest( @@ -320,7 +320,7 @@ export class JobService { existingJobSolutions.filter((solution) => !solution.error).length >= manifest.submissionsRequired ) { - throw new BadRequestException(ErrorJob.JobCompleted); + throw new ConflictError(ErrorJob.JobCompleted); } const newJobSolutions: ISolution[] = [ @@ -379,7 +379,7 @@ export class JobService { webhook.failureDetail = ErrorJob.ManifestNotFound; await this.webhookRepository.createUnique(webhook); - throw new NotFoundException(ErrorJob.ManifestNotFound); + throw new NotFoundError(ErrorJob.ManifestNotFound); } return manifest; @@ -391,10 +391,10 @@ export class JobService { webhook.escrowAddress, ); if (!jobEntity) { - throw new NotFoundException(ErrorJob.NotFound); + throw new ServerError(ErrorJob.NotFound); } if (jobEntity.status !== JobStatus.ACTIVE) { - throw new BadRequestException(ErrorJob.InvalidStatus); + throw new ConflictError(ErrorJob.InvalidStatus); } jobEntity.status = JobStatus.PAUSED; await this.jobRepository.updateOne(jobEntity); @@ -406,10 +406,10 @@ export class JobService { webhook.escrowAddress, ); if (!jobEntity) { - throw new NotFoundException(ErrorJob.NotFound); + throw new ServerError(ErrorJob.NotFound); } if (jobEntity.status !== JobStatus.PAUSED) { - throw new BadRequestException(ErrorJob.InvalidStatus); + throw new ConflictError(ErrorJob.InvalidStatus); } jobEntity.status = JobStatus.ACTIVE; await this.jobRepository.updateOne(jobEntity); diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/storage/storage.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/storage/storage.service.ts index 3a39e6afb5..b18126bdf9 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/storage/storage.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/storage/storage.service.ts @@ -3,20 +3,16 @@ import { Encryption, EncryptionUtils, EscrowClient, - StorageClient, KVStoreUtils, + StorageClient, } from '@human-protocol/sdk'; -import { - BadRequestException, - Inject, - Injectable, - Logger, -} from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import * as Minio from 'minio'; +import { PGPConfigService } from '../../common/config/pgp-config.service'; +import { S3ConfigService } from '../../common/config/s3-config.service'; +import { NotFoundError, ServerError } from '../../common/errors'; import { ISolution } from '../../common/interfaces/job'; import { Web3Service } from '../web3/web3.service'; -import { S3ConfigService } from '../../common/config/s3-config.service'; -import { PGPConfigService } from '../../common/config/pgp-config.service'; @Injectable() export class StorageService { @@ -75,7 +71,7 @@ export class StorageService { solutions: ISolution[], ): Promise { if (!(await this.minioClient.bucketExists(this.s3ConfigService.bucket))) { - throw new BadRequestException('Bucket not found'); + throw new NotFoundError('Bucket not found'); } let fileToUpload = JSON.stringify(solutions); @@ -98,7 +94,7 @@ export class StorageService { !exchangeOraclePublickKey.length || !recordingOraclePublicKey.length ) { - throw new BadRequestException('Missing public key'); + throw new ServerError('Missing public key'); } fileToUpload = await EncryptionUtils.encrypt(fileToUpload, [ @@ -107,7 +103,7 @@ export class StorageService { ]); } catch (e) { Logger.error(e); - throw new BadRequestException('Encryption error'); + throw new ServerError('Encryption error'); } } @@ -125,7 +121,7 @@ export class StorageService { return this.getJobUrl(escrowAddress, chainId); } catch (e) { Logger.error(e); - throw new BadRequestException('File not uploaded'); + throw new ServerError('File not uploaded'); } } } diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts index 7fffd318ef..f049f1e76d 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts @@ -4,7 +4,7 @@ import { HttpService } from '@nestjs/axios'; import { HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { JOB_LAUNCHER_WEBHOOK_URL, MOCK_ADDRESS, @@ -12,6 +12,10 @@ import { MOCK_RECORDING_ORACLE_WEBHOOK_URL, mockConfig, } from '../../../test/constants'; +import { PGPConfigService } from '../../common/config/pgp-config.service'; +import { S3ConfigService } from '../../common/config/s3-config.service'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; import { HEADER_SIGNATURE_KEY } from '../../common/constant'; import { ErrorWebhook } from '../../common/constant/errors'; import { EventType, WebhookStatus } from '../../common/enums/webhook'; @@ -24,10 +28,6 @@ import { WebhookDto } from './webhook.dto'; import { WebhookEntity } from './webhook.entity'; import { WebhookRepository } from './webhook.repository'; import { WebhookService } from './webhook.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { PGPConfigService } from '../../common/config/pgp-config.service'; -import { S3ConfigService } from '../../common/config/s3-config.service'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -223,13 +223,11 @@ describe('WebhookService', () => { .spyOn(webhookService as any, 'getOracleWebhookUrl') .mockResolvedValue(MOCK_RECORDING_ORACLE_WEBHOOK_URL); jest.spyOn(httpService as any, 'post').mockImplementation(() => { - return of({ - data: undefined, - }); + return throwError(() => new Error('HTTP request failed')); }); await expect( (webhookService as any).sendWebhook(webhookEntity), - ).rejects.toThrowError(ErrorWebhook.NotSent); + ).rejects.toThrowError('HTTP request failed'); }); it('should successfully process a webhook with signature', async () => { diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts index 86b2381607..32fa800d0c 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts @@ -1,27 +1,23 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { - BadRequestException, - HttpStatus, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; +import { ChainId, EscrowClient, OperatorUtils } from '@human-protocol/sdk'; +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { HEADER_SIGNATURE_KEY } from '../../common/constant'; +import { ErrorWebhook } from '../../common/constant/errors'; import { EventType, WebhookStatus } from '../../common/enums/webhook'; -import { WebhookDto } from './webhook.dto'; -import { JobService } from '../job/job.service'; -import { WebhookEntity } from './webhook.entity'; +import { ValidationError } from '../../common/errors'; import { CaseConverter } from '../../common/utils/case-converter'; +import { formatAxiosError } from '../../common/utils/http'; import { signMessage } from '../../common/utils/signature'; -import { HEADER_SIGNATURE_KEY } from '../../common/constant'; -import { firstValueFrom } from 'rxjs'; -import { HttpService } from '@nestjs/axios'; -import { ErrorWebhook } from '../../common/constant/errors'; -import { WebhookRepository } from './webhook.repository'; -import { ChainId, EscrowClient, OperatorUtils } from '@human-protocol/sdk'; -import { Web3Service } from '../web3/web3.service'; +import { JobService } from '../job/job.service'; import { StorageService } from '../storage/storage.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ServerConfigService } from '../../common/config/server-config.service'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookDto } from './webhook.dto'; +import { WebhookEntity } from './webhook.entity'; +import { WebhookRepository } from './webhook.repository'; @Injectable() export class WebhookService { @@ -64,7 +60,7 @@ export class WebhookService { break; default: - throw new BadRequestException( + throw new ValidationError( `Invalid webhook event type: ${webhook.eventType}`, ); } @@ -87,8 +83,7 @@ export class WebhookService { // Check if the webhook URL was found. if (!webhookUrl) { - this.logger.log(ErrorWebhook.UrlNotFound, WebhookService.name); - throw new NotFoundException(ErrorWebhook.UrlNotFound); + throw new Error(ErrorWebhook.UrlNotFound); } // Build the webhook data object based on the oracle type. @@ -122,14 +117,16 @@ export class WebhookService { }; // Make the HTTP request to the webhook. - const { status } = await firstValueFrom( - this.httpService.post(webhookUrl, transformedWebhook, config), - ); - - // Check if the request was successful. - if (status !== HttpStatus.CREATED) { - this.logger.log(ErrorWebhook.NotSent, WebhookService.name); - throw new NotFoundException(ErrorWebhook.NotSent); + try { + await firstValueFrom( + this.httpService.post(webhookUrl, transformedWebhook, config), + ); + } catch (error) { + const formattedError = formatAxiosError(error); + this.logger.error('Webhook not sent', { + error: formattedError, + }); + throw new Error(formattedError.message); } } @@ -175,7 +172,7 @@ export class WebhookService { await escrowClient.getRecordingOracleAddress(escrowAddress); break; default: - throw new BadRequestException('Invalid outgoing event type'); + throw new ValidationError('Invalid outgoing event type'); } const oracle = await OperatorUtils.getOperator(chainId, oracleAddress); const oracleWebhookUrl = oracle.webhookUrl; diff --git a/packages/apps/fortune/recording-oracle/src/app.module.ts b/packages/apps/fortune/recording-oracle/src/app.module.ts index 5f0836f482..9a352672b2 100644 --- a/packages/apps/fortune/recording-oracle/src/app.module.ts +++ b/packages/apps/fortune/recording-oracle/src/app.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { HttpValidationPipe } from './common/pipes'; import { JobModule } from './modules/job/job.module'; @@ -10,6 +10,7 @@ import { WebhookModule } from './modules/webhook/webhook.module'; import { envValidator } from './common/config/env-schema'; import { EnvConfigModule } from './common/config/config.module'; import { TransformEnumInterceptor } from './common/interceptors/transform-enum.interceptor'; +import { ExceptionFilter } from './common/exceptions/exception.filter'; @Module({ providers: [ @@ -17,6 +18,10 @@ import { TransformEnumInterceptor } from './common/interceptors/transform-enum.i provide: APP_PIPE, useClass: HttpValidationPipe, }, + { + provide: APP_FILTER, + useClass: ExceptionFilter, + }, { provide: APP_INTERCEPTOR, useClass: SnakeCaseInterceptor, diff --git a/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts b/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts index 62fca7d022..37ada53a31 100644 --- a/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts +++ b/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts @@ -9,7 +9,6 @@ export enum ErrorJob { NotFoundIntermediateResultsUrl = 'Error while getting intermediate results url from escrow contract', SolutionAlreadyExists = 'Solution already exists', AllSolutionsHaveAlreadyBeenSent = 'All solutions have already been sent', - WebhookWasNotSent = 'Webhook was not sent', ManifestNotFound = 'Manifest not found', } diff --git a/packages/apps/fortune/recording-oracle/src/common/errors/index.ts b/packages/apps/fortune/recording-oracle/src/common/errors/index.ts new file mode 100644 index 0000000000..2eaeb72c4f --- /dev/null +++ b/packages/apps/fortune/recording-oracle/src/common/errors/index.ts @@ -0,0 +1,45 @@ +export class BaseError extends Error { + constructor(message: string, stack?: string) { + super(message); + this.name = this.constructor.name; + if (stack) { + this.stack = stack; + } + } +} + +export class ValidationError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class AuthError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ForbiddenError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class NotFoundError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ConflictError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ServerError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} diff --git a/packages/apps/fortune/recording-oracle/src/common/exceptions/exception.filter.ts b/packages/apps/fortune/recording-oracle/src/common/exceptions/exception.filter.ts new file mode 100644 index 0000000000..655aca6ac3 --- /dev/null +++ b/packages/apps/fortune/recording-oracle/src/common/exceptions/exception.filter.ts @@ -0,0 +1,60 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter as IExceptionFilter, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { + ValidationError, + AuthError, + ForbiddenError, + NotFoundError, + ConflictError, + ServerError, +} from '../errors'; + +@Catch() +export class ExceptionFilter implements IExceptionFilter { + private logger = new Logger(ExceptionFilter.name); + private getStatus(exception: any): number { + if (exception instanceof ValidationError) { + return HttpStatus.BAD_REQUEST; + } else if (exception instanceof AuthError) { + return HttpStatus.UNAUTHORIZED; + } else if (exception instanceof ForbiddenError) { + return HttpStatus.FORBIDDEN; + } else if (exception instanceof NotFoundError) { + return HttpStatus.NOT_FOUND; + } else if (exception instanceof ConflictError) { + return HttpStatus.CONFLICT; + } else if (exception instanceof ServerError) { + return HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception.statusCode) { + return exception.statusCode; + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } + + catch(exception: any, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = this.getStatus(exception); + const message = exception.message || 'Internal server error'; + + this.logger.error( + `Exception caught: ${message}`, + exception.stack || 'No stack trace available', + ); + + response.status(status).json({ + status_code: status, + timestamp: new Date().toISOString(), + message: message, + path: request.url, + }); + } +} diff --git a/packages/apps/fortune/recording-oracle/src/common/filter/global-exceptions.filter.ts b/packages/apps/fortune/recording-oracle/src/common/filter/global-exceptions.filter.ts deleted file mode 100644 index 57509972db..0000000000 --- a/packages/apps/fortune/recording-oracle/src/common/filter/global-exceptions.filter.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpException, - HttpStatus, -} from '@nestjs/common'; - -@Catch() -export class GlobalExceptionsFilter implements ExceptionFilter { - catch(exception: unknown, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - - response.status(status).json({ - statusCode: status, - message: - exception instanceof HttpException - ? exception.getResponse() - : exception instanceof Error - ? exception.message - : 'Internal Server Error', - }); - } -} diff --git a/packages/apps/fortune/recording-oracle/src/common/filter/index.ts b/packages/apps/fortune/recording-oracle/src/common/filter/index.ts deleted file mode 100644 index fa1fe5651b..0000000000 --- a/packages/apps/fortune/recording-oracle/src/common/filter/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './global-exceptions.filter'; diff --git a/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.spec.ts b/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.spec.ts index 3693649275..5e6a91d92b 100644 --- a/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.spec.ts +++ b/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.spec.ts @@ -1,11 +1,12 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { SignatureAuthGuard } from './signature.auth'; -import { verifySignature } from '../utils/signature'; import { ChainId, EscrowUtils } from '@human-protocol/sdk'; +import { ExecutionContext } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; import { MOCK_ADDRESS } from '../../../test/constants'; -import { Role } from '../enums/role'; import { HEADER_SIGNATURE_KEY } from '../constants'; +import { Role } from '../enums/role'; +import { AuthError } from '../errors'; +import { verifySignature } from '../utils/signature'; +import { SignatureAuthGuard } from './signature.auth'; jest.mock('../../common/utils/signature'); @@ -77,18 +78,18 @@ describe('SignatureAuthGuard', () => { ); }); - it('should throw unauthorized exception if signature is not verified', async () => { + it('should throw AuthError if signature is not verified', async () => { (verifySignature as jest.Mock).mockReturnValue(false); await expect(guard.canActivate(context as any)).rejects.toThrow( - UnauthorizedException, + AuthError, ); }); - it('should throw unauthorized exception for unrecognized oracle type', async () => { + it('should throw AuthError for unrecognized oracle type', async () => { mockRequest.originalUrl = '/some/random/path'; await expect(guard.canActivate(context as any)).rejects.toThrow( - UnauthorizedException, + AuthError, ); }); }); diff --git a/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.ts b/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.ts index 7bdc9a6625..f49be08e83 100644 --- a/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.ts +++ b/packages/apps/fortune/recording-oracle/src/common/guards/signature.auth.ts @@ -1,13 +1,9 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; -import { verifySignature } from '../utils/signature'; -import { HEADER_SIGNATURE_KEY } from '../constants'; import { EscrowUtils } from '@human-protocol/sdk'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { HEADER_SIGNATURE_KEY } from '../constants'; import { Role } from '../enums/role'; +import { AuthError, ValidationError } from '../errors'; +import { verifySignature } from '../utils/signature'; @Injectable() export class SignatureAuthGuard implements CanActivate { @@ -42,11 +38,10 @@ export class SignatureAuthGuard implements CanActivate { if (isVerified) { return true; } - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + } catch (error: any) { + throw new ValidationError(error.message); } - throw new UnauthorizedException('Unauthorized'); + throw new AuthError('Unauthorized'); } } diff --git a/packages/apps/fortune/recording-oracle/src/common/interceptors/transform-enum.interceptor.ts b/packages/apps/fortune/recording-oracle/src/common/interceptors/transform-enum.interceptor.ts index 020899bc21..f6976d210b 100644 --- a/packages/apps/fortune/recording-oracle/src/common/interceptors/transform-enum.interceptor.ts +++ b/packages/apps/fortune/recording-oracle/src/common/interceptors/transform-enum.interceptor.ts @@ -1,15 +1,15 @@ import { + CallHandler, + ExecutionContext, Injectable, NestInterceptor, - ExecutionContext, - CallHandler, - BadRequestException, } from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { plainToInstance, ClassConstructor } from 'class-transformer'; +import { ClassConstructor, plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import 'reflect-metadata'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ValidationError } from '../errors'; @Injectable() export class TransformEnumInterceptor implements NestInterceptor { @@ -72,7 +72,7 @@ export class TransformEnumInterceptor implements NestInterceptor { // Validate the transformed data const validationErrors = validateSync(transformedInstance); if (validationErrors.length > 0) { - throw new BadRequestException('Validation failed'); + throw new ValidationError('Validation failed'); } return bodyOrQuery; diff --git a/packages/apps/fortune/recording-oracle/src/common/pipes/validation.ts b/packages/apps/fortune/recording-oracle/src/common/pipes/validation.ts index f46e742df2..9654e9fd60 100644 --- a/packages/apps/fortune/recording-oracle/src/common/pipes/validation.ts +++ b/packages/apps/fortune/recording-oracle/src/common/pipes/validation.ts @@ -1,17 +1,19 @@ import { - BadRequestException, Injectable, - ValidationError, + ValidationError as ValidError, ValidationPipe, ValidationPipeOptions, } from '@nestjs/common'; +import { ValidationError } from '../errors'; @Injectable() export class HttpValidationPipe extends ValidationPipe { constructor(options?: ValidationPipeOptions) { super({ - exceptionFactory: (errors: ValidationError[]): BadRequestException => - new BadRequestException(errors), + exceptionFactory: (errors: ValidError[]): ValidationError => { + const flattenErrors = this.flattenValidationErrors(errors); + throw new ValidationError(flattenErrors.join(', ')); + }, transform: true, whitelist: true, forbidNonWhitelisted: true, diff --git a/packages/apps/fortune/recording-oracle/src/common/utils/http.ts b/packages/apps/fortune/recording-oracle/src/common/utils/http.ts new file mode 100644 index 0000000000..6c4fb2b45b --- /dev/null +++ b/packages/apps/fortune/recording-oracle/src/common/utils/http.ts @@ -0,0 +1,10 @@ +import { AxiosError } from 'axios'; + +export function formatAxiosError(error: AxiosError) { + return { + name: error.name, + stack: error.stack, + cause: error.cause, + message: error.message, + }; +} diff --git a/packages/apps/fortune/recording-oracle/src/common/utils/signature.ts b/packages/apps/fortune/recording-oracle/src/common/utils/signature.ts index 20cc25b726..d5e78a413d 100644 --- a/packages/apps/fortune/recording-oracle/src/common/utils/signature.ts +++ b/packages/apps/fortune/recording-oracle/src/common/utils/signature.ts @@ -1,5 +1,5 @@ -import { ConflictException } from '@nestjs/common'; import { ethers } from 'ethers'; +import { ValidationError } from '../errors'; export function verifySignature( message: object | string, @@ -11,7 +11,7 @@ export function verifySignature( if ( !addresses.some((address) => address.toLowerCase() === signer.toLowerCase()) ) { - throw new ConflictException('Signature not verified'); + throw new ValidationError('Signature not verified'); } return true; @@ -42,6 +42,6 @@ export function recoverSigner( try { return ethers.verifyMessage(message, signature); } catch (e) { - throw new ConflictException('Invalid signature'); + throw new ValidationError('Invalid signature'); } } diff --git a/packages/apps/fortune/recording-oracle/src/common/utils/webhook.ts b/packages/apps/fortune/recording-oracle/src/common/utils/webhook.ts index 49172fb718..f176df23e9 100644 --- a/packages/apps/fortune/recording-oracle/src/common/utils/webhook.ts +++ b/packages/apps/fortune/recording-oracle/src/common/utils/webhook.ts @@ -1,11 +1,11 @@ -import { HttpStatus, Logger, NotFoundException } from '@nestjs/common'; -import { firstValueFrom } from 'rxjs'; import { HttpService } from '@nestjs/axios'; -import { ErrorJob } from '../constants/errors'; -import { signMessage } from './signature'; +import { Logger } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { WebhookDto } from '../../modules/webhook/webhook.dto'; import { HEADER_SIGNATURE_KEY } from '../constants'; import { CaseConverter } from './case-converter'; -import { WebhookDto } from '../../modules/webhook/webhook.dto'; +import { signMessage } from './signature'; +import { formatAxiosError } from './http'; export async function sendWebhook( httpService: HttpService, @@ -16,15 +16,18 @@ export async function sendWebhook( ): Promise { const snake_case_body = CaseConverter.transformToSnakeCase(webhookBody); const signedBody = await signMessage(snake_case_body, privateKey); - const { status } = await firstValueFrom( - await httpService.post(webhookUrl, snake_case_body, { - headers: { [HEADER_SIGNATURE_KEY]: signedBody }, - }), - ); - - if (status !== HttpStatus.CREATED) { - logger.log(ErrorJob.WebhookWasNotSent, 'JobService'); - throw new NotFoundException(ErrorJob.WebhookWasNotSent); + try { + await firstValueFrom( + httpService.post(webhookUrl, snake_case_body, { + headers: { [HEADER_SIGNATURE_KEY]: signedBody }, + }), + ); + } catch (error: any) { + const formattedError = formatAxiosError(error); + logger.error('Webhook not sent', { + error: formattedError, + }); + throw new Error(formattedError.message); } return true; diff --git a/packages/apps/fortune/recording-oracle/src/common/validators/ethers.spec.ts b/packages/apps/fortune/recording-oracle/src/common/validators/ethers.spec.ts deleted file mode 100644 index 3fb1762b40..0000000000 --- a/packages/apps/fortune/recording-oracle/src/common/validators/ethers.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Validator } from 'class-validator'; -import { IsValidEthereumAddress } from './ethers'; - -const validator = new Validator(); - -describe('IsValidEthereumAddress', () => { - class MyClass { - @IsValidEthereumAddress() - someString: string; - } - - it('should be valid', () => { - const obj = new MyClass(); - obj.someString = '0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc'; - return validator.validate(obj).then((errors) => { - expect(errors.length).toBe(0); - }); - }); - - it('should be invalid', () => { - const obj = new MyClass(); - obj.someString = '0x9965507D1a55bcC2695C58ba16FB37d819B0A4dd'; - return validator.validate(obj).then((errors) => { - expect(errors.length).toBe(1); - }); - }); -}); diff --git a/packages/apps/fortune/recording-oracle/src/common/validators/ethers.ts b/packages/apps/fortune/recording-oracle/src/common/validators/ethers.ts deleted file mode 100644 index 55bc380197..0000000000 --- a/packages/apps/fortune/recording-oracle/src/common/validators/ethers.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - registerDecorator, - ValidationOptions, - ValidatorConstraint, - ValidatorConstraintInterface, -} from 'class-validator'; -import { ethers } from 'ethers'; - -@ValidatorConstraint({ name: 'IsValidEthereumAddress' }) -@Injectable() -class ValidateEthereumAddress implements ValidatorConstraintInterface { - public validate(value: string): boolean { - return ethers.isAddress(value); - } - - public defaultMessage(): string { - return 'Invalid Ethereum address'; - } -} - -export function IsValidEthereumAddress(validationOptions?: ValidationOptions) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (object: any, propertyName: string): void => { - registerDecorator({ - name: 'IsValidEthereumAddress', - target: object.constructor, - propertyName, - options: validationOptions, - validator: ValidateEthereumAddress, - }); - }; -} diff --git a/packages/apps/fortune/recording-oracle/src/common/validators/index.ts b/packages/apps/fortune/recording-oracle/src/common/validators/index.ts deleted file mode 100644 index 01dda0ca1a..0000000000 --- a/packages/apps/fortune/recording-oracle/src/common/validators/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ethers'; diff --git a/packages/apps/fortune/recording-oracle/src/main.ts b/packages/apps/fortune/recording-oracle/src/main.ts index 369cf35591..991069853a 100644 --- a/packages/apps/fortune/recording-oracle/src/main.ts +++ b/packages/apps/fortune/recording-oracle/src/main.ts @@ -6,7 +6,6 @@ import helmet from 'helmet'; import { INestApplication } from '@nestjs/common'; import { AppModule } from './app.module'; -import { GlobalExceptionsFilter } from './common/filter'; import { ServerConfigService } from './common/config/server-config.service'; import { ConfigService } from '@nestjs/config'; @@ -21,8 +20,6 @@ async function bootstrap() { const host = serverConfigService.host; const port = serverConfigService.port; - app.useGlobalFilters(new GlobalExceptionsFilter()); - app.enableCors({ origin: true, credentials: true, diff --git a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts index 2d816ad895..8baeb91adb 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts @@ -10,7 +10,7 @@ import { import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { MOCK_ADDRESS, MOCK_EXCHANGE_ORACLE_WEBHOOK_URL, @@ -346,9 +346,9 @@ describe('JobService', () => { .mockResolvedValueOnce(JSON.stringify(existingJobSolutions)) .mockResolvedValue(JSON.stringify(exchangeJobSolutions)); - httpServicePostMock.mockRejectedValueOnce( - new Error(ErrorJob.WebhookWasNotSent), - ); + httpServicePostMock.mockImplementationOnce(() => { + return throwError(() => new Error('HTTP request failed')); + }); KVStoreUtils.get = jest .fn() .mockResolvedValueOnce(MOCK_REPUTATION_ORACLE_WEBHOOK_URL); @@ -360,9 +360,9 @@ describe('JobService', () => { eventData: { solutionsUrl: MOCK_FILE_URL }, }; - await expect( - jobService.processJobSolution(newSolution), - ).rejects.toThrowError(ErrorJob.WebhookWasNotSent); + await expect(jobService.processJobSolution(newSolution)).rejects.toThrow( + 'HTTP request failed', + ); }); it('should return solution are recorded when one solution is sent', async () => { diff --git a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts index bcbc737d58..10191d7f2b 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts @@ -5,28 +5,24 @@ import { KVStoreUtils, } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; -import { - BadRequestException, - Inject, - Injectable, - Logger, -} from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { ethers } from 'ethers'; import * as Minio from 'minio'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ErrorJob } from '../../common/constants/errors'; import { JobRequestType, SolutionError } from '../../common/enums/job'; +import { EventType } from '../../common/enums/webhook'; +import { ConflictError, ValidationError } from '../../common/errors'; import { IManifest, ISolution } from '../../common/interfaces/job'; import { checkCurseWords } from '../../common/utils/curseWords'; import { sendWebhook } from '../../common/utils/webhook'; import { StorageService } from '../storage/storage.service'; import { Web3Service } from '../web3/web3.service'; -import { EventType } from '../../common/enums/webhook'; import { AssignmentRejection, SolutionEventData, WebhookDto, } from '../webhook/webhook.dto'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; @Injectable() export class JobService { @@ -100,7 +96,7 @@ export class JobService { ethers.getAddress(recordingOracleAddress) !== (await signer.getAddress()) ) { this.logger.log(ErrorJob.AddressMismatches, JobService.name); - throw new BadRequestException(ErrorJob.AddressMismatches); + throw new ValidationError(ErrorJob.AddressMismatches); } const escrowStatus = await escrowClient.getStatus(webhook.escrowAddress); @@ -109,7 +105,7 @@ export class JobService { escrowStatus !== EscrowStatus.Partial ) { this.logger.log(ErrorJob.InvalidStatus, JobService.name); - throw new BadRequestException(ErrorJob.InvalidStatus); + throw new ConflictError(ErrorJob.InvalidStatus); } const manifestUrl = await escrowClient.getManifestUrl( @@ -120,12 +116,12 @@ export class JobService { if (!submissionsRequired || !requestType) { this.logger.log(ErrorJob.InvalidManifest, JobService.name); - throw new BadRequestException(ErrorJob.InvalidManifest); + throw new ValidationError(ErrorJob.InvalidManifest); } if (requestType !== JobRequestType.FORTUNE) { this.logger.log(ErrorJob.InvalidJobType, JobService.name); - throw new BadRequestException(ErrorJob.InvalidJobType); + throw new ValidationError(ErrorJob.InvalidJobType); } const existingJobSolutionsURL = @@ -143,7 +139,7 @@ export class JobService { ErrorJob.AllSolutionsHaveAlreadyBeenSent, JobService.name, ); - throw new BadRequestException(ErrorJob.AllSolutionsHaveAlreadyBeenSent); + throw new ConflictError(ErrorJob.AllSolutionsHaveAlreadyBeenSent); } const exchangeJobSolutions: ISolution[] = diff --git a/packages/apps/fortune/recording-oracle/src/modules/storage/storage.service.ts b/packages/apps/fortune/recording-oracle/src/modules/storage/storage.service.ts index 6c7bcdfa5c..5afbcb5779 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/storage/storage.service.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/storage/storage.service.ts @@ -3,17 +3,18 @@ import { Encryption, EncryptionUtils, EscrowClient, - StorageClient, KVStoreUtils, + StorageClient, } from '@human-protocol/sdk'; -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import crypto from 'crypto'; import * as Minio from 'minio'; +import { PGPConfigService } from '../../common/config/pgp-config.service'; +import { S3ConfigService } from '../../common/config/s3-config.service'; +import { ServerError, ValidationError } from '../../common/errors'; import { ISolution } from '../../common/interfaces/job'; -import crypto from 'crypto'; import { SaveSolutionsDto } from '../job/job.dto'; import { Web3Service } from '../web3/web3.service'; -import { S3ConfigService } from '../../common/config/s3-config.service'; -import { PGPConfigService } from '../../common/config/pgp-config.service'; @Injectable() export class StorageService { @@ -49,6 +50,7 @@ export class StorageService { ) { try { const encryption = await Encryption.build( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.pgpConfigService.privateKey!, this.pgpConfigService.passphrase, ); @@ -56,7 +58,7 @@ export class StorageService { const decryptedData = await encryption.decrypt(fileContent); return JSON.parse(Buffer.from(decryptedData).toString()); } catch { - throw new Error('Unable to decrypt manifest'); + throw new ServerError('Unable to decrypt manifest'); } } else { try { @@ -78,7 +80,7 @@ export class StorageService { solutions: ISolution[], ): Promise { if (!(await this.minioClient.bucketExists(this.s3ConfigService.bucket))) { - throw new BadRequestException('Bucket not found'); + throw new ValidationError('Bucket not found'); } let fileToUpload = JSON.stringify(solutions); @@ -98,14 +100,12 @@ export class StorageService { reputationOracleAddress, ); if ( + !recordingOraclePublicKey || !recordingOraclePublicKey.length || + !reputationOraclePublicKey || !reputationOraclePublicKey.length ) { - throw new BadRequestException('Missing public key'); - } - - if (!recordingOraclePublicKey || !reputationOraclePublicKey) { - throw new Error(); + throw new ServerError('Missing public key'); } fileToUpload = await EncryptionUtils.encrypt(fileToUpload, [ @@ -113,7 +113,7 @@ export class StorageService { reputationOraclePublicKey, ]); } catch (e) { - throw new BadRequestException('Encryption error'); + throw new ServerError('Encryption error'); } } @@ -131,7 +131,7 @@ export class StorageService { return { url: this.getJobUrl(hash), hash }; } catch (e) { - throw new BadRequestException('File not uploaded'); + throw new ServerError('File not uploaded'); } } } diff --git a/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.dto.ts b/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.dto.ts index 2342333b9c..19188f1e3b 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.dto.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.dto.ts @@ -1,7 +1,12 @@ import { ChainId } from '@human-protocol/sdk'; import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'; -import { IsValidEthereumAddress } from '../../common/validators'; +import { + IsArray, + IsEthereumAddress, + IsObject, + IsOptional, + IsString, +} from 'class-validator'; import { EventType } from '../../common/enums/webhook'; import { IsEnumCaseInsensitive } from '@/common/decorators'; @@ -40,8 +45,7 @@ export class WebhookDto { public chainId: ChainId; @ApiProperty({ name: 'escrow_address' }) - @IsString() - @IsValidEthereumAddress() + @IsEthereumAddress() public escrowAddress: string; @ApiProperty({ diff --git a/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.service.ts b/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.service.ts index 2bc9a185cc..7c8a72dce2 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.service.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.service.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { EventType } from '../../common/enums/webhook'; -import { WebhookDto } from './webhook.dto'; +import { ValidationError } from '../../common/errors'; import { JobService } from '../job/job.service'; +import { WebhookDto } from './webhook.dto'; @Injectable() export class WebhookService { @@ -18,7 +19,7 @@ export class WebhookService { break; default: - throw new BadRequestException( + throw new ValidationError( `Invalid webhook event type: ${wehbook.eventType}`, ); } diff --git a/packages/apps/human-app/server/.env.example b/packages/apps/human-app/server/.env.example index cc6083d8eb..3e2e15827b 100644 --- a/packages/apps/human-app/server/.env.example +++ b/packages/apps/human-app/server/.env.example @@ -11,8 +11,7 @@ HCAPTCHA_LABELING_VERIFY_API_URL= # string HCAPTCHA_LABELING_API_KEY= # string IS_AXIOS_REQUEST_LOGGING_ENABLED= #string, true if enabled, disabled otherwise ALLOWED_HOST= #string, example 'localhost:3000' -HUMAN_APP_EMAIL= # string -HUMAN_APP_PASSWORD= # string +HUMAN_APP_SECRET_KEY= # string # CACHE TTL VALUES - in seconds CACHE_TTL_ORACLE_DISCOVERY= # number, example: 43200 CACHE_TTL_ORACLE_STATS= # number, example: 900 diff --git a/packages/apps/human-app/server/ENV.md b/packages/apps/human-app/server/ENV.md index d6a4dbc63a..ae4a763e38 100644 --- a/packages/apps/human-app/server/ENV.md +++ b/packages/apps/human-app/server/ENV.md @@ -84,11 +84,8 @@ CHAIN_IDS_ENABLED="false" ### The email address for the human app. Required IS_CACHE_TO_RESTART= -### The password for the human app. Required -HUMAN_APP_EMAIL= - -### The maximum number of iteration to skip. Default: 5 -HUMAN_APP_PASSWORD="5" +### The secret key for Reputation Oracle. Required +HUMAN_APP_SECRET_KEY= ### Feature flag for job discovery MAX_EXECUTIONS_TO_SKIP= diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index d75fea0237..f6f3414bc2 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -79,8 +79,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); return value; }) .required(), - HUMAN_APP_EMAIL: Joi.string().email().required(), - HUMAN_APP_PASSWORD: Joi.string().required(), + HUMAN_APP_SECRET_KEY: Joi.string().required(), IS_AXIOS_REQUEST_LOGGING_ENABLED: JOI_BOOLEAN_STRING_SCHEMA, ALLOWED_HOST: Joi.string().required(), CORS_ENABLED: JOI_BOOLEAN_STRING_SCHEMA, diff --git a/packages/apps/human-app/server/src/common/config/environment-config.service.ts b/packages/apps/human-app/server/src/common/config/environment-config.service.ts index fd7b513dce..af1cd562f6 100644 --- a/packages/apps/human-app/server/src/common/config/environment-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/environment-config.service.ts @@ -303,19 +303,12 @@ export class EnvironmentConfigService { } /** - * The email address for the human app. + * Secret key for machine-to-machine authorization + * of HUMAN App in Reputation Oracle * Required */ - get email(): string { - return this.configService.getOrThrow('HUMAN_APP_EMAIL'); - } - - /** - * The password for the human app. - * Required - */ - get password(): string { - return this.configService.getOrThrow('HUMAN_APP_PASSWORD'); + get m2mAuthSecretKey(): string { + return this.configService.getOrThrow('HUMAN_APP_SECRET_KEY'); } /** diff --git a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts index f499dbb562..89a86627f7 100644 --- a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts @@ -41,6 +41,11 @@ export class GatewayConfigService { method: HttpMethod.POST, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.M2M_SIGNIN]: { + endpoint: '/auth/m2m/signin', + method: HttpMethod.POST, + headers: this.JSON_HEADER, + }, [ReputationOracleEndpoints.REGISTRATION_IN_EXCHANGE_ORACLE]: { endpoint: '/user/exchange-oracle-registration', method: HttpMethod.POST, diff --git a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts index 1bd52fba79..bedd90d15d 100644 --- a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts +++ b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts @@ -3,6 +3,7 @@ export enum ReputationOracleEndpoints { WORKER_SIGNIN = 'worker_signin', OPERATOR_SIGNUP = 'operator_signup', OPERATOR_SIGNIN = 'operator_signin', + M2M_SIGNIN = 'm2m_signin', EMAIL_VERIFICATION = 'email_verification', RESEND_EMAIL_VERIFICATION = 'resend_email_verification', FORGOT_PASSWORD = 'forgot_password', diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts index bc5b586913..6fcb8fe0ab 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts @@ -197,6 +197,19 @@ export class ReputationOracleGateway { return this.handleRequestToReputationOracle(options); } + async sendM2mSignin(secretKey: string) { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.M2M_SIGNIN, + ); + options.headers = { + ...options.headers, + 'human-m2m-auth-key': secretKey, + }; + return this.handleRequestToReputationOracle<{ access_token: string }>( + options, + ); + } + async sendRegistrationInExchangeOracle( command: RegistrationInExchangeOracleCommand, ) { diff --git a/packages/apps/human-app/server/src/modules/cron-job/cron-job.module.ts b/packages/apps/human-app/server/src/modules/cron-job/cron-job.module.ts index 74bd8b7277..7d2c9679ed 100644 --- a/packages/apps/human-app/server/src/modules/cron-job/cron-job.module.ts +++ b/packages/apps/human-app/server/src/modules/cron-job/cron-job.module.ts @@ -1,18 +1,18 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; import { ExchangeOracleModule } from '../../integrations/exchange-oracle/exchange-oracle.module'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; import { CronJobService } from './cron-job.service'; import { OracleDiscoveryModule } from '../oracle-discovery/oracle-discovery.module'; -import { WorkerModule } from '../user-worker/worker.module'; import { JobsDiscoveryModule } from '../jobs-discovery/jobs-discovery.module'; @Module({ imports: [ ScheduleModule.forRoot(), ExchangeOracleModule, + ReputationOracleModule, OracleDiscoveryModule, JobsDiscoveryModule, - WorkerModule, ], providers: [CronJobService], exports: [CronJobService], diff --git a/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts index 5487014efb..089324b618 100644 --- a/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { CronJob } from 'cron'; import { ExchangeOracleGateway } from '../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; import { DiscoveredJob, JobsDiscoveryParams, @@ -10,7 +11,6 @@ import { import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { OracleDiscoveryService } from '../oracle-discovery/oracle-discovery.service'; import { DiscoveredOracle } from '../oracle-discovery/model/oracle-discovery.model'; -import { WorkerService } from '../user-worker/worker.service'; import { JobDiscoveryFieldName, JobStatus, @@ -43,12 +43,12 @@ function assertJobsDiscoveryResponseItemsFormat( export class CronJobService { private readonly logger = new Logger(CronJobService.name); constructor( + private readonly reputationOracleGateway: ReputationOracleGateway, private readonly exchangeOracleGateway: ExchangeOracleGateway, - private configService: EnvironmentConfigService, - private oracleDiscoveryService: OracleDiscoveryService, - private jobsDiscoveryService: JobsDiscoveryService, - private workerService: WorkerService, - private schedulerRegistry: SchedulerRegistry, + private readonly configService: EnvironmentConfigService, + private readonly oracleDiscoveryService: OracleDiscoveryService, + private readonly jobsDiscoveryService: JobsDiscoveryService, + private readonly schedulerRegistry: SchedulerRegistry, ) { if (this.configService.jobsDiscoveryFlag) { this.initializeCronJob(); @@ -74,10 +74,9 @@ export class CronJobService { } try { - const response = await this.workerService.signinWorker({ - email: this.configService.email, - password: this.configService.password, - }); + const response = await this.reputationOracleGateway.sendM2mSignin( + this.configService.m2mAuthSecretKey, + ); for (const oracle of oracles) { if (oracle.executionsToSkip > 0) { diff --git a/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts b/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts index 06e86f90f9..eca3cb0c17 100644 --- a/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts @@ -3,8 +3,8 @@ import { SchedulerRegistry } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; import { CronJobService } from '../cron-job.service'; import { ExchangeOracleGateway } from '../../../integrations/exchange-oracle/exchange-oracle.gateway'; +import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; import { OracleDiscoveryService } from '../../../modules/oracle-discovery/oracle-discovery.service'; -import { WorkerService } from '../../../modules/user-worker/worker.service'; import { EnvironmentConfigService } from '../../../common/config/environment-config.service'; import { DiscoveredJob, @@ -31,7 +31,7 @@ describe('CronJobService', () => { let exchangeOracleGatewayMock: Partial; let oracleDiscoveryServiceMock: Partial; let jobDiscoveryServiceMock: Partial; - let workerServiceMock: Partial; + let reputationOracleGatewayMock: Partial; let configServiceMock: Partial; beforeEach(async () => { @@ -48,13 +48,12 @@ describe('CronJobService', () => { setCachedJobs: jest.fn(), }; - workerServiceMock = { - signinWorker: jest.fn(), + reputationOracleGatewayMock = { + sendM2mSignin: jest.fn(), }; configServiceMock = { - email: 'human-app@hmt.ai', - password: 'Test1234*', + m2mAuthSecretKey: 'sk_test_e7ODIlpLSKNlNV8nRK_2rqLsGu_ft-84C7c-dJzJ3kU', cacheTtlOracleDiscovery: 600, chainIdsEnabled: [ChainId.POLYGON, ChainId.MAINNET], jobsDiscoveryFlag: false, @@ -73,7 +72,10 @@ describe('CronJobService', () => { provide: JobsDiscoveryService, useValue: jobDiscoveryServiceMock, }, - { provide: WorkerService, useValue: workerServiceMock }, + { + provide: ReputationOracleGateway, + useValue: reputationOracleGatewayMock, + }, { provide: EnvironmentConfigService, useValue: configServiceMock }, SchedulerRegistry, ], @@ -99,11 +101,11 @@ describe('CronJobService', () => { (configServiceMock as any).jobsDiscoveryFlag = true; service = new CronJobService( + reputationOracleGatewayMock as ReputationOracleGateway, exchangeOracleGatewayMock as ExchangeOracleGateway, configServiceMock as any, oracleDiscoveryServiceMock as OracleDiscoveryService, jobDiscoveryServiceMock as JobsDiscoveryService, - workerServiceMock as WorkerService, schedulerRegistryMock, ); @@ -114,11 +116,11 @@ describe('CronJobService', () => { (configServiceMock as any).jobsDiscoveryFlag = false; service = new CronJobService( + reputationOracleGatewayMock as ReputationOracleGateway, exchangeOracleGatewayMock as ExchangeOracleGateway, configServiceMock as any, oracleDiscoveryServiceMock as OracleDiscoveryService, jobDiscoveryServiceMock as JobsDiscoveryService, - workerServiceMock as WorkerService, schedulerRegistryMock, ); @@ -135,7 +137,7 @@ describe('CronJobService', () => { await service.updateJobsListCron(); expect(oracleDiscoveryServiceMock.discoverOracles).toHaveBeenCalledWith(); - expect(workerServiceMock.signinWorker).not.toHaveBeenCalled(); + expect(reputationOracleGatewayMock.sendM2mSignin).not.toHaveBeenCalled(); }); it('should proceed with valid oracles and update jobs list cache', async () => { @@ -143,7 +145,9 @@ describe('CronJobService', () => { ( oracleDiscoveryServiceMock.discoverOracles as jest.Mock ).mockResolvedValue(oraclesDiscovery); - (workerServiceMock.signinWorker as jest.Mock).mockResolvedValue({ + ( + reputationOracleGatewayMock.sendM2mSignin as jest.Mock + ).mockResolvedValue({ access_token: 'token', }); @@ -154,10 +158,9 @@ describe('CronJobService', () => { await service.updateJobsListCron(); expect(oracleDiscoveryServiceMock.discoverOracles).toHaveBeenCalledWith(); - expect(workerServiceMock.signinWorker).toHaveBeenCalledWith({ - email: configServiceMock.email, - password: configServiceMock.password, - }); + expect(reputationOracleGatewayMock.sendM2mSignin).toHaveBeenCalledWith( + configServiceMock.m2mAuthSecretKey, + ); expect(updateJobsListCacheSpy).toHaveBeenCalledWith( oraclesDiscovery[0], 'Bearer token', diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts index 6e3b8b6b35..87a0ddfc7e 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts @@ -22,8 +22,6 @@ describe('JobsDiscoveryController', () => { let controller: JobsDiscoveryController; let jobsDiscoveryService: JobsDiscoveryService; const configServiceMock: Partial = { - email: 'human-app@hmt.ai', - password: 'Test1234*', cacheTtlOracleDiscovery: 600, chainIdsEnabled: [ChainId.POLYGON, ChainId.MAINNET], jobsDiscoveryFlag: true, diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.controller.spec.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.controller.spec.ts index 31a08a42e5..58df22e460 100644 --- a/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/spec/oracle-discovery.controller.spec.ts @@ -20,8 +20,6 @@ describe('OracleDiscoveryController', () => { let controller: OracleDiscoveryController; let serviceMock: OracleDiscoveryService; const configServiceMock: Partial = { - email: 'human-app@hmt.ai', - password: 'Test1234*', cacheTtlOracleDiscovery: 600, chainIdsEnabled: [ChainId.POLYGON, ChainId.MAINNET], jobsDiscoveryFlag: true, diff --git a/packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx b/packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx index 2255f2c6e3..fe301cb1b8 100644 --- a/packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx +++ b/packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx @@ -43,7 +43,7 @@ const DeleteCardModal = ({ onSuccess(); } catch (error: any) { if ( - error.response?.status === 400 && + error.response?.status === 409 && error.response?.data?.message === 'Cannot delete the default payment method in use' ) { diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 68ce51748f..e7b9330205 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -72,6 +72,7 @@ export enum ErrorEscrow { */ export enum ErrorUser { NotFound = 'User not found.', + InvalidStatus = 'User has an invalid status.', AccountCannotBeRegistered = 'Account cannot be registered.', InvalidCredentials = 'Invalid credentials.', UserNotActive = 'User not active.', @@ -216,3 +217,13 @@ export enum ErrorQualification { export enum ErrorEncryption { MissingPrivateKey = 'Encryption private key cannot be empty, when it is enabled', } + +/** + * Represents error messages associated to storage. + */ +export enum ErrorStorage { + FailedToDownload = 'Failed to download file', + NotFound = 'File not found', + InvalidUrl = 'Invalid file URL', + FileNotUploaded = 'File not uploaded', +} diff --git a/packages/apps/job-launcher/server/src/common/errors/base.ts b/packages/apps/job-launcher/server/src/common/errors/base.ts deleted file mode 100644 index dfc528a73b..0000000000 --- a/packages/apps/job-launcher/server/src/common/errors/base.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class BaseError extends Error { - constructor(message: string, cause?: unknown) { - const errorOptions: ErrorOptions = {}; - if (cause) { - errorOptions.cause = cause; - } - - super(message, errorOptions); - this.name = this.constructor.name; - } -} diff --git a/packages/apps/job-launcher/server/src/common/errors/controlled.ts b/packages/apps/job-launcher/server/src/common/errors/controlled.ts deleted file mode 100644 index 32cc401c90..0000000000 --- a/packages/apps/job-launcher/server/src/common/errors/controlled.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HttpStatus } from '@nestjs/common'; - -export class ControlledError extends Error { - status: HttpStatus; - - constructor(message: string, status: HttpStatus, stack?: string) { - super(message); - this.name = this.constructor.name; - this.status = status; - if (stack) this.stack = stack; - else Error.captureStackTrace(this, this.constructor); - } -} diff --git a/packages/apps/job-launcher/server/src/common/errors/database.ts b/packages/apps/job-launcher/server/src/common/errors/database.ts index c369f38558..de5201b9e1 100644 --- a/packages/apps/job-launcher/server/src/common/errors/database.ts +++ b/packages/apps/job-launcher/server/src/common/errors/database.ts @@ -1,13 +1,7 @@ import { QueryFailedError } from 'typeorm'; +import { DatabaseError } from '.'; import { PostgresErrorCodes } from '../enums/database'; -export class DatabaseError extends Error { - constructor(message: string, stack: string) { - super(message); - this.stack = stack; - } -} - export function handleQueryFailedError(error: QueryFailedError): DatabaseError { const stack = error.stack || ''; let message = error.message; diff --git a/packages/apps/job-launcher/server/src/common/errors/index.ts b/packages/apps/job-launcher/server/src/common/errors/index.ts new file mode 100644 index 0000000000..6747ce6861 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/errors/index.ts @@ -0,0 +1,53 @@ +export * from './database'; + +export class BaseError extends Error { + constructor(message: string, stack?: string) { + super(message); + this.name = this.constructor.name; + if (stack) { + this.stack = stack; + } + } +} + +export class ValidationError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class AuthError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ForbiddenError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class NotFoundError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ConflictError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class ServerError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} + +export class DatabaseError extends BaseError { + constructor(message: string, stack?: string) { + super(message, stack); + } +} diff --git a/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts b/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts index da42ac85c2..7ad8be772a 100644 --- a/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts +++ b/packages/apps/job-launcher/server/src/common/exceptions/exception.filter.ts @@ -6,44 +6,53 @@ import { Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; -import { DatabaseError } from '../errors/database'; -import { ControlledError } from '../errors/controlled'; +import { + ValidationError, + AuthError, + ForbiddenError, + NotFoundError, + ConflictError, + ServerError, + DatabaseError, +} from '../errors'; @Catch() export class ExceptionFilter implements IExceptionFilter { private logger = new Logger(ExceptionFilter.name); + private getStatus(exception: any): number { + if (exception instanceof ValidationError) { + return HttpStatus.BAD_REQUEST; + } else if (exception instanceof AuthError) { + return HttpStatus.UNAUTHORIZED; + } else if (exception instanceof ForbiddenError) { + return HttpStatus.FORBIDDEN; + } else if (exception instanceof NotFoundError) { + return HttpStatus.NOT_FOUND; + } else if (exception instanceof ConflictError) { + return HttpStatus.CONFLICT; + } else if (exception instanceof ServerError) { + return HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception instanceof DatabaseError) { + return HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception.statusCode) { + return exception.statusCode; + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } + catch(exception: any, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); - let status = HttpStatus.INTERNAL_SERVER_ERROR; - let message = 'Internal server error'; + const status = this.getStatus(exception); + const message = exception.message || 'Internal server error'; - if (exception instanceof ControlledError) { - status = exception.status; - message = exception.message; - - this.logger.error(`Job Launcher error: ${message}`, exception.stack); - } else if (exception instanceof DatabaseError) { - status = HttpStatus.UNPROCESSABLE_ENTITY; - message = exception.message; - - this.logger.error( - `Database error: ${exception.message}`, - exception.stack, - ); - } else { - if (exception.statusCode === HttpStatus.BAD_REQUEST) { - status = exception.statusCode; - message = exception.message; - } - this.logger.error( - `Unhandled exception: ${exception.message}`, - exception.stack, - ); - } + this.logger.error( + `Exception caught: ${message}`, + exception.stack || 'No stack trace available', + ); response.status(status).json({ statusCode: status, diff --git a/packages/apps/job-launcher/server/src/common/guards/apikey.auth.spec.ts b/packages/apps/job-launcher/server/src/common/guards/apikey.auth.spec.ts index 2e0096d572..24f75dfd1c 100644 --- a/packages/apps/job-launcher/server/src/common/guards/apikey.auth.spec.ts +++ b/packages/apps/job-launcher/server/src/common/guards/apikey.auth.spec.ts @@ -1,9 +1,9 @@ -import { ExecutionContext, HttpStatus } from '@nestjs/common'; +import { ExecutionContext } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ApiKeyGuard } from './apikey.auth'; import { AuthService } from '../../modules/auth/auth.service'; import { UserEntity } from '../../modules/user/user.entity'; -import { ControlledError } from '../errors/controlled'; +import { AuthError } from '../errors'; +import { ApiKeyGuard } from './apikey.auth'; describe('ApiKeyGuard', () => { let guard: ApiKeyGuard; @@ -71,7 +71,7 @@ describe('ApiKeyGuard', () => { .mockResolvedValue(null); await expect(guard.canActivate(context)).rejects.toThrow( - new ControlledError('Unauthorized', HttpStatus.UNAUTHORIZED), + new AuthError('Unauthorized'), ); }); diff --git a/packages/apps/job-launcher/server/src/common/guards/apikey.auth.ts b/packages/apps/job-launcher/server/src/common/guards/apikey.auth.ts index adec8982a3..8ce67f72fd 100644 --- a/packages/apps/job-launcher/server/src/common/guards/apikey.auth.ts +++ b/packages/apps/job-launcher/server/src/common/guards/apikey.auth.ts @@ -1,12 +1,7 @@ -import { - Injectable, - ExecutionContext, - CanActivate, - HttpStatus, -} from '@nestjs/common'; +import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthService } from '../../modules/auth/auth.service'; -import { ControlledError } from '../errors/controlled'; +import { AuthError } from '../errors'; @Injectable() export class ApiKeyGuard implements CanActivate { @@ -35,9 +30,9 @@ export class ApiKeyGuard implements CanActivate { return true; } } else { - throw new ControlledError('Invalid API Key', HttpStatus.UNAUTHORIZED); + throw new AuthError('Invalid API Key'); } } - throw new ControlledError('Unauthorized', HttpStatus.UNAUTHORIZED); + throw new AuthError('Unauthorized'); } } diff --git a/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts b/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts index 00176a1008..e60297c4fa 100644 --- a/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts +++ b/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts @@ -6,8 +6,8 @@ import { } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { AuthError, ForbiddenError } from '../errors'; import { ApiKeyGuard } from './apikey.auth'; -import { ControlledError } from '../errors/controlled'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt-http') implements CanActivate { @@ -33,7 +33,7 @@ export class JwtAuthGuard extends AuthGuard('jwt-http') implements CanActivate { } } - throw new ControlledError('Unauthorized', HttpStatus.UNAUTHORIZED); + throw new AuthError('Unauthorized'); } public async canActivate(context: ExecutionContext): Promise { @@ -58,11 +58,11 @@ export class JwtAuthGuard extends AuthGuard('jwt-http') implements CanActivate { return this.handleApiKeyAuthentication(context); case HttpStatus.FORBIDDEN: if (jwtError?.response?.message === 'Forbidden') { - throw new ControlledError('Forbidden', HttpStatus.FORBIDDEN); + throw new ForbiddenError('Forbidden'); } break; default: - throw new ControlledError('Unauthorized', HttpStatus.UNAUTHORIZED); + throw new AuthError('Unauthorized'); } return false; diff --git a/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts b/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts index e04d70880c..a3eab12d9e 100644 --- a/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts +++ b/packages/apps/job-launcher/server/src/common/guards/signature.auth.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, HttpStatus } from '@nestjs/common'; -import { SignatureAuthGuard } from './signature.auth'; -import { verifySignature } from '../utils/signature'; import { ChainId, EscrowUtils } from '@human-protocol/sdk'; +import { ExecutionContext } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; import { MOCK_ADDRESS } from '../../../test/constants'; import { Role } from '../enums/role'; -import { ControlledError } from '../errors/controlled'; +import { verifySignature } from '../utils/signature'; +import { SignatureAuthGuard } from './signature.auth'; +import { AuthError } from '../errors'; jest.mock('../../common/utils/signature'); @@ -82,14 +82,14 @@ describe('SignatureAuthGuard', () => { (verifySignature as jest.Mock).mockReturnValue(false); await expect(guard.canActivate(context as any)).rejects.toThrow( - new ControlledError('Unauthorized', HttpStatus.UNAUTHORIZED), + new AuthError('Unauthorized'), ); }); it('should throw unauthorized exception for unrecognized oracle type', async () => { mockRequest.originalUrl = '/some/random/path'; await expect(guard.canActivate(context as any)).rejects.toThrow( - new ControlledError('Unauthorized', HttpStatus.UNAUTHORIZED), + new AuthError('Unauthorized'), ); }); }); diff --git a/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts b/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts index 9374641e5b..8b536358f5 100644 --- a/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts +++ b/packages/apps/job-launcher/server/src/common/guards/signature.auth.ts @@ -1,17 +1,19 @@ +import { EscrowUtils } from '@human-protocol/sdk'; import { CanActivate, ExecutionContext, - HttpStatus, Injectable, + Logger, } from '@nestjs/common'; -import { verifySignature } from '../utils/signature'; import { HEADER_SIGNATURE_KEY } from '../constants'; -import { EscrowUtils } from '@human-protocol/sdk'; import { Role } from '../enums/role'; -import { ControlledError } from '../errors/controlled'; +import { AuthError } from '../errors'; +import { verifySignature } from '../utils/signature'; @Injectable() export class SignatureAuthGuard implements CanActivate { + private readonly logger = new Logger(SignatureAuthGuard.name); + constructor(private role: Role[]) {} public async canActivate(context: ExecutionContext): Promise { @@ -47,9 +49,12 @@ export class SignatureAuthGuard implements CanActivate { return true; } } catch (error) { - console.error(error); + this.logger.error( + `Error verifying signature: ${error.message}`, + error.stack, + ); } - throw new ControlledError('Unauthorized', HttpStatus.UNAUTHORIZED); + throw new AuthError('Unauthorized'); } } diff --git a/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.spec.ts b/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.spec.ts index d2f686626f..180886a8a8 100644 --- a/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.spec.ts +++ b/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.spec.ts @@ -1,8 +1,8 @@ -import { ExecutionContext, HttpStatus } from '@nestjs/common'; +import { ExecutionContext } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { WhitelistAuthGuard } from './whitelist.auth'; import { WhitelistService } from '../../modules/whitelist/whitelist.service'; -import { ControlledError } from '../errors/controlled'; +import { AuthError } from '../errors'; +import { WhitelistAuthGuard } from './whitelist.auth'; describe('WhitelistAuthGuard', () => { let guard: WhitelistAuthGuard; @@ -36,9 +36,7 @@ describe('WhitelistAuthGuard', () => { await expect( guard.canActivate(mockContext as ExecutionContext), - ).rejects.toThrow( - new ControlledError('User not found.', HttpStatus.UNAUTHORIZED), - ); + ).rejects.toThrow(new AuthError('User not found.')); }); it('should throw an error if the user is not whitelisted', async () => { @@ -54,9 +52,7 @@ describe('WhitelistAuthGuard', () => { await expect( guard.canActivate(mockContext as ExecutionContext), - ).rejects.toThrow( - new ControlledError('Unauthorized.', HttpStatus.UNAUTHORIZED), - ); + ).rejects.toThrow(new AuthError('Unauthorized.')); }); it('should return true if the user is whitelisted', async () => { diff --git a/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.ts b/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.ts index 58e2ccc3ab..5683a2b81f 100644 --- a/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.ts +++ b/packages/apps/job-launcher/server/src/common/guards/whitelist.auth.ts @@ -1,14 +1,16 @@ import { CanActivate, ExecutionContext, - HttpStatus, Injectable, + Logger, } from '@nestjs/common'; import { WhitelistService } from '../../modules/whitelist/whitelist.service'; -import { ControlledError } from '../errors/controlled'; +import { AuthError } from '../errors'; @Injectable() export class WhitelistAuthGuard implements CanActivate { + private readonly logger = new Logger(WhitelistAuthGuard.name); + constructor(private readonly whitelistService: WhitelistService) {} async canActivate(context: ExecutionContext): Promise { @@ -16,14 +18,15 @@ export class WhitelistAuthGuard implements CanActivate { const user = request.user; if (!user) { - throw new ControlledError('User not found.', HttpStatus.UNAUTHORIZED); + this.logger.error('User object is missing in the request.', request); + throw new AuthError('User not found.'); } const isWhitelisted = await this.whitelistService.isUserWhitelisted( user.id, ); if (!isWhitelisted) { - throw new ControlledError('Unauthorized.', HttpStatus.UNAUTHORIZED); + throw new AuthError('Unauthorized.'); } return true; diff --git a/packages/apps/job-launcher/server/src/common/pipes/validation.ts b/packages/apps/job-launcher/server/src/common/pipes/validation.ts index eec0dea408..9654e9fd60 100644 --- a/packages/apps/job-launcher/server/src/common/pipes/validation.ts +++ b/packages/apps/job-launcher/server/src/common/pipes/validation.ts @@ -1,27 +1,18 @@ import { - HttpStatus, Injectable, - ValidationError, + ValidationError as ValidError, ValidationPipe, ValidationPipeOptions, } from '@nestjs/common'; -import { ControlledError } from '../errors/controlled'; +import { ValidationError } from '../errors'; @Injectable() export class HttpValidationPipe extends ValidationPipe { constructor(options?: ValidationPipeOptions) { super({ - exceptionFactory: (errors: ValidationError[]): ControlledError => { - const errorMessages = errors - .map( - (error) => - Object.values((error as any).constraints) as unknown as string, - ) - .flat(); - throw new ControlledError( - errorMessages.join(', '), - HttpStatus.BAD_REQUEST, - ); + exceptionFactory: (errors: ValidError[]): ValidationError => { + const flattenErrors = this.flattenValidationErrors(errors); + throw new ValidationError(flattenErrors.join(', ')); }, transform: true, whitelist: true, diff --git a/packages/apps/job-launcher/server/src/common/utils/decimal.ts b/packages/apps/job-launcher/server/src/common/utils/decimal.ts index 4bcd4a5a00..a5273f6d6c 100644 --- a/packages/apps/job-launcher/server/src/common/utils/decimal.ts +++ b/packages/apps/job-launcher/server/src/common/utils/decimal.ts @@ -1,6 +1,4 @@ import Decimal from 'decimal.js'; -import { ControlledError } from '../errors/controlled'; -import { HttpStatus } from '@nestjs/common'; export function mul(a: number, b: number): number { const decimalA = new Decimal(a); @@ -16,10 +14,7 @@ export function div(a: number, b: number): number { const decimalB = new Decimal(b); if (decimalB.isZero()) { - throw new ControlledError( - 'Division by zero is not allowed.', - HttpStatus.CONFLICT, - ); + throw new Error('Division by zero is not allowed.'); } const result = decimalA.dividedBy(decimalB); diff --git a/packages/apps/job-launcher/server/src/common/utils/http.ts b/packages/apps/job-launcher/server/src/common/utils/http.ts new file mode 100644 index 0000000000..6c4fb2b45b --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/utils/http.ts @@ -0,0 +1,10 @@ +import { AxiosError } from 'axios'; + +export function formatAxiosError(error: AxiosError) { + return { + name: error.name, + stack: error.stack, + cause: error.cause, + message: error.message, + }; +} diff --git a/packages/apps/job-launcher/server/src/common/utils/index.ts b/packages/apps/job-launcher/server/src/common/utils/index.ts index a8cf2bbd7d..e7b7e7410d 100644 --- a/packages/apps/job-launcher/server/src/common/utils/index.ts +++ b/packages/apps/job-launcher/server/src/common/utils/index.ts @@ -1,7 +1,6 @@ -import { HttpStatus } from '@nestjs/common'; import * as crypto from 'crypto'; import { Readable } from 'stream'; -import { ControlledError } from '../errors/controlled'; +import { ValidationError } from '../errors'; export const parseUrl = ( url: string, @@ -76,7 +75,7 @@ export const parseUrl = ( } } - throw new ControlledError('Invalid URL', HttpStatus.BAD_REQUEST); + throw new ValidationError('Invalid URL'); }; export function hashStream(stream: Readable): Promise { diff --git a/packages/apps/job-launcher/server/src/common/utils/signature.spec.ts b/packages/apps/job-launcher/server/src/common/utils/signature.spec.ts index 2b655775f4..ca5ed5620f 100644 --- a/packages/apps/job-launcher/server/src/common/utils/signature.spec.ts +++ b/packages/apps/job-launcher/server/src/common/utils/signature.spec.ts @@ -1,8 +1,7 @@ -import { verifySignature, recoverSigner, signMessage } from './signature'; import { MOCK_ADDRESS, MOCK_PRIVATE_KEY } from '../../../test/constants'; import { ErrorSignature } from '../constants/errors'; -import { ControlledError } from '../errors/controlled'; -import { HttpStatus } from '@nestjs/common'; +import { ConflictError } from '../errors'; +import { recoverSigner, signMessage, verifySignature } from './signature'; jest.doMock('ethers', () => { return { @@ -12,10 +11,7 @@ jest.doMock('ethers', () => { if (message === 'valid-message' && signature === 'valid-signature') { return 'recovered-address'; } else { - throw new ControlledError( - 'Invalid signature', - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError('Invalid signature'); } }); }, @@ -42,12 +38,7 @@ describe('Signature utility', () => { expect(() => { verifySignature(message, invalidSignature, [invalidAddress]); - }).toThrow( - new ControlledError( - ErrorSignature.SignatureNotVerified, - HttpStatus.CONFLICT, - ), - ); + }).toThrow(new ConflictError(ErrorSignature.SignatureNotVerified)); }); it('should throw conflict exception for invalid signature', () => { @@ -56,12 +47,7 @@ describe('Signature utility', () => { expect(() => { verifySignature(message, invalidSignature, [MOCK_ADDRESS]); - }).toThrow( - new ControlledError( - ErrorSignature.InvalidSignature, - HttpStatus.CONFLICT, - ), - ); + }).toThrow(new ConflictError(ErrorSignature.InvalidSignature)); }); }); @@ -81,12 +67,7 @@ describe('Signature utility', () => { expect(() => { recoverSigner(message, invalidSignature); - }).toThrow( - new ControlledError( - ErrorSignature.InvalidSignature, - HttpStatus.CONFLICT, - ), - ); + }).toThrow(new ConflictError(ErrorSignature.InvalidSignature)); }); it('should stringify message object if it is not already a string', async () => { diff --git a/packages/apps/job-launcher/server/src/common/utils/signature.ts b/packages/apps/job-launcher/server/src/common/utils/signature.ts index 34ebeb8eb9..e4dd1ba52b 100644 --- a/packages/apps/job-launcher/server/src/common/utils/signature.ts +++ b/packages/apps/job-launcher/server/src/common/utils/signature.ts @@ -1,7 +1,6 @@ -import { HttpStatus } from '@nestjs/common'; import { ethers } from 'ethers'; import { ErrorSignature } from '../constants/errors'; -import { ControlledError } from '../errors/controlled'; +import { ValidationError } from '../errors'; export function verifySignature( message: object | string, @@ -13,10 +12,7 @@ export function verifySignature( if ( !addresses.some((address) => address.toLowerCase() === signer.toLowerCase()) ) { - throw new ControlledError( - ErrorSignature.SignatureNotVerified, - HttpStatus.CONFLICT, - ); + throw new ValidationError(ErrorSignature.SignatureNotVerified); } return true; @@ -47,9 +43,6 @@ export function recoverSigner( try { return ethers.verifyMessage(message, signature); } catch (_error) { - throw new ControlledError( - ErrorSignature.InvalidSignature, - HttpStatus.CONFLICT, - ); + throw new ValidationError(ErrorSignature.InvalidSignature); } } diff --git a/packages/apps/job-launcher/server/src/common/utils/storage.ts b/packages/apps/job-launcher/server/src/common/utils/storage.ts index d51cb003d5..d9f897ca24 100644 --- a/packages/apps/job-launcher/server/src/common/utils/storage.ts +++ b/packages/apps/job-launcher/server/src/common/utils/storage.ts @@ -1,14 +1,14 @@ import { HttpStatus } from '@nestjs/common'; +import axios from 'axios'; +import { parseString } from 'xml2js'; import { StorageDataDto } from '../../modules/job/job.dto'; -import { AWSRegions, StorageProviders } from '../enums/storage'; import { ErrorBucket } from '../constants/errors'; import { AudinoJobType, CvatJobType, JobRequestType } from '../enums/job'; -import axios from 'axios'; -import { parseString } from 'xml2js'; -import { ControlledError } from '../errors/controlled'; +import { AWSRegions, StorageProviders } from '../enums/storage'; +import { ValidationError } from '../errors'; import { - GCS_HTTP_REGEX_SUBDOMAIN, GCS_HTTP_REGEX_PATH_BASED, + GCS_HTTP_REGEX_SUBDOMAIN, } from './gcstorage'; export function generateBucketUrl( @@ -30,28 +30,19 @@ export function generateBucketUrl( storageData.provider != StorageProviders.GCS && storageData.provider != StorageProviders.LOCAL ) { - throw new ControlledError( - ErrorBucket.InvalidProvider, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorBucket.InvalidProvider); } if (!storageData.bucketName) { - throw new ControlledError(ErrorBucket.EmptyBucket, HttpStatus.BAD_REQUEST); + throw new ValidationError(ErrorBucket.EmptyBucket); } switch (storageData.provider) { case StorageProviders.AWS: if (!storageData.region) { - throw new ControlledError( - ErrorBucket.EmptyRegion, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorBucket.EmptyRegion); } if (!isRegion(storageData.region)) { - throw new ControlledError( - ErrorBucket.InvalidRegion, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorBucket.InvalidRegion); } return new URL( `https://${storageData.bucketName}.s3.${ @@ -73,10 +64,7 @@ export function generateBucketUrl( }`, ); default: - throw new ControlledError( - ErrorBucket.InvalidProvider, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorBucket.InvalidProvider); } } diff --git a/packages/apps/job-launcher/server/src/database/base.repository.ts b/packages/apps/job-launcher/server/src/database/base.repository.ts index 32cc350cf4..758525d8b4 100644 --- a/packages/apps/job-launcher/server/src/database/base.repository.ts +++ b/packages/apps/job-launcher/server/src/database/base.repository.ts @@ -6,7 +6,7 @@ import { QueryFailedError, Repository, } from 'typeorm'; -import { handleQueryFailedError } from '../common/errors/database'; +import { handleQueryFailedError } from '../common/errors'; export class BaseRepository extends Repository { private readonly entityManager: EntityManager; diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts index df1055b929..cdd9440f7f 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts @@ -2,43 +2,42 @@ import { Body, ClassSerializerInterceptor, Controller, + HttpCode, + Ip, + Logger, Post, Req, + Request, UseGuards, UseInterceptors, - Request, - Logger, - Ip, - HttpCode, - HttpStatus, } from '@nestjs/common'; import { ApiBearerAuth, - ApiTags, - ApiResponse, ApiBody, ApiOperation, + ApiResponse, + ApiTags, } from '@nestjs/swagger'; +import { ErrorAuth } from '../../common/constants/errors'; import { Public } from '../../common/decorators'; +import { ValidationError } from '../../common/errors'; +import { JwtAuthGuard } from '../../common/guards'; +import { RequestWithUser } from '../../common/types'; import { UserCreateDto } from '../user/user.dto'; import { ApiKeyDto, AuthDto, ForgotPasswordDto, + RefreshDto, ResendEmailVerificationDto, RestorePasswordDto, SignInDto, VerifyEmailDto, - RefreshDto, } from './auth.dto'; import { AuthService } from './auth.service'; -import { JwtAuthGuard } from '../../common/guards'; -import { RequestWithUser } from '../../common/types'; -import { ErrorAuth } from '../../common/constants/errors'; -import { TokenRepository } from './token.repository'; import { TokenType } from './token.entity'; -import { ControlledError } from '../../common/errors/controlled'; +import { TokenRepository } from './token.repository'; @ApiTags('Auth') @ApiResponse({ @@ -251,10 +250,7 @@ export class AuthJwtController { e.message, `${AuthJwtController.name} - ${ErrorAuth.ApiKeyCouldNotBeCreatedOrUpdated}`, ); - throw new ControlledError( - ErrorAuth.ApiKeyCouldNotBeCreatedOrUpdated, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorAuth.ApiKeyCouldNotBeCreatedOrUpdated); } } } diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts index 0a25d4d542..a525dffe4b 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts @@ -1,14 +1,18 @@ -import { Test } from '@nestjs/testing'; -import { AuthService } from './auth.service'; -import { TokenRepository } from './token.repository'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +jest.mock('@human-protocol/sdk'); +jest.mock('../../common/utils/hcaptcha', () => ({ + verifyToken: jest.fn().mockReturnValue({ success: true }), +})); + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('mocked-uuid'), +})); + import { createMock } from '@golevelup/ts-jest'; -import { UserRepository } from '../user/user.repository'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import { UserService } from '../user/user.service'; -import { UserEntity } from '../user/user.entity'; -import { ErrorAuth, ErrorUser } from '../../common/constants/errors'; +import { Test } from '@nestjs/testing'; +import { v4 } from 'uuid'; import { MOCK_ACCESS_TOKEN, MOCK_EMAIL, @@ -19,27 +23,26 @@ import { MOCK_REFRESH_TOKEN, mockConfig, } from '../../../test/constants'; -import { TokenEntity, TokenType } from './token.entity'; -import { v4 } from 'uuid'; -import { PaymentService } from '../payment/payment.service'; +import { AuthConfigService } from '../../common/config/auth-config.service'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { SENDGRID_TEMPLATES, SERVICE_NAME } from '../../common/constants'; +import { ErrorAuth, ErrorUser } from '../../common/constants/errors'; import { UserStatus } from '../../common/enums/user'; +import { + ConflictError, + ForbiddenError, + NotFoundError, +} from '../../common/errors'; +import { PaymentService } from '../payment/payment.service'; import { SendGridService } from '../sendgrid/sendgrid.service'; -import { SENDGRID_TEMPLATES, SERVICE_NAME } from '../../common/constants'; -import { ApiKeyRepository } from './apikey.repository'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { AuthConfigService } from '../../common/config/auth-config.service'; -import { ControlledError } from '../../common/errors/controlled'; -import { HttpStatus } from '@nestjs/common'; +import { UserEntity } from '../user/user.entity'; +import { UserRepository } from '../user/user.repository'; +import { UserService } from '../user/user.service'; import { WhitelistService } from '../whitelist/whitelist.service'; - -jest.mock('@human-protocol/sdk'); -jest.mock('../../common/utils/hcaptcha', () => ({ - verifyToken: jest.fn().mockReturnValue({ success: true }), -})); - -jest.mock('uuid', () => ({ - v4: jest.fn().mockReturnValue('mocked-uuid'), -})); +import { ApiKeyRepository } from './apikey.repository'; +import { AuthService } from './auth.service'; +import { TokenEntity, TokenType } from './token.entity'; +import { TokenRepository } from './token.repository'; describe('AuthService', () => { let authService: AuthService; @@ -142,14 +145,11 @@ describe('AuthService', () => { }); }); - it('should throw UnauthorizedException if user credentials are invalid', async () => { + it('should throw ForbiddenError if user credentials are invalid', async () => { getByCredentialsMock.mockResolvedValue(undefined); await expect(authService.signin(signInDto)).rejects.toThrow( - new ControlledError( - ErrorAuth.InvalidEmailOrPassword, - HttpStatus.FORBIDDEN, - ), + new ForbiddenError(ErrorAuth.InvalidEmailOrPassword), ); expect(userService.getByCredentials).toHaveBeenCalledWith( @@ -212,7 +212,7 @@ describe('AuthService', () => { .mockResolvedValue(userEntity as any); await expect(authService.signup(userCreateDto)).rejects.toThrow( - new ControlledError(ErrorUser.DuplicatedEmail, HttpStatus.BAD_REQUEST), + new ConflictError(ErrorUser.DuplicatedEmail), ); expect(userRepository.findByEmail).toHaveBeenCalledWith(userEntity.email); @@ -327,23 +327,19 @@ describe('AuthService', () => { jest.clearAllMocks(); }); - it('should throw NotFound exception if user is not found', () => { + it('should throw NotFoundError if user is not found', () => { findByEmailMock.mockResolvedValue(null); expect( authService.forgotPassword({ email: 'user@example.com' }), - ).rejects.toThrow( - new ControlledError(ErrorUser.NotFound, HttpStatus.NO_CONTENT), - ); + ).rejects.toThrow(new NotFoundError(ErrorUser.NotFound)); }); - it('should throw Unauthorized exception if user is not active', () => { + it('should throw ForbiddenError if user is not active', () => { userEntity.status = UserStatus.INACTIVE; findByEmailMock.mockResolvedValue(userEntity); expect( authService.forgotPassword({ email: 'user@example.com' }), - ).rejects.toThrow( - new ControlledError(ErrorUser.UserNotActive, HttpStatus.FORBIDDEN), - ); + ).rejects.toThrow(new ForbiddenError(ErrorUser.UserNotActive)); }); it('should remove existing token if it exists', async () => { @@ -410,9 +406,7 @@ describe('AuthService', () => { password: 'password', hCaptchaToken: 'token', }), - ).rejects.toThrow( - new ControlledError(ErrorAuth.InvalidToken, HttpStatus.FORBIDDEN), - ); + ).rejects.toThrow(new ForbiddenError(ErrorAuth.InvalidToken)); }); it('should throw an error if token is expired', () => { @@ -425,9 +419,7 @@ describe('AuthService', () => { password: 'password', hCaptchaToken: 'token', }), - ).rejects.toThrow( - new ControlledError(ErrorAuth.TokenExpired, HttpStatus.FORBIDDEN), - ); + ).rejects.toThrow(new ForbiddenError(ErrorAuth.TokenExpired)); }); it('should update password and send email', async () => { @@ -478,14 +470,14 @@ describe('AuthService', () => { it('should throw an error if token is not found', () => { findTokenMock.mockResolvedValue(null); expect(authService.emailVerification({ token: 'token' })).rejects.toThrow( - new ControlledError(ErrorAuth.NotFound, HttpStatus.FORBIDDEN), + new NotFoundError(ErrorAuth.NotFound), ); }); it('should throw an error if token is expired', () => { tokenEntity.expiresAt = new Date(new Date().getDate() - 1); findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); expect(authService.emailVerification({ token: 'token' })).rejects.toThrow( - new ControlledError(ErrorAuth.TokenExpired, HttpStatus.FORBIDDEN), + new ForbiddenError(ErrorAuth.TokenExpired), ); }); @@ -528,9 +520,7 @@ describe('AuthService', () => { findByEmailMock.mockResolvedValue(null); expect( authService.resendEmailVerification({ email: 'user@example.com' }), - ).rejects.toThrow( - new ControlledError(ErrorUser.NotFound, HttpStatus.NO_CONTENT), - ); + ).rejects.toThrow(new NotFoundError(ErrorUser.NotFound)); }); it('should throw an error if user is not pending', () => { @@ -538,9 +528,7 @@ describe('AuthService', () => { findByEmailMock.mockResolvedValue(userEntity); expect( authService.resendEmailVerification({ email: 'user@example.com' }), - ).rejects.toThrow( - new ControlledError(ErrorUser.NotFound, HttpStatus.NO_CONTENT), - ); + ).rejects.toThrow(new ConflictError(ErrorUser.InvalidStatus)); }); it('should create token and send email', async () => { diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts index 534cd4afc2..278f12c0a2 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { HttpStatus, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ErrorAuth, ErrorUser } from '../../common/constants/errors'; @@ -22,16 +22,20 @@ import { TokenRepository } from './token.repository'; import { AuthConfigService } from '../../common/config/auth-config.service'; import { ServerConfigService } from '../../common/config/server-config.service'; -import { SendGridService } from '../sendgrid/sendgrid.service'; +import * as crypto from 'crypto'; +import { + ConflictError, + ForbiddenError, + NotFoundError, +} from '../../common/errors'; import { SENDGRID_TEMPLATES, SERVICE_NAME } from '../../common/constants'; import { generateHash } from '../../common/utils/crypto'; -import { ApiKeyRepository } from './apikey.repository'; -import * as crypto from 'crypto'; import { verifyToken } from '../../common/utils/hcaptcha'; +import { SendGridService } from '../sendgrid/sendgrid.service'; import { UserRepository } from '../user/user.repository'; -import { ApiKeyEntity } from './apikey.entity'; -import { ControlledError } from '../../common/errors/controlled'; import { WhitelistService } from '../whitelist/whitelist.service'; +import { ApiKeyEntity } from './apikey.entity'; +import { ApiKeyRepository } from './apikey.repository'; @Injectable() export class AuthService { @@ -59,9 +63,8 @@ export class AuthService { // ) // ).success // ) { - // throw new ControlledError( + // throw new ForbiddenError( // ErrorAuth.InvalidCaptchaToken, - // HttpStatus.FORBIDDEN, // ); // } const userEntity = await this.userService.getByCredentials( @@ -70,10 +73,7 @@ export class AuthService { ); if (!userEntity) { - throw new ControlledError( - ErrorAuth.InvalidEmailOrPassword, - HttpStatus.FORBIDDEN, - ); + throw new ForbiddenError(ErrorAuth.InvalidEmailOrPassword); } return this.auth(userEntity); @@ -91,17 +91,11 @@ export class AuthService { ) ).success ) { - throw new ControlledError( - ErrorAuth.InvalidCaptchaToken, - HttpStatus.FORBIDDEN, - ); + throw new ForbiddenError(ErrorAuth.InvalidCaptchaToken); } const storedUser = await this.userRepository.findByEmail(data.email); if (storedUser) { - throw new ControlledError( - ErrorUser.DuplicatedEmail, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorUser.DuplicatedEmail); } const userEntity = await this.userService.create(data); @@ -138,11 +132,11 @@ export class AuthService { ); if (!tokenEntity) { - throw new ControlledError(ErrorAuth.InvalidToken, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorAuth.InvalidToken); } if (new Date() > tokenEntity.expiresAt) { - throw new ControlledError(ErrorAuth.TokenExpired, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorAuth.TokenExpired); } return this.auth(tokenEntity.user); @@ -191,11 +185,11 @@ export class AuthService { const userEntity = await this.userRepository.findByEmail(data.email); if (!userEntity) { - throw new ControlledError(ErrorUser.NotFound, HttpStatus.NO_CONTENT); + throw new NotFoundError(ErrorUser.NotFound); } if (userEntity.status !== UserStatus.ACTIVE) { - throw new ControlledError(ErrorUser.UserNotActive, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorUser.UserNotActive); } const existingToken = await this.tokenRepository.findOneByUserIdAndType( @@ -246,10 +240,7 @@ export class AuthService { ) ).success ) { - throw new ControlledError( - ErrorAuth.InvalidCaptchaToken, - HttpStatus.FORBIDDEN, - ); + throw new ForbiddenError(ErrorAuth.InvalidCaptchaToken); } const tokenEntity = await this.tokenRepository.findOneByUuidAndType( @@ -258,11 +249,11 @@ export class AuthService { ); if (!tokenEntity) { - throw new ControlledError(ErrorAuth.InvalidToken, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorAuth.InvalidToken); } if (new Date() > tokenEntity.expiresAt) { - throw new ControlledError(ErrorAuth.TokenExpired, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorAuth.TokenExpired); } await this.userService.updatePassword(tokenEntity.user, data); @@ -288,11 +279,11 @@ export class AuthService { ); if (!tokenEntity) { - throw new ControlledError(ErrorAuth.NotFound, HttpStatus.FORBIDDEN); + throw new NotFoundError(ErrorAuth.NotFound); } if (new Date() > tokenEntity.expiresAt) { - throw new ControlledError(ErrorAuth.TokenExpired, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorAuth.TokenExpired); } tokenEntity.user.status = UserStatus.ACTIVE; @@ -304,8 +295,10 @@ export class AuthService { ): Promise { const userEntity = await this.userRepository.findByEmail(data.email); - if (!userEntity || userEntity?.status != UserStatus.PENDING) { - throw new ControlledError(ErrorUser.NotFound, HttpStatus.NO_CONTENT); + if (!userEntity) { + throw new NotFoundError(ErrorUser.NotFound); + } else if (userEntity?.status != UserStatus.PENDING) { + throw new ConflictError(ErrorUser.InvalidStatus); } const existingToken = await this.tokenRepository.findOneByUserIdAndType( @@ -378,7 +371,7 @@ export class AuthService { const apiKeyEntity = await this.apiKeyRepository.findAPIKeyByUserId(userId); if (!apiKeyEntity) { - throw new ControlledError(ErrorAuth.ApiKeyNotFound, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorAuth.ApiKeyNotFound); } const hash = await generateHash( @@ -398,7 +391,7 @@ export class AuthService { const apiKeyEntity = await this.apiKeyRepository.findAPIKeyById(apiKeyId); if (!apiKeyEntity) { - throw new ControlledError(ErrorAuth.ApiKeyNotFound, HttpStatus.FORBIDDEN); + throw new ForbiddenError(ErrorAuth.ApiKeyNotFound); } const hash = await generateHash( apiKey, diff --git a/packages/apps/job-launcher/server/src/modules/auth/strategy/jwt.http.ts b/packages/apps/job-launcher/server/src/modules/auth/strategy/jwt.http.ts index bdf08ceb28..112a63ba2d 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/strategy/jwt.http.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/strategy/jwt.http.ts @@ -1,18 +1,18 @@ -import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Injectable, Req } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; -import { HttpStatus, Injectable, Req } from '@nestjs/common'; +import { ExtractJwt, Strategy } from 'passport-jwt'; -import { UserEntity } from '../../user/user.entity'; +import { AuthConfigService } from '../../../common/config/auth-config.service'; import { LOGOUT_PATH, RESEND_EMAIL_VERIFICATION_PATH, } from '../../../common/constants'; import { UserStatus } from '../../../common/enums/user'; -import { AuthConfigService } from '../../../common/config/auth-config.service'; +import { AuthError } from '../../../common/errors'; +import { UserEntity } from '../../user/user.entity'; import { UserRepository } from '../../user/user.repository'; -import { ControlledError } from '../../../common/errors/controlled'; -import { TokenRepository } from '../token.repository'; import { TokenType } from '../token.entity'; +import { TokenRepository } from '../token.repository'; @Injectable() export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { @@ -36,7 +36,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { const user = await this.userRepository.findById(payload.userId); if (!user) { - throw new ControlledError('User not found', HttpStatus.UNAUTHORIZED); + throw new AuthError('User not found'); } if ( @@ -44,7 +44,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { request.url !== RESEND_EMAIL_VERIFICATION_PATH && request.url !== LOGOUT_PATH ) { - throw new ControlledError('User not active', HttpStatus.UNAUTHORIZED); + throw new AuthError('User not active'); } const token = await this.tokenRepository.findOneByUserIdAndType( @@ -53,10 +53,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { ); if (!token) { - throw new ControlledError( - 'User is not authorized', - HttpStatus.UNAUTHORIZED, - ); + throw new AuthError('User is not authorized'); } return user; diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.repository.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.repository.ts index 009814295d..179b6c1092 100644 --- a/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.repository.ts @@ -6,7 +6,7 @@ import { ContentModerationRequestStatus } from '../../common/enums/content-moder import { BaseRepository } from '../../database/base.repository'; import { ContentModerationRequestEntity } from './content-moderation-request.entity'; import { QueryFailedError } from 'typeorm'; -import { handleQueryFailedError } from '../../common/errors/database'; +import { handleQueryFailedError } from '../../common/errors'; @Injectable() export class ContentModerationRequestRepository extends BaseRepository { diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.spec.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.spec.ts index ba784488da..84bce6d2ef 100644 --- a/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.spec.ts @@ -1,3 +1,13 @@ +jest.mock('@google-cloud/storage'); +jest.mock('@google-cloud/vision'); +jest.mock('../../common/utils/slack', () => ({ + sendSlackNotification: jest.fn(), +})); +jest.mock('../../common/utils/storage', () => ({ + ...jest.requireActual('../../common/utils/storage'), + listObjectsInBucket: jest.fn(), +})); + import { faker } from '@faker-js/faker'; import { Storage } from '@google-cloud/storage'; import { ImageAnnotatorClient } from '@google-cloud/vision'; @@ -6,28 +16,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SlackConfigService } from '../../common/config/slack-config.service'; import { VisionConfigService } from '../../common/config/vision-config.service'; import { ErrorContentModeration } from '../../common/constants/errors'; -import { ContentModerationLevel } from '../../common/enums/gcv'; import { ContentModerationRequestStatus } from '../../common/enums/content-moderation'; +import { ContentModerationLevel } from '../../common/enums/gcv'; import { JobStatus } from '../../common/enums/job'; -import { ControlledError } from '../../common/errors/controlled'; +import { sendSlackNotification } from '../../common/utils/slack'; +import { listObjectsInBucket } from '../../common/utils/storage'; import { JobEntity } from '../job/job.entity'; import { JobRepository } from '../job/job.repository'; +import { ManifestService } from '../manifest/manifest.service'; import { ContentModerationRequestEntity } from './content-moderation-request.entity'; import { ContentModerationRequestRepository } from './content-moderation-request.repository'; import { GCVContentModerationService } from './gcv-content-moderation.service'; -import { sendSlackNotification } from '../../common/utils/slack'; -import { listObjectsInBucket } from '../../common/utils/storage'; -import { ManifestService } from '../manifest/manifest.service'; - -jest.mock('@google-cloud/storage'); -jest.mock('@google-cloud/vision'); -jest.mock('../../common/utils/slack', () => ({ - sendSlackNotification: jest.fn(), -})); -jest.mock('../../common/utils/storage', () => ({ - ...jest.requireActual('../../common/utils/storage'), - listObjectsInBucket: jest.fn(), -})); describe('GCVContentModerationService', () => { let service: GCVContentModerationService; @@ -465,14 +464,14 @@ describe('GCVContentModerationService', () => { ); }); - it('should throw ControlledError if vision call fails', async () => { + it('should throw Error if vision call fails', async () => { mockVisionClient.asyncBatchAnnotateImages.mockRejectedValueOnce( new Error('Vision failure'), ); await expect( (service as any).asyncBatchAnnotateImages([], 'my-file'), - ).rejects.toThrow(ControlledError); + ).rejects.toThrow(Error); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.ts index 73b7fbff23..d4bd6f8b0c 100644 --- a/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.ts +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.ts @@ -1,6 +1,7 @@ import { Storage } from '@google-cloud/storage'; import { ImageAnnotatorClient, protos } from '@google-cloud/vision'; -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import NodeCache from 'node-cache'; import { SlackConfigService } from '../../common/config/slack-config.service'; import { VisionConfigService } from '../../common/config/vision-config.service'; import { @@ -8,13 +9,12 @@ import { GCV_CONTENT_MODERATION_BATCH_SIZE_PER_TASK, } from '../../common/constants'; import { ErrorContentModeration } from '../../common/constants/errors'; +import { ContentModerationRequestStatus } from '../../common/enums/content-moderation'; import { ContentModerationFeature, ContentModerationLevel, } from '../../common/enums/gcv'; -import { ContentModerationRequestStatus } from '../../common/enums/content-moderation'; import { JobStatus } from '../../common/enums/job'; -import { ControlledError } from '../../common/errors/controlled'; import { constructGcsPath, convertToGCSPath, @@ -25,13 +25,12 @@ import { sendSlackNotification } from '../../common/utils/slack'; import { listObjectsInBucket } from '../../common/utils/storage'; import { JobEntity } from '../job/job.entity'; import { JobRepository } from '../job/job.repository'; +import { CvatManifestDto } from '../manifest/manifest.dto'; +import { ManifestService } from '../manifest/manifest.service'; import { ContentModerationRequestEntity } from './content-moderation-request.entity'; import { ContentModerationRequestRepository } from './content-moderation-request.repository'; -import { IContentModeratorService } from './content-moderation.interface'; import { ModerationResultDto } from './content-moderation.dto'; -import NodeCache from 'node-cache'; -import { ManifestService } from '../manifest/manifest.service'; -import { CvatManifestDto } from '../manifest/manifest.dto'; +import { IContentModeratorService } from './content-moderation.interface'; @Injectable() export class GCVContentModerationService implements IContentModeratorService { @@ -333,10 +332,7 @@ export class GCVContentModerationService implements IContentModeratorService { ); } catch (error) { this.logger.error('Error analyzing images:', error); - throw new ControlledError( - ErrorContentModeration.ContentModerationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new Error(ErrorContentModeration.ContentModerationFailed); } } @@ -379,10 +375,7 @@ export class GCVContentModerationService implements IContentModeratorService { const [files] = await bucket.getFiles({ prefix: bucketPrefix }); if (!files || files.length === 0) { - throw new ControlledError( - ErrorContentModeration.NoResultsFound, - HttpStatus.NOT_FOUND, - ); + throw new Error(ErrorContentModeration.NoResultsFound); } const allResponses = []; @@ -397,15 +390,11 @@ export class GCVContentModerationService implements IContentModeratorService { } return this.categorizeModerationResults(allResponses); } catch (err) { - this.logger.error('Error collecting moderation results:', err); - if (err instanceof ControlledError) { + if (err.message === ErrorContentModeration.NoResultsFound) { throw err; - } else { - throw new ControlledError( - ErrorContentModeration.ResultsParsingFailed, - HttpStatus.INTERNAL_SERVER_ERROR, - ); } + this.logger.error('Error collecting moderation results:', err); + throw new Error(ErrorContentModeration.ResultsParsingFailed); } } diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts index 3f04992874..dd397c871f 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts @@ -1,6 +1,19 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { CronJobType } from '../../common/enums/cron-job'; +jest.mock('@human-protocol/sdk', () => ({ + ...jest.requireActual('@human-protocol/sdk'), + EscrowClient: { + build: jest.fn().mockImplementation(() => ({ + createEscrow: jest.fn().mockResolvedValue(MOCK_ADDRESS), + setup: jest.fn().mockResolvedValue(null), + fund: jest.fn().mockResolvedValue(null), + })), + }, + KVStoreUtils: { + get: jest.fn(), + }, + EscrowUtils: { + getStatusEvents: jest.fn(), + }, +})); import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; @@ -15,8 +28,8 @@ import { } from '@human-protocol/sdk'; import { StatusEvent } from '@human-protocol/sdk/dist/graphql'; import { HttpService } from '@nestjs/axios'; -import { HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; import { ethers } from 'ethers'; import { DeepPartial } from 'typeorm'; import { @@ -37,9 +50,10 @@ import { ErrorContentModeration, ErrorCronJob, } from '../../common/constants/errors'; +import { CronJobType } from '../../common/enums/cron-job'; import { CvatJobType, FortuneJobType, JobStatus } from '../../common/enums/job'; import { WebhookStatus } from '../../common/enums/webhook'; -import { ControlledError } from '../../common/errors/controlled'; +import { ConflictError } from '../../common/errors'; import { ContentModerationRequestRepository } from '../content-moderation/content-moderation-request.repository'; import { GCVContentModerationService } from '../content-moderation/gcv-content-moderation.service'; import { JobEntity } from '../job/job.entity'; @@ -62,23 +76,6 @@ import { CronJobEntity } from './cron-job.entity'; import { CronJobRepository } from './cron-job.repository'; import { CronJobService } from './cron-job.service'; -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - EscrowClient: { - build: jest.fn().mockImplementation(() => ({ - createEscrow: jest.fn().mockResolvedValue(MOCK_ADDRESS), - setup: jest.fn().mockResolvedValue(null), - fund: jest.fn().mockResolvedValue(null), - })), - }, - KVStoreUtils: { - get: jest.fn(), - }, - EscrowUtils: { - getStatusEvents: jest.fn(), - }, -})); - describe('CronJobService', () => { let service: CronJobService, repository: CronJobRepository, @@ -327,7 +324,7 @@ describe('CronJobService', () => { .mockResolvedValue(cronJobEntity); await expect(service.completeCronJob(cronJobEntity)).rejects.toThrow( - new ControlledError(ErrorCronJob.Completed, HttpStatus.BAD_REQUEST), + new ConflictError(ErrorCronJob.Completed), ); expect(updateOneSpy).not.toHaveBeenCalled(); }); diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts index 2e972b05a2..482d5bc904 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts @@ -1,4 +1,4 @@ -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { ErrorContentModeration, @@ -18,7 +18,7 @@ import { OracleType, WebhookStatus, } from '../../common/enums/webhook'; -import { ControlledError } from '../../common/errors/controlled'; +import { ConflictError, NotFoundError } from '../../common/errors'; import { GCVContentModerationService } from '../content-moderation/gcv-content-moderation.service'; import { JobEntity } from '../job/job.entity'; import { JobRepository } from '../job/job.repository'; @@ -75,7 +75,7 @@ export class CronJobService { cronJobEntity: CronJobEntity, ): Promise { if (cronJobEntity.completedAt) { - throw new ControlledError(ErrorCronJob.Completed, HttpStatus.BAD_REQUEST); + throw new ConflictError(ErrorCronJob.Completed); } cronJobEntity.completedAt = new Date(); @@ -395,10 +395,7 @@ export class CronJobService { ); if (!jobEntity) { this.logger.log(ErrorJob.NotFound, JobService.name); - throw new ControlledError( - ErrorJob.NotFound, - HttpStatus.BAD_REQUEST, - ); + throw new NotFoundError(ErrorJob.NotFound); } if ( jobEntity.escrowAddress && diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts index fb934f4cd5..cd2b584083 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts @@ -1,8 +1,8 @@ +import { ChainId } from '@human-protocol/sdk'; import { Body, Controller, Get, - HttpStatus, Param, Patch, Post, @@ -13,36 +13,35 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, - ApiOperation, - ApiTags, ApiBody, + ApiOperation, ApiResponse, + ApiTags, } from '@nestjs/swagger'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { MUTEX_TIMEOUT } from '../../common/constants'; +import { ApiKey } from '../../common/decorators'; +import { FortuneJobType } from '../../common/enums/job'; +import { Web3Env } from '../../common/enums/web3'; +import { ForbiddenError } from '../../common/errors'; import { JwtAuthGuard } from '../../common/guards'; +import { PageDto } from '../../common/pagination/pagination.dto'; import { RequestWithUser } from '../../common/types'; +import { MutexManagerService } from '../mutex/mutex-manager.service'; import { - JobFortuneDto, + FortuneFinalResultDto, + GetJobsDto, + JobAudinoDto, + JobCancelDto, JobCvatDto, - JobListDto, JobDetailsDto, + JobFortuneDto, JobIdDto, - FortuneFinalResultDto, + JobListDto, // JobCaptchaDto, JobQuickLaunchDto, - JobCancelDto, - GetJobsDto, - JobAudinoDto, } from './job.dto'; import { JobService } from './job.service'; -import { FortuneJobType } from '../../common/enums/job'; -import { ApiKey } from '../../common/decorators'; -import { ChainId } from '@human-protocol/sdk'; -import { ControlledError } from '../../common/errors/controlled'; -import { PageDto } from '../../common/pagination/pagination.dto'; -import { MutexManagerService } from '../mutex/mutex-manager.service'; -import { MUTEX_TIMEOUT } from '../../common/constants'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { Web3Env } from '../../common/enums/web3'; @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -125,7 +124,7 @@ export class JobController { @Request() req: RequestWithUser, ): Promise { if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError('Disabled', HttpStatus.METHOD_NOT_ALLOWED); + throw new ForbiddenError('Disabled'); } return await this.mutexManagerService.runExclusive( @@ -205,7 +204,7 @@ export class JobController { @Request() req: RequestWithUser, ): Promise { if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError('Disabled', HttpStatus.METHOD_NOT_ALLOWED); + throw new ForbiddenError('Disabled'); } return await this.mutexManagerService.runExclusive( @@ -244,9 +243,8 @@ export class JobController { // @Body() data: JobCaptchaDto, // @Request() req: RequestWithUser, // ): Promise { - // throw new ControlledError( + // throw new ForbiddenError( // 'Hcaptcha jobs disabled temporally', - // HttpStatus.UNAUTHORIZED, // ); // return await this.mutexManagerService.runExclusive( // { id: `user${req.user.id}` }, diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 08ba57620e..8b3085f130 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -9,15 +9,12 @@ import { NETWORKS, StorageParams, } from '@human-protocol/sdk'; -import { - HttpStatus, - Inject, - Injectable, - Logger, - ValidationError, -} from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { validate } from 'class-validator'; +import { + ValidationError as ClassValidationError, + validate, +} from 'class-validator'; import { ethers } from 'ethers'; import { ServerConfigService } from '../../common/config/server-config.service'; import { CANCEL_JOB_STATUSES } from '../../common/constants'; @@ -39,7 +36,12 @@ import { } from '../../common/enums/job'; import { FiatCurrency } from '../../common/enums/payment'; import { EventType, OracleType } from '../../common/enums/webhook'; -import { ControlledError } from '../../common/errors/controlled'; +import { + ConflictError, + NotFoundError, + ServerError, + ValidationError, +} from '../../common/errors'; import { PageDto } from '../../common/pagination/pagination.dto'; import { parseUrl } from '../../common/utils'; import { add, div, max, mul } from '../../common/utils/decimal'; @@ -148,10 +150,7 @@ export class JobService { user.stripeCustomerId, )) ) - throw new ControlledError( - ErrorJob.NotActiveCard, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.NotActiveCard); } const feePercentage = Number( @@ -237,10 +236,7 @@ export class JobService { dto.qualifications.forEach((qualification) => { if (!validQualificationReferences.includes(qualification)) { - throw new ControlledError( - ErrorQualification.InvalidQualification, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorQualification.InvalidQualification); } }); } @@ -252,10 +248,7 @@ export class JobService { const { filename } = parseUrl(dto.manifestUrl); if (!filename) { - throw new ControlledError( - ErrorJob.ManifestHashNotExist, - HttpStatus.CONFLICT, - ); + throw new ValidationError(ErrorJob.ManifestHashNotExist); } jobEntity.manifestHash = filename; @@ -341,7 +334,7 @@ export class JobService { ); if (!escrowAddress) { - throw new ControlledError(ErrorEscrow.NotCreated, HttpStatus.NOT_FOUND); + throw new ConflictError(ErrorEscrow.NotCreated); } jobEntity.status = JobStatus.CREATED; @@ -430,7 +423,7 @@ export class JobService { ); if (!jobEntity) { - throw new ControlledError(ErrorJob.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.NotFound); } await this.requestToCancelJob(jobEntity); @@ -449,7 +442,7 @@ export class JobService { ); if (!jobEntity || (jobEntity && jobEntity.userId !== userId)) { - throw new ControlledError(ErrorJob.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.NotFound); } await this.requestToCancelJob(jobEntity); @@ -467,10 +460,7 @@ export class JobService { private async requestToCancelJob(jobEntity: JobEntity): Promise { if (!CANCEL_JOB_STATUSES.includes(jobEntity.status)) { - throw new ControlledError( - ErrorJob.InvalidStatusCancellation, - HttpStatus.CONFLICT, - ); + throw new ConflictError(ErrorJob.InvalidStatusCancellation); } let status = JobStatus.CANCELED; @@ -496,10 +486,7 @@ export class JobService { } if (status === JobStatus.FAILED) { - throw new ControlledError( - ErrorJob.CancelWhileProcessing, - HttpStatus.CONFLICT, - ); + throw new ConflictError(ErrorJob.CancelWhileProcessing); } if (status === JobStatus.CANCELED) { @@ -545,11 +532,7 @@ export class JobService { return new PageDto(data.page!, data.pageSize!, itemCount, jobs); } catch (error) { - throw new ControlledError( - error.message, - HttpStatus.BAD_REQUEST, - error.stack, - ); + throw new ServerError(error.message, error.stack); } } @@ -563,11 +546,11 @@ export class JobService { ); if (!jobEntity) { - throw new ControlledError(ErrorJob.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.NotFound); } if (!jobEntity.escrowAddress) { - throw new ControlledError(ErrorJob.ResultNotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.ResultNotFound); } const signer = this.web3Service.getSigner(jobEntity.chainId); @@ -578,7 +561,7 @@ export class JobService { ); if (!finalResultUrl) { - throw new ControlledError(ErrorJob.ResultNotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.ResultNotFound); } if (jobEntity.requestType === FortuneJobType.FORTUNE) { @@ -587,18 +570,15 @@ export class JobService { )) as Array; if (!data.length) { - throw new ControlledError( - ErrorJob.ResultNotFound, - HttpStatus.NOT_FOUND, - ); + throw new NotFoundError(ErrorJob.ResultNotFound); } - const allFortuneValidationErrors: ValidationError[] = []; + const allFortuneValidationErrors: ClassValidationError[] = []; for (const fortune of data) { const fortuneDtoCheck = new FortuneFinalResultDto(); Object.assign(fortuneDtoCheck, fortune); - const fortuneValidationErrors: ValidationError[] = + const fortuneValidationErrors: ClassValidationError[] = await validate(fortuneDtoCheck); allFortuneValidationErrors.push(...fortuneValidationErrors); } @@ -609,10 +589,7 @@ export class JobService { JobService.name, allFortuneValidationErrors, ); - throw new ControlledError( - ErrorJob.ResultValidationFailed, - HttpStatus.NOT_FOUND, - ); + throw new ValidationError(ErrorJob.ResultValidationFailed); } return data; } @@ -632,18 +609,15 @@ export class JobService { ); if (!jobEntity) { - throw new ControlledError(ErrorJob.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.NotFound); } if (!jobEntity.escrowAddress) { - throw new ControlledError(ErrorJob.ResultNotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.ResultNotFound); } if (jobEntity.requestType === FortuneJobType.FORTUNE) { - throw new ControlledError( - ErrorJob.InvalidRequestType, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.InvalidRequestType); } const signer = this.web3Service.getSigner(jobEntity.chainId); @@ -654,7 +628,7 @@ export class JobService { ); if (!finalResultUrl) { - throw new ControlledError(ErrorJob.ResultNotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.ResultNotFound); } const contents = await this.storageService.downloadFile(finalResultUrl); @@ -705,18 +679,12 @@ export class JobService { escrowStatus === EscrowStatus.Paid || escrowStatus === EscrowStatus.Cancelled ) { - throw new ControlledError( - ErrorEscrow.InvalidStatusCancellation, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorEscrow.InvalidStatusCancellation); } const balance = await escrowClient.getBalance(escrowAddress); if (balance === 0n) { - throw new ControlledError( - ErrorEscrow.InvalidBalanceCancellation, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorEscrow.InvalidBalanceCancellation); } return escrowClient.cancel(escrowAddress, { @@ -726,10 +694,7 @@ export class JobService { public async escrowFailedWebhook(dto: WebhookDataDto): Promise { if (dto.eventType !== EventType.ESCROW_FAILED) { - throw new ControlledError( - ErrorJob.InvalidEventType, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.InvalidEventType); } const jobEntity = await this.jobRepository.findOneByChainIdAndEscrowAddress( dto.chainId, @@ -737,27 +702,21 @@ export class JobService { ); if (!jobEntity) { - throw new ControlledError(ErrorJob.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.NotFound); } if (jobEntity.status !== JobStatus.LAUNCHED) { - throw new ControlledError(ErrorJob.NotLaunched, HttpStatus.CONFLICT); + throw new ConflictError(ErrorJob.NotLaunched); } if (!dto.eventData) { - throw new ControlledError( - 'Event data is required but was not provided.', - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError('Event data is required but was not provided.'); } const reason = dto.eventData.reason; if (!reason) { - throw new ControlledError( - 'Reason is undefined in event data.', - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError('Reason is undefined in event data.'); } jobEntity.status = JobStatus.FAILED; @@ -775,7 +734,7 @@ export class JobService { ); if (!jobEntity) { - throw new ControlledError(ErrorJob.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.NotFound); } const { chainId, escrowAddress, manifestUrl, manifestHash } = jobEntity; @@ -910,7 +869,7 @@ export class JobService { ); if (!jobEntity) { - throw new ControlledError(ErrorJob.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorJob.NotFound); } // If job status already completed by getDetails do nothing @@ -921,10 +880,7 @@ export class JobService { jobEntity.status !== JobStatus.LAUNCHED && jobEntity.status !== JobStatus.PARTIAL ) { - throw new ControlledError( - ErrorJob.InvalidStatusCompletion, - HttpStatus.CONFLICT, - ); + throw new ConflictError(ErrorJob.InvalidStatusCompletion); } jobEntity.status = JobStatus.COMPLETED; diff --git a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts index 2d509f00ad..970468cedf 100644 --- a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts @@ -7,7 +7,6 @@ jest.mock('../../common/utils/storage', () => ({ import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; import { Encryption } from '@human-protocol/sdk'; -import { HttpStatus } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { ethers } from 'ethers'; import { AuthConfigService } from '../../common/config/auth-config.service'; @@ -38,7 +37,11 @@ import { JobCaptchaRequestType, JobCaptchaShapeType, } from '../../common/enums/job'; -import { ControlledError } from '../../common/errors/controlled'; +import { + ConflictError, + ServerError, + ValidationError, +} from '../../common/errors'; import { generateBucketUrl, listObjectsInBucket, @@ -278,9 +281,7 @@ describe('ManifestService', () => { tokenFundAmount, tokenFundDecimals, ), - ).rejects.toThrow( - new ControlledError(ErrorJob.DataNotExist, HttpStatus.CONFLICT), - ); + ).rejects.toThrow(new ConflictError(ErrorJob.DataNotExist)); }); it('should throw an error if data does not exist for image skeletons from boxes job type', async () => { @@ -295,9 +296,7 @@ describe('ManifestService', () => { tokenFundAmount, tokenFundDecimals, ), - ).rejects.toThrow( - new ControlledError(ErrorJob.DataNotExist, HttpStatus.CONFLICT), - ); + ).rejects.toThrow(new ConflictError(ErrorJob.DataNotExist)); }); }); @@ -502,7 +501,7 @@ describe('ManifestService', () => { }); }); - it('should throw ControlledError for invalid POLYGON job type without label', async () => { + it('should throw ValidationError for invalid POLYGON job type without label', async () => { const jobDto = createJobCaptchaDto({ annotations: { typeOfJob: JobCaptchaShapeType.POLYGON, @@ -520,10 +519,7 @@ describe('ManifestService', () => { tokenFundDecimals, ), ).rejects.toThrow( - new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ), + new ValidationError(ErrorJob.JobParamsValidationFailed), ); }); @@ -577,7 +573,7 @@ describe('ManifestService', () => { }); }); - it('should throw ControlledError for invalid POINT job type without label', async () => { + it('should throw ValidationError for invalid POINT job type without label', async () => { const jobDto = createJobCaptchaDto({ annotations: { typeOfJob: JobCaptchaShapeType.POINT, @@ -595,10 +591,7 @@ describe('ManifestService', () => { tokenFundDecimals, ), ).rejects.toThrow( - new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ), + new ValidationError(ErrorJob.JobParamsValidationFailed), ); }); @@ -652,7 +645,7 @@ describe('ManifestService', () => { }); }); - it('should throw ControlledError for invalid BOUNDING_BOX job type without label', async () => { + it('should throw ValidationError for invalid BOUNDING_BOX job type without label', async () => { const jobDto = createJobCaptchaDto({ annotations: { typeOfJob: JobCaptchaShapeType.BOUNDING_BOX, @@ -670,10 +663,7 @@ describe('ManifestService', () => { tokenFundDecimals, ), ).rejects.toThrow( - new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ), + new ValidationError(ErrorJob.JobParamsValidationFailed), ); }); @@ -727,7 +717,7 @@ describe('ManifestService', () => { }); }); - it('should throw ControlledError for invalid IMMO job type without label', async () => { + it('should throw ValidationError for invalid IMMO job type without label', async () => { const jobDto = createJobCaptchaDto({ annotations: { typeOfJob: JobCaptchaShapeType.IMMO, @@ -745,14 +735,11 @@ describe('ManifestService', () => { tokenFundDecimals, ), ).rejects.toThrow( - new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ), + new ValidationError(ErrorJob.JobParamsValidationFailed), ); }); - it('should throw ControlledError for invalid job type', async () => { + it('should throw ValidationError for invalid job type', async () => { const jobDto = createJobCaptchaDto({ annotations: { typeOfJob: 'INVALID_JOB_TYPE' as JobCaptchaShapeType, @@ -769,12 +756,7 @@ describe('ManifestService', () => { tokenFundAmount, tokenFundDecimals, ), - ).rejects.toThrow( - new ControlledError( - ErrorJob.HCaptchaInvalidJobType, - HttpStatus.CONFLICT, - ), - ); + ).rejects.toThrow(new ValidationError(ErrorJob.HCaptchaInvalidJobType)); }); }); }); @@ -813,7 +795,7 @@ describe('ManifestService', () => { const mockOracleAddresses: string[] = []; mockStorageService.uploadJsonLikeData.mockRejectedValue( - new ControlledError('File not uploaded', HttpStatus.BAD_REQUEST), + new ServerError('File not uploaded'), ); await expect( @@ -822,7 +804,7 @@ describe('ManifestService', () => { mockData, mockOracleAddresses, ), - ).rejects.toThrow(ControlledError); + ).rejects.toThrow(ServerError); }); }); @@ -864,12 +846,7 @@ describe('ManifestService', () => { ); await expect( manifestService.downloadManifest(mockManifestUrl, mockRequestType), - ).rejects.toThrow( - new ControlledError( - ErrorJob.ManifestValidationFailed, - HttpStatus.NOT_FOUND, - ), - ); + ).rejects.toThrow(new ValidationError(ErrorJob.ManifestValidationFailed)); }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts index 0a91fa38d2..3e7e9176d1 100644 --- a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts +++ b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.ts @@ -6,10 +6,9 @@ import { StorageParams, } from '@human-protocol/sdk'; import { - HttpStatus, + ValidationError as ClassValidationError, Injectable, Logger, - ValidationError, } from '@nestjs/common'; import { validate } from 'class-validator'; import { ethers } from 'ethers'; @@ -44,7 +43,7 @@ import { JobCaptchaShapeType, JobRequestType, } from '../../common/enums/job'; -import { ControlledError } from '../../common/errors/controlled'; +import { ConflictError, ValidationError } from '../../common/errors'; import { generateBucketUrl, listObjectsInBucket, @@ -67,10 +66,10 @@ import { Web3Service } from '../web3/web3.service'; import { AudinoManifestDto, CvatManifestDto, - HCaptchaManifestDto, FortuneManifestDto, - RestrictedAudience, + HCaptchaManifestDto, ManifestDto, + RestrictedAudience, } from './manifest.dto'; @Injectable() @@ -126,10 +125,7 @@ export class ManifestService { ); default: - throw new ControlledError( - ErrorJob.InvalidRequestType, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.InvalidRequestType); } } @@ -144,18 +140,12 @@ export class ManifestService { case CvatJobType.IMAGE_POINTS: const data = await listObjectsInBucket(urls.dataUrl); if (!data || data.length === 0 || !data[0]) - throw new ControlledError( - ErrorJob.DatasetValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.DatasetValidationFailed); gt = (await this.storageService.downloadJsonLikeData( `${urls.gtUrl.protocol}//${urls.gtUrl.host}${urls.gtUrl.pathname}`, )) as any; if (!gt || !gt.images || gt.images.length === 0) - throw new ControlledError( - ErrorJob.GroundThuthValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.GroundThuthValidationFailed); await this.checkImageConsistency(gt.images, data); @@ -170,10 +160,7 @@ export class ManifestService { )) as any; if (!gt || !gt.images || gt.images.length === 0) { - throw new ControlledError( - ErrorJob.GroundThuthValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.GroundThuthValidationFailed); } gtEntries = 0; @@ -203,10 +190,7 @@ export class ManifestService { )) as any; if (!gt || !gt.images || gt.images.length === 0) { - throw new ControlledError( - ErrorJob.GroundThuthValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.GroundThuthValidationFailed); } gtEntries = 0; @@ -228,10 +212,7 @@ export class ManifestService { return boxes.annotations.length - gtEntries; default: - throw new ControlledError( - ErrorJob.InvalidRequestType, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.InvalidRequestType); } } @@ -248,10 +229,7 @@ export class ManifestService { ); if (missingFileNames.length !== 0) { - throw new ControlledError( - ErrorJob.ImageConsistency, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorJob.ImageConsistency); } } @@ -298,7 +276,7 @@ export class ManifestService { !dto.data.boxes) || (requestType === CvatJobType.IMAGE_BOXES_FROM_POINTS && !dto.data.points) ) { - throw new ControlledError(ErrorJob.DataNotExist, HttpStatus.CONFLICT); + throw new ConflictError(ErrorJob.DataNotExist); } const urls = { @@ -442,10 +420,7 @@ export class ManifestService { case JobCaptchaShapeType.POLYGON: if (!jobDto.annotations.label) { - throw new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.JobParamsValidationFailed); } const polygonManifest = { @@ -471,10 +446,7 @@ export class ManifestService { case JobCaptchaShapeType.POINT: if (!jobDto.annotations.label) { - throw new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.JobParamsValidationFailed); } const pointManifest = { @@ -497,10 +469,7 @@ export class ManifestService { return pointManifest; case JobCaptchaShapeType.BOUNDING_BOX: if (!jobDto.annotations.label) { - throw new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.JobParamsValidationFailed); } const boundingBoxManifest = { @@ -523,10 +492,7 @@ export class ManifestService { return boundingBoxManifest; case JobCaptchaShapeType.IMMO: if (!jobDto.annotations.label) { - throw new ControlledError( - ErrorJob.JobParamsValidationFailed, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorJob.JobParamsValidationFailed); } const immoManifest = { @@ -549,10 +515,7 @@ export class ManifestService { return immoManifest; default: - throw new ControlledError( - ErrorJob.HCaptchaInvalidJobType, - HttpStatus.CONFLICT, - ); + throw new ValidationError(ErrorJob.HCaptchaInvalidJobType); } } @@ -673,12 +636,9 @@ export class ManifestService { Object.assign(dtoCheck, manifest); - const validationErrors: ValidationError[] = await validate(dtoCheck); + const validationErrors: ClassValidationError[] = await validate(dtoCheck); if (validationErrors.length > 0) { - throw new ControlledError( - ErrorJob.ManifestValidationFailed, - HttpStatus.NOT_FOUND, - ); + throw new ValidationError(ErrorJob.ManifestValidationFailed); } } diff --git a/packages/apps/job-launcher/server/src/modules/mutex/mutex-manager.service.ts b/packages/apps/job-launcher/server/src/modules/mutex/mutex-manager.service.ts index 850b9d3376..159face8a5 100644 --- a/packages/apps/job-launcher/server/src/modules/mutex/mutex-manager.service.ts +++ b/packages/apps/job-launcher/server/src/modules/mutex/mutex-manager.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; -import { Mutex, MutexInterface, withTimeout, E_TIMEOUT } from 'async-mutex'; -import { ControlledError } from '../../common/errors/controlled'; +import { E_TIMEOUT, Mutex, MutexInterface, withTimeout } from 'async-mutex'; +import { ServerError } from '../../common/errors'; @Injectable() export class MutexManagerService implements OnModuleDestroy { @@ -60,9 +60,6 @@ export class MutexManagerService implements OnModuleDestroy { }); return result; } catch (e) { - if (e instanceof ControlledError) { - throw e; - } if (e === E_TIMEOUT) { this.logger.error( `Function execution timed out for ${(key as any).id as string}`, @@ -75,7 +72,7 @@ export class MutexManagerService implements OnModuleDestroy { `Function execution failed for ${(key as any).id as string}`, e, ); - throw new Error( + throw new ServerError( `Function execution failed for ${(key as any).id as string}`, ); } diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts index 0cc824795b..d9ce950b3b 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts @@ -4,7 +4,6 @@ import { Delete, Get, Headers, - HttpStatus, Param, Patch, Post, @@ -25,11 +24,14 @@ import { RequestWithUser } from '../../common/types'; import { ChainId } from '@human-protocol/sdk'; import { ErrorPayment } from 'src/common/constants/errors'; import { ServerConfigService } from '../../common/config/server-config.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; import { HEADER_SIGNATURE_KEY } from '../../common/constants'; import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; import { ApiKey } from '../../common/decorators'; -import { ControlledError } from '../../common/errors/controlled'; +import { + ConflictError, + ServerError, + ValidationError, +} from '../../common/errors'; import { WhitelistAuthGuard } from '../../common/guards/whitelist.auth'; import { PageDto } from '../../common/pagination/pagination.dto'; import { RateService } from '../rate/rate.service'; @@ -44,11 +46,11 @@ import { PaymentFiatConfirmDto, PaymentFiatCreateDto, PaymentMethodIdDto, + TokenDto, TokensResponseDto, UserBalanceDto, } from './payment.dto'; import { PaymentService } from './payment.service'; -import { TokenDto } from './payment.dto'; @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -59,7 +61,6 @@ export class PaymentController { private readonly paymentService: PaymentService, private readonly serverConfigService: ServerConfigService, private readonly rateService: RateService, - private readonly web3ConfigService: Web3ConfigService, ) {} @ApiOperation({ @@ -83,10 +84,7 @@ export class PaymentController { try { return this.paymentService.getUserBalance(req.user.id); } catch { - throw new ControlledError( - ErrorPayment.BalanceCouldNotBeRetrieved, - HttpStatus.UNPROCESSABLE_ENTITY, - ); + throw new ServerError(ErrorPayment.BalanceCouldNotBeRetrieved); } } @@ -152,11 +150,7 @@ export class PaymentController { try { return this.rateService.getRate(data.from, data.to); } catch (e) { - throw new ControlledError( - 'Error getting rates', - HttpStatus.CONFLICT, - e.stack, - ); + throw new ConflictError('Error getting rates', e.stack); } } @@ -454,10 +448,7 @@ export class PaymentController { ): Promise { const tokens = TOKEN_ADDRESSES[chainId]; if (!tokens) { - throw new ControlledError( - ErrorPayment.InvalidChainId, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorPayment.InvalidChainId); } return tokens; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 6dfa82c298..4f1d74d264 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -1,57 +1,60 @@ -import { ethers } from 'ethers'; +jest.mock('@human-protocol/sdk'); +jest.mock('../../common/utils/signature', () => ({ + verifySignature: jest.fn().mockReturnValue(true), +})); + +import { faker } from '@faker-js/faker/.'; +import { createMock } from '@golevelup/ts-jest'; +import { HMToken__factory } from '@human-protocol/core/typechain-types'; +import { ChainId, NETWORKS } from '@human-protocol/sdk'; +import { HttpService } from '@nestjs/axios'; +import { ConflictException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; +import { ethers } from 'ethers'; import Stripe from 'stripe'; -import { PaymentService } from './payment.service'; -import { PaymentRepository } from './payment.repository'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { createMock } from '@golevelup/ts-jest'; +import { + MOCK_ADDRESS, + MOCK_PAYMENT_ID, + MOCK_SIGNATURE, + MOCK_TRANSACTION_HASH, + mockConfig, +} from '../../../test/constants'; +import { NetworkConfigService } from '../../common/config/network-config.service'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { StripeConfigService } from '../../common/config/stripe-config.service'; +import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { ErrorPayment, ErrorPostgres, ErrorSignature, } from '../../common/constants/errors'; +import { SortDirection } from '../../common/enums/collection'; +import { Country } from '../../common/enums/job'; import { + PaymentCurrency, PaymentSortField, PaymentSource, PaymentStatus, PaymentType, StripePaymentStatus, VatType, - PaymentCurrency, } from '../../common/enums/payment'; -import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { - MOCK_ADDRESS, - MOCK_PAYMENT_ID, - MOCK_SIGNATURE, - MOCK_TRANSACTION_HASH, - mockConfig, -} from '../../../test/constants'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { Web3Service } from '../web3/web3.service'; -import { HMToken__factory } from '@human-protocol/core/typechain-types'; -import { ChainId, NETWORKS } from '@human-protocol/sdk'; -import { PaymentEntity } from './payment.entity'; + ConflictError, + DatabaseError, + NotFoundError, + ServerError, +} from '../../common/errors'; import { verifySignature } from '../../common/utils/signature'; -import { ConflictException, HttpStatus } from '@nestjs/common'; -import { DatabaseError } from '../../common/errors/database'; -import { StripeConfigService } from '../../common/config/stripe-config.service'; -import { NetworkConfigService } from '../../common/config/network-config.service'; -import { ControlledError } from '../../common/errors/controlled'; +import { JobRepository } from '../job/job.repository'; import { RateService } from '../rate/rate.service'; import { UserRepository } from '../user/user.repository'; -import { JobRepository } from '../job/job.repository'; +import { Web3Service } from '../web3/web3.service'; import { GetPaymentsDto, UserBalanceDto } from './payment.dto'; -import { SortDirection } from '../../common/enums/collection'; -import { Country } from '../../common/enums/job'; -import { faker } from '@faker-js/faker/.'; - -jest.mock('@human-protocol/sdk'); - -jest.mock('../../common/utils/signature', () => ({ - verifySignature: jest.fn().mockReturnValue(true), -})); +import { PaymentEntity } from './payment.entity'; +import { PaymentRepository } from './payment.repository'; +import { PaymentService } from './payment.service'; describe('PaymentService', () => { let stripe: Stripe; @@ -283,10 +286,7 @@ describe('PaymentService', () => { await expect( paymentService.createFiatPayment(user as any, dto), ).rejects.toThrow( - new ControlledError( - ErrorPayment.TransactionAlreadyExists, - HttpStatus.BAD_REQUEST, - ), + new ConflictError(ErrorPayment.TransactionAlreadyExists), ); }); @@ -321,12 +321,7 @@ describe('PaymentService', () => { await expect( paymentService.createFiatPayment(user as any, dto), - ).rejects.toThrow( - new ControlledError( - ErrorPayment.ClientSecretDoesNotExist, - HttpStatus.NOT_FOUND, - ), - ); + ).rejects.toThrow(new ServerError(ErrorPayment.ClientSecretDoesNotExist)); }); }); @@ -395,9 +390,7 @@ describe('PaymentService', () => { await expect( paymentService.confirmFiatPayment(userId, dto), - ).rejects.toThrow( - new ControlledError(ErrorPayment.NotSuccess, HttpStatus.BAD_REQUEST), - ); + ).rejects.toThrow(new ConflictError(ErrorPayment.NotSuccess)); }); it('should handle payment requiring a payment method', async () => { @@ -425,9 +418,7 @@ describe('PaymentService', () => { await expect( paymentService.confirmFiatPayment(userId, dto), - ).rejects.toThrow( - new ControlledError(ErrorPayment.NotSuccess, HttpStatus.BAD_REQUEST), - ); + ).rejects.toThrow(new ConflictError(ErrorPayment.NotSuccess)); }); it('should handle payment status other than succeeded', async () => { @@ -468,9 +459,7 @@ describe('PaymentService', () => { await expect( paymentService.confirmFiatPayment(userId, dto), - ).rejects.toThrow( - new ControlledError(ErrorPayment.NotFound, HttpStatus.NOT_FOUND), - ); + ).rejects.toThrow(new NotFoundError(ErrorPayment.NotFound)); }); }); @@ -600,9 +589,7 @@ describe('PaymentService', () => { await expect( paymentService.createCryptoPayment(userId, dto, MOCK_SIGNATURE), - ).rejects.toThrow( - new ControlledError(ErrorPayment.UnsupportedToken, HttpStatus.CONFLICT), - ); + ).rejects.toThrow(new ConflictError(ErrorPayment.UnsupportedToken)); }); it('should throw a conflict exception if an unsupported token is used', async () => { @@ -641,9 +628,7 @@ describe('PaymentService', () => { await expect( paymentService.createCryptoPayment(userId, dto, MOCK_SIGNATURE), - ).rejects.toThrow( - new ControlledError(ErrorPayment.UnsupportedToken, HttpStatus.CONFLICT), - ); + ).rejects.toThrow(new ConflictError(ErrorPayment.UnsupportedToken)); }); it('should throw a signature error if the signature is wrong', async () => { @@ -668,12 +653,7 @@ describe('PaymentService', () => { await expect( paymentService.createCryptoPayment(userId, dto, MOCK_SIGNATURE), - ).rejects.toThrow( - new ControlledError( - ErrorSignature.SignatureNotVerified, - HttpStatus.CONFLICT, - ), - ); + ).rejects.toThrow(new ConflictError(ErrorSignature.SignatureNotVerified)); }); it('should throw a not found exception if the transaction is not found by hash', async () => { @@ -688,10 +668,7 @@ describe('PaymentService', () => { await expect( paymentService.createCryptoPayment(userId, dto, MOCK_SIGNATURE), ).rejects.toThrow( - new ControlledError( - ErrorPayment.TransactionNotFoundByHash, - HttpStatus.NOT_FOUND, - ), + new NotFoundError(ErrorPayment.TransactionNotFoundByHash), ); }); @@ -714,12 +691,7 @@ describe('PaymentService', () => { await expect( paymentService.createCryptoPayment(userId, dto, MOCK_SIGNATURE), - ).rejects.toThrow( - new ControlledError( - ErrorPayment.InvalidTransactionData, - HttpStatus.NOT_FOUND, - ), - ); + ).rejects.toThrow(new ServerError(ErrorPayment.InvalidTransactionData)); }); it('should throw a not found exception if the transaction has insufficient confirmations', async () => { @@ -757,9 +729,8 @@ describe('PaymentService', () => { await expect( paymentService.createCryptoPayment(userId, dto, MOCK_SIGNATURE), ).rejects.toThrow( - new ControlledError( + new ConflictError( ErrorPayment.TransactionHasNotEnoughAmountOfConfirmations, - HttpStatus.NOT_FOUND, ), ); }); @@ -801,10 +772,7 @@ describe('PaymentService', () => { await expect( paymentService.createCryptoPayment(userId, dto, MOCK_SIGNATURE), ).rejects.toThrow( - new ControlledError( - ErrorPayment.TransactionAlreadyExists, - HttpStatus.BAD_REQUEST, - ), + new ConflictError(ErrorPayment.TransactionAlreadyExists), ); }); }); @@ -923,12 +891,7 @@ describe('PaymentService', () => { await expect( paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow( - new ControlledError( - ErrorPayment.CustomerNotCreated, - HttpStatus.NOT_FOUND, - ), - ); + ).rejects.toThrow(new ServerError(ErrorPayment.CustomerNotCreated)); }); it('should throw a bad request exception if the setup intent creation fails', async () => { @@ -1210,12 +1173,7 @@ describe('PaymentService', () => { await expect( paymentService.deletePaymentMethod(user as any, 'pm_123'), - ).rejects.toThrow( - new ControlledError( - ErrorPayment.PaymentMethodInUse, - HttpStatus.BAD_REQUEST, - ), - ); + ).rejects.toThrow(new ConflictError(ErrorPayment.PaymentMethodInUse)); }); }); @@ -1508,7 +1466,7 @@ describe('PaymentService', () => { retrievePaymentIntentMock.mockResolvedValue(null); await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( - new ControlledError(ErrorPayment.NotFound, HttpStatus.NOT_FOUND), + new NotFoundError(ErrorPayment.NotFound), ); }); @@ -1524,7 +1482,7 @@ describe('PaymentService', () => { retrieveChargeMock.mockResolvedValue(null); await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( - new ControlledError(ErrorPayment.NotFound, HttpStatus.NOT_FOUND), + new NotFoundError(ErrorPayment.NotFound), ); }); }); @@ -1644,12 +1602,7 @@ describe('PaymentService', () => { await expect( paymentService.createWithdrawalPayment(userId, amount, currency, rate), - ).rejects.toThrow( - new ControlledError( - ErrorPayment.NotEnoughFunds, - HttpStatus.BAD_REQUEST, - ), - ); + ).rejects.toThrow(new ServerError(ErrorPayment.NotEnoughFunds)); }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 412bec9075..db4c2bd3f7 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -1,9 +1,29 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; -import Stripe from 'stripe'; +import { + HMToken, + HMToken__factory, +} from '@human-protocol/core/typechain-types'; +import { Injectable, Logger } from '@nestjs/common'; import { ethers, formatUnits } from 'ethers'; +import Stripe from 'stripe'; +import { NetworkConfigService } from '../../common/config/network-config.service'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { StripeConfigService } from '../../common/config/stripe-config.service'; +import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { ErrorPayment } from '../../common/constants/errors'; -import { PaymentRepository } from './payment.repository'; +import { CoingeckoTokenId } from '../../common/constants/payment'; +import { + FiatCurrency, + PaymentCurrency, + PaymentSource, + PaymentStatus, + PaymentType, + StripePaymentStatus, + VatType, +} from '../../common/enums/payment'; +import { add, div, eq, lt, mul } from '../../common/utils/decimal'; +import { verifySignature } from '../../common/utils/signature'; +import { Web3Service } from '../web3/web3.service'; import { AddressDto, BillingInfoDto, @@ -18,37 +38,18 @@ import { PaymentRefund, UserBalanceDto, } from './payment.dto'; -import { - FiatCurrency, - PaymentCurrency, - PaymentSource, - PaymentStatus, - PaymentType, - StripePaymentStatus, - VatType, -} from '../../common/enums/payment'; -import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; -import { NetworkConfigService } from '../../common/config/network-config.service'; -import { StripeConfigService } from '../../common/config/stripe-config.service'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { - HMToken, - HMToken__factory, -} from '@human-protocol/core/typechain-types'; -import { Web3Service } from '../web3/web3.service'; -import { CoingeckoTokenId } from '../../common/constants/payment'; -import { div, eq, mul, add, lt } from '../../common/utils/decimal'; -import { verifySignature } from '../../common/utils/signature'; import { PaymentEntity } from './payment.entity'; -import { ControlledError } from '../../common/errors/controlled'; -import { RateService } from '../rate/rate.service'; -import { UserEntity } from '../user/user.entity'; -import { UserRepository } from '../user/user.repository'; -import { JobRepository } from '../job/job.repository'; -import { PageDto } from '../../common/pagination/pagination.dto'; +import { PaymentRepository } from './payment.repository'; + import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; import { EscrowFundToken } from '../../common/enums/job'; +import { ConflictError, NotFoundError, ServerError } from '../../common/errors'; +import { PageDto } from '../../common/pagination/pagination.dto'; import { JobEntity } from '../job/job.entity'; +import { JobRepository } from '../job/job.repository'; +import { RateService } from '../rate/rate.service'; +import { UserEntity } from '../user/user.entity'; +import { UserRepository } from '../user/user.repository'; @Injectable() export class PaymentService { @@ -91,10 +92,7 @@ export class PaymentService { ).id; } catch (error) { this.logger.log(error.message, PaymentService.name); - throw new ControlledError( - ErrorPayment.CustomerNotCreated, - HttpStatus.BAD_REQUEST, - ); + throw new ServerError(ErrorPayment.CustomerNotCreated); } } try { @@ -107,10 +105,7 @@ export class PaymentService { }); } catch (error) { this.logger.log(error.message, PaymentService.name); - throw new ControlledError( - ErrorPayment.CardNotAssigned, - HttpStatus.BAD_REQUEST, - ); + throw new ServerError(ErrorPayment.CardNotAssigned); } // Ensure the SetupIntent contains a client secret for completing the card setup process. @@ -119,10 +114,7 @@ export class PaymentService { ErrorPayment.ClientSecretDoesNotExist, PaymentService.name, ); - throw new ControlledError( - ErrorPayment.ClientSecretDoesNotExist, - HttpStatus.NOT_FOUND, - ); + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); } return setupIntent.client_secret; @@ -137,24 +129,29 @@ export class PaymentService { if (!setup) { this.logger.log(ErrorPayment.SetupNotFound, PaymentService.name); - throw new ControlledError( - ErrorPayment.SetupNotFound, - HttpStatus.NOT_FOUND, + throw new NotFoundError(ErrorPayment.SetupNotFound); + } + + let defaultPaymentMethod: string | null = null; + if (!user.stripeCustomerId) { + // Assign the Stripe customer ID to the user if it does not exist yet. + user.stripeCustomerId = setup.customer as string; + await this.userRepository.updateOne(user); + } else { + // Check if the user already has a default payment method. + defaultPaymentMethod = await this.getDefaultPaymentMethod( + user.stripeCustomerId, ); } - if (data.defaultCard || !user.stripeCustomerId) { + + if (data.defaultCard || !defaultPaymentMethod) { // Update Stripe customer settings to use this payment method by default. - await this.stripe.customers.update(setup.customer, { + await this.stripe.customers.update(user.stripeCustomerId, { invoice_settings: { default_payment_method: setup.payment_method, }, }); } - if (!user.stripeCustomerId) { - // Assign the Stripe customer ID to the user if it does not exist yet. - user.stripeCustomerId = setup.customer as string; - await this.userRepository.updateOne(user); - } return true; } @@ -185,10 +182,7 @@ export class PaymentService { ); if (paymentEntity) { - throw new ControlledError( - ErrorPayment.TransactionAlreadyExists, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorPayment.TransactionAlreadyExists); } const newPaymentEntity = new PaymentEntity(); @@ -217,7 +211,7 @@ export class PaymentService { ); if (!paymentData) { - throw new ControlledError(ErrorPayment.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorPayment.NotFound); } const paymentEntity = await this.paymentRepository.findOneByTransaction( @@ -232,7 +226,7 @@ export class PaymentService { !eq(paymentEntity.amount, div(paymentData.amount_received, 100)) || paymentEntity.currency !== paymentData.currency ) { - throw new ControlledError(ErrorPayment.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorPayment.NotFound); } if ( @@ -241,10 +235,7 @@ export class PaymentService { ) { paymentEntity.status = PaymentStatus.FAILED; await this.paymentRepository.updateOne(paymentEntity); - throw new ControlledError( - ErrorPayment.NotSuccess, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorPayment.NotSuccess); } else if (paymentData?.status !== StripePaymentStatus.SUCCEEDED) { return false; // TODO: Handling other cases } @@ -272,28 +263,21 @@ export class PaymentService { ); if (!transaction) { - throw new ControlledError( - ErrorPayment.TransactionNotFoundByHash, - HttpStatus.NOT_FOUND, - ); + throw new NotFoundError(ErrorPayment.TransactionNotFoundByHash); } verifySignature(dto, signature, [transaction.from]); if (!transaction.logs[0] || !transaction.logs[0].data) { - throw new ControlledError( - ErrorPayment.InvalidTransactionData, - HttpStatus.NOT_FOUND, - ); + throw new ServerError(ErrorPayment.InvalidTransactionData); } if ((await transaction.confirmations()) < TX_CONFIRMATION_TRESHOLD) { this.logger.error( `Transaction has ${transaction.confirmations} confirmations instead of ${TX_CONFIRMATION_TRESHOLD}`, ); - throw new ControlledError( + throw new ConflictError( ErrorPayment.TransactionHasNotEnoughAmountOfConfirmations, - HttpStatus.NOT_FOUND, ); } @@ -313,20 +297,14 @@ export class PaymentService { })?.args['_to'], ) !== ethers.hexlify(signer.address) ) { - throw new ControlledError( - ErrorPayment.InvalidRecipient, - HttpStatus.CONFLICT, - ); + throw new ConflictError(ErrorPayment.InvalidRecipient); } const tokenId = (await tokenContract.symbol()).toLowerCase(); const token = TOKEN_ADDRESSES[dto.chainId]?.[tokenId as EscrowFundToken]; if (token?.address !== tokenAddress || !CoingeckoTokenId[tokenId]) { - throw new ControlledError( - ErrorPayment.UnsupportedToken, - HttpStatus.CONFLICT, - ); + throw new ConflictError(ErrorPayment.UnsupportedToken); } const amount = Number( @@ -339,10 +317,7 @@ export class PaymentService { ); if (paymentEntity) { - throw new ControlledError( - ErrorPayment.TransactionAlreadyExists, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorPayment.TransactionAlreadyExists); } const rate = await this.rateService.getRate(tokenId, FiatCurrency.USD); @@ -435,10 +410,7 @@ export class PaymentService { invoice = await this.stripe.invoices.finalizeInvoice(invoice.id); if (!invoice.payment_intent) { - throw new ControlledError( - ErrorPayment.IntentNotCreated, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new ServerError(ErrorPayment.IntentNotCreated); } return invoice; @@ -463,20 +435,14 @@ export class PaymentService { }); } } catch { - throw new ControlledError( - ErrorPayment.PaymentMethodAssociationFailed, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new ServerError(ErrorPayment.PaymentMethodAssociationFailed); } const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); if (!paymentIntent?.client_secret) { - throw new ControlledError( - ErrorPayment.ClientSecretDoesNotExist, - HttpStatus.NOT_FOUND, - ); + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); } return paymentIntent; @@ -489,10 +455,7 @@ export class PaymentService { const user = await this.userRepository.findById(job.userId); if (!user) { this.logger.log(ErrorPayment.CustomerNotFound, PaymentService.name); - throw new ControlledError( - ErrorPayment.CustomerNotFound, - HttpStatus.BAD_REQUEST, - ); + throw new NotFoundError(ErrorPayment.CustomerNotFound); } const amountInCents = Math.ceil(mul(amount, 100)); @@ -508,10 +471,7 @@ export class PaymentService { ); if (!defaultPaymentMethod) { - throw new ControlledError( - ErrorPayment.NotDefaultPaymentMethod, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + throw new ServerError(ErrorPayment.NotDefaultPaymentMethod); } const paymentIntent = await this.handleStripePaymentIntent( @@ -556,10 +516,7 @@ export class PaymentService { // Check if the user has enough balance const userBalance = await this.getUserBalanceByCurrency(userId, currency); if (lt(userBalance, amount)) { - throw new ControlledError( - ErrorPayment.NotEnoughFunds, - HttpStatus.BAD_REQUEST, - ); + throw new ServerError(ErrorPayment.NotEnoughFunds); } const paymentEntity = new PaymentEntity(); @@ -618,10 +575,7 @@ export class PaymentService { (await this.getDefaultPaymentMethod(user.stripeCustomerId)) && (await this.isPaymentMethodInUse(user.id)) ) { - throw new ControlledError( - ErrorPayment.PaymentMethodInUse, - HttpStatus.BAD_REQUEST, - ); + throw new ConflictError(ErrorPayment.PaymentMethodInUse); } // Detach the payment method from the user's account @@ -663,10 +617,7 @@ export class PaymentService { updateBillingInfoDto: BillingInfoDto, ) { if (!user.stripeCustomerId) { - throw new ControlledError( - ErrorPayment.CustomerNotFound, - HttpStatus.BAD_REQUEST, - ); + throw new NotFoundError(ErrorPayment.CustomerNotFound); } // If the VAT or VAT type has changed, update it in Stripe const existingTaxIds = await this.stripe.customers.listTaxIds( @@ -714,10 +665,7 @@ export class PaymentService { async getDefaultPaymentMethod(customerId: string): Promise { if (!customerId) { - throw new ControlledError( - ErrorPayment.CustomerNotFound, - HttpStatus.BAD_REQUEST, - ); + throw new NotFoundError(ErrorPayment.CustomerNotFound); } // Retrieve the customer from Stripe and return the default payment method @@ -770,7 +718,7 @@ export class PaymentService { const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentId); if (!paymentIntent || paymentIntent.customer !== user.stripeCustomerId) { - throw new ControlledError(ErrorPayment.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorPayment.NotFound); } // Retrieve the charge for the payment intent and ensure it has a receipt URL @@ -778,7 +726,7 @@ export class PaymentService { paymentIntent.latest_charge as string, ); if (!charge || !charge.receipt_url) { - throw new ControlledError(ErrorPayment.NotFound, HttpStatus.NOT_FOUND); + throw new NotFoundError(ErrorPayment.NotFound); } return charge.receipt_url; diff --git a/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.spec.ts b/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.spec.ts index f214d95a69..5136a5e6af 100644 --- a/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.spec.ts @@ -1,27 +1,26 @@ -import { Test } from '@nestjs/testing'; -import { QualificationService } from './qualification.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ConfigService } from '@nestjs/config'; +jest.mock('@human-protocol/sdk', () => ({ + ...jest.requireActual('@human-protocol/sdk'), + KVStoreUtils: { + get: jest.fn(), + }, +})); + +import { ChainId, KVStoreUtils } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; import { of, throwError } from 'rxjs'; -import { ChainId, KVStoreUtils } from '@human-protocol/sdk'; import { MOCK_REPUTATION_ORACLE_URL, MOCK_WEB3_RPC_URL, mockConfig, } from '../../../test/constants'; -import { ControlledError } from '../../common/errors/controlled'; -import { ErrorQualification, ErrorWeb3 } from '../../common/constants/errors'; -import { HttpStatus } from '@nestjs/common'; import { NetworkConfigService } from '../../common/config/network-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { ErrorQualification, ErrorWeb3 } from '../../common/constants/errors'; +import { ServerError, ValidationError } from '../../common/errors'; import { Web3Service } from '../web3/web3.service'; - -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - KVStoreUtils: { - get: jest.fn(), - }, -})); +import { QualificationService } from './qualification.service'; describe.only('QualificationService', () => { let qualificationService: QualificationService, httpService: HttpService; @@ -97,37 +96,31 @@ describe.only('QualificationService', () => { expect(result).toEqual(qualifications); }); - it('should throw a ControlledError when KVStoreUtils.get fails', async () => { + it('should throw a ServerError when KVStoreUtils.get fails', async () => { (KVStoreUtils.get as any).mockRejectedValue(new Error('KV store error')); await expect( qualificationService.getQualifications(ChainId.LOCALHOST), - ).rejects.toThrow( - new ControlledError( - ErrorWeb3.ReputationOracleUrlNotSet, - HttpStatus.BAD_REQUEST, - ), - ); + ).rejects.toThrow(new ServerError(ErrorWeb3.ReputationOracleUrlNotSet)); }); - it('should throw a ControlledError when HTTP request fails', async () => { + it('should throw a ServerError when HTTP request fails', async () => { (KVStoreUtils.get as any).mockResolvedValue(MOCK_REPUTATION_ORACLE_URL); jest .spyOn(httpService, 'get') - .mockImplementation(() => throwError(new Error('HTTP error')) as any); + .mockImplementation( + () => throwError(() => new Error('HTTP error')) as any, + ); await expect( qualificationService.getQualifications(ChainId.LOCALHOST), ).rejects.toThrow( - new ControlledError( - ErrorQualification.FailedToFetchQualifications, - HttpStatus.BAD_REQUEST, - ), + new ServerError(ErrorQualification.FailedToFetchQualifications), ); }); - it('should throw a ControlledError when invalid chainId', async () => { + it('should throw a ValidationError when invalid chainId', async () => { (KVStoreUtils.get as any).mockResolvedValue(MOCK_REPUTATION_ORACLE_URL); jest @@ -136,9 +129,7 @@ describe.only('QualificationService', () => { await expect( qualificationService.getQualifications(ChainId.MAINNET), - ).rejects.toThrow( - new ControlledError(ErrorWeb3.InvalidChainId, HttpStatus.BAD_REQUEST), - ); + ).rejects.toThrow(new ValidationError(ErrorWeb3.InvalidChainId)); }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.ts b/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.ts index 9e51d255fc..00837e962a 100644 --- a/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.ts +++ b/packages/apps/job-launcher/server/src/modules/qualification/qualification.service.ts @@ -1,12 +1,12 @@ -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; -import { QualificationDto } from './qualification.dto'; -import { firstValueFrom } from 'rxjs'; +import { ChainId, KVStoreKeys, KVStoreUtils } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ControlledError } from '../../common/errors/controlled'; import { ErrorQualification, ErrorWeb3 } from '../../common/constants/errors'; -import { ChainId, KVStoreKeys, KVStoreUtils } from '@human-protocol/sdk'; +import { ServerError } from '../../common/errors'; import { Web3Service } from '../web3/web3.service'; +import { QualificationDto } from './qualification.dto'; @Injectable() export class QualificationService { @@ -21,26 +21,22 @@ export class QualificationService { public async getQualifications( chainId: ChainId, ): Promise { - try { - let reputationOracleUrl = ''; + let reputationOracleUrl = ''; + this.web3Service.validateChainId(chainId); - this.web3Service.validateChainId(chainId); - - try { - reputationOracleUrl = await KVStoreUtils.get( - chainId, - this.web3ConfigService.reputationOracleAddress, - KVStoreKeys.url, - ); - } catch {} + try { + reputationOracleUrl = await KVStoreUtils.get( + chainId, + this.web3ConfigService.reputationOracleAddress, + KVStoreKeys.url, + ); + } catch {} - if (!reputationOracleUrl || reputationOracleUrl === '') { - throw new ControlledError( - ErrorWeb3.ReputationOracleUrlNotSet, - HttpStatus.BAD_REQUEST, - ); - } + if (!reputationOracleUrl || reputationOracleUrl === '') { + throw new ServerError(ErrorWeb3.ReputationOracleUrlNotSet); + } + try { const { data } = await firstValueFrom( this.httpService.get( `${reputationOracleUrl}/qualifications`, @@ -49,14 +45,10 @@ export class QualificationService { return data; } catch (error) { - if (error instanceof ControlledError) { - throw error; - } else { - throw new ControlledError( - ErrorQualification.FailedToFetchQualifications, - HttpStatus.BAD_REQUEST, - ); - } + this.logger.error( + `Error fetching qualifications from reputation oracle: ${error}`, + ); + throw new ServerError(ErrorQualification.FailedToFetchQualifications); } } } diff --git a/packages/apps/job-launcher/server/src/modules/rate/rate.service.spec.ts b/packages/apps/job-launcher/server/src/modules/rate/rate.service.spec.ts index fb2e3db575..c473f0dffa 100644 --- a/packages/apps/job-launcher/server/src/modules/rate/rate.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/rate/rate.service.spec.ts @@ -1,13 +1,12 @@ import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { of } from 'rxjs'; -import { RateService } from './rate.service'; -import { ControlledError } from '../../common/errors/controlled'; -import { ErrorCurrency } from '../../common/constants/errors'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { HttpStatus } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { mockConfig } from '../../../test/constants'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { ErrorCurrency } from '../../common/constants/errors'; +import { NotFoundError } from '../../common/errors'; +import { RateService } from './rate.service'; describe('RateService', () => { let service: RateService; @@ -89,8 +88,8 @@ describe('RateService', () => { }) as any, ); - await expect(service.getRate(from, to)).rejects.toThrowError( - new ControlledError(ErrorCurrency.PairNotFound, HttpStatus.NOT_FOUND), + await expect(service.getRate(from, to)).rejects.toThrow( + new NotFoundError(ErrorCurrency.PairNotFound), ); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/rate/rate.service.ts b/packages/apps/job-launcher/server/src/modules/rate/rate.service.ts index 2d15ed2d57..8c5b008e62 100644 --- a/packages/apps/job-launcher/server/src/modules/rate/rate.service.ts +++ b/packages/apps/job-launcher/server/src/modules/rate/rate.service.ts @@ -1,12 +1,12 @@ import { HttpService } from '@nestjs/axios'; -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { firstValueFrom } from 'rxjs'; import { ServerConfigService } from '../../common/config/server-config.service'; import { COINGECKO_API_URL } from '../../common/constants'; import { ErrorCurrency } from '../../common/constants/errors'; import { CoingeckoTokenId } from '../../common/constants/payment'; -import { ControlledError } from '../../common/errors/controlled'; import { EscrowFundToken } from '../../common/enums/job'; +import { NotFoundError } from '../../common/errors'; @Injectable() export class RateService { @@ -67,10 +67,7 @@ export class RateService { )) as any; if (!data[coingeckoFrom] || !data[coingeckoFrom][coingeckoTo]) { - throw new ControlledError( - ErrorCurrency.PairNotFound, - HttpStatus.NOT_FOUND, - ); + throw new NotFoundError(ErrorCurrency.PairNotFound); } const rate = data[coingeckoFrom][coingeckoTo]; const finalRate = reversed ? 1 / rate : rate; @@ -80,10 +77,7 @@ export class RateService { return finalRate; } catch (error) { this.logger.error(error); - throw new ControlledError( - ErrorCurrency.PairNotFound, - HttpStatus.NOT_FOUND, - ); + throw new NotFoundError(ErrorCurrency.PairNotFound); } } } diff --git a/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.spec.ts b/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.spec.ts index b08b0a94b1..9893a6d342 100644 --- a/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.spec.ts @@ -1,18 +1,3 @@ -import { Test } from '@nestjs/testing'; - -import { RoutingProtocolService } from './routing-protocol.service'; -import { ChainId, Role } from '@human-protocol/sdk'; -import { MOCK_REPUTATION_ORACLE_1, mockConfig } from '../../../test/constants'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { NetworkConfigService } from '../../common/config/network-config.service'; -import { Web3Service } from '../web3/web3.service'; -import { ConfigService } from '@nestjs/config'; -import { ControlledError } from '../../common/errors/controlled'; -import { FortuneJobType } from '../../common/enums/job'; -import { ErrorRoutingProtocol } from '../../common/constants/errors'; -import { HttpStatus } from '@nestjs/common'; -import { hashString } from '../../common/utils'; - jest.mock('../../common/utils', () => ({ ...jest.requireActual('../../common/utils'), hashString: jest.fn(), @@ -25,6 +10,19 @@ jest.mock('@human-protocol/sdk', () => ({ }, })); +import { ChainId, Role } from '@human-protocol/sdk'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { MOCK_REPUTATION_ORACLE_1, mockConfig } from '../../../test/constants'; +import { NetworkConfigService } from '../../common/config/network-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { ErrorRoutingProtocol } from '../../common/constants/errors'; +import { FortuneJobType } from '../../common/enums/job'; +import { ServerError } from '../../common/errors'; +import { hashString } from '../../common/utils'; +import { Web3Service } from '../web3/web3.service'; +import { RoutingProtocolService } from './routing-protocol.service'; + describe('RoutingProtocolService', () => { let web3Service: Web3Service; let routingProtocolService: RoutingProtocolService; @@ -422,10 +420,7 @@ describe('RoutingProtocolService', () => { invalidReputationOracle, ), ).rejects.toThrow( - new ControlledError( - ErrorRoutingProtocol.ReputationOracleNotFound, - HttpStatus.NOT_FOUND, - ), + new ServerError(ErrorRoutingProtocol.ReputationOracleNotFound), ); }); @@ -454,10 +449,7 @@ describe('RoutingProtocolService', () => { 'invalidExchangeOracle', ), ).rejects.toThrow( - new ControlledError( - ErrorRoutingProtocol.ExchangeOracleNotFound, - HttpStatus.NOT_FOUND, - ), + new ServerError(ErrorRoutingProtocol.ExchangeOracleNotFound), ); }); @@ -487,10 +479,7 @@ describe('RoutingProtocolService', () => { 'invalidRecordingOracle', ), ).rejects.toThrow( - new ControlledError( - ErrorRoutingProtocol.RecordingOracleNotFound, - HttpStatus.NOT_FOUND, - ), + new ServerError(ErrorRoutingProtocol.RecordingOracleNotFound), ); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.ts b/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.ts index b682367678..f57657182b 100644 --- a/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.ts +++ b/packages/apps/job-launcher/server/src/modules/routing-protocol/routing-protocol.service.ts @@ -1,8 +1,7 @@ -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ChainId, Role } from '@human-protocol/sdk'; -import { Web3Service } from '../web3/web3.service'; +import { Injectable, Logger } from '@nestjs/common'; +import { NetworkConfigService } from '../../common/config/network-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { hashString } from '../../common/utils'; import { ErrorRoutingProtocol } from '../../common/constants/errors'; import { AudinoJobType, @@ -10,8 +9,9 @@ import { HCaptchaJobType, JobRequestType, } from '../../common/enums/job'; -import { ControlledError } from '../../common/errors/controlled'; -import { NetworkConfigService } from '../../common/config/network-config.service'; +import { ServerError } from '../../common/errors'; +import { hashString } from '../../common/utils'; +import { Web3Service } from '../web3/web3.service'; import { OracleHash, OracleIndex, @@ -217,10 +217,7 @@ export class RoutingProtocolService { .map((address) => address.trim()); if (!reputationOracles.includes(reputationOracle)) { - throw new ControlledError( - ErrorRoutingProtocol.ReputationOracleNotFound, - HttpStatus.NOT_FOUND, - ); + throw new ServerError(ErrorRoutingProtocol.ReputationOracleNotFound); } const availableOracles = await this.web3Service.findAvailableOracles( @@ -237,10 +234,7 @@ export class RoutingProtocolService { Role.ExchangeOracle, ) ) { - throw new ControlledError( - ErrorRoutingProtocol.ExchangeOracleNotFound, - HttpStatus.NOT_FOUND, - ); + throw new ServerError(ErrorRoutingProtocol.ExchangeOracleNotFound); } if ( @@ -251,10 +245,7 @@ export class RoutingProtocolService { Role.RecordingOracle, ) ) { - throw new ControlledError( - ErrorRoutingProtocol.RecordingOracleNotFound, - HttpStatus.NOT_FOUND, - ); + throw new ServerError(ErrorRoutingProtocol.RecordingOracleNotFound); } } diff --git a/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.spec.ts b/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.spec.ts index 90a0d4c967..91b701f986 100644 --- a/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.spec.ts @@ -1,8 +1,6 @@ import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; -import { SendGridService } from './sendgrid.service'; import { MailService } from '@sendgrid/mail'; -import { ErrorSendGrid } from '../../common/constants/errors'; import { MOCK_SENDGRID_API_KEY, MOCK_SENDGRID_FROM_EMAIL, @@ -10,8 +8,9 @@ import { mockConfig, } from '../../../test/constants'; import { SendgridConfigService } from '../../common/config/sendgrid-config.service'; -import { ControlledError } from '../../common/errors/controlled'; -import { HttpStatus } from '@nestjs/common'; +import { ErrorSendGrid } from '../../common/constants/errors'; +import { ConflictError, ServerError } from '../../common/errors'; +import { SendGridService } from './sendgrid.service'; describe('SendGridService', () => { let sendGridService: SendGridService; @@ -102,9 +101,7 @@ describe('SendGridService', () => { text: 'and easy to do anywhere, even with Node.js', html: 'and easy to do anywhere, even with Node.js', }), - ).rejects.toThrow( - new ControlledError(ErrorSendGrid.EmailNotSent, HttpStatus.BAD_REQUEST), - ); + ).rejects.toThrow(new ServerError(ErrorSendGrid.EmailNotSent)); }); }); @@ -132,9 +129,7 @@ describe('SendGridService', () => { mailService, configService as any, ); - }).toThrow( - new ControlledError(ErrorSendGrid.InvalidApiKey, HttpStatus.CONFLICT), - ); + }).toThrow(new ConflictError(ErrorSendGrid.InvalidApiKey)); }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.ts b/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.ts index bcadbfc8d4..5a5c9bd71f 100644 --- a/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.ts +++ b/packages/apps/job-launcher/server/src/modules/sendgrid/sendgrid.service.ts @@ -1,12 +1,12 @@ -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { MailDataRequired, MailService } from '@sendgrid/mail'; +import { SendgridConfigService } from '../../common/config/sendgrid-config.service'; import { SENDGRID_API_KEY_DISABLED, SENDGRID_API_KEY_REGEX, } from '../../common/constants'; import { ErrorSendGrid } from '../../common/constants/errors'; -import { SendgridConfigService } from '../../common/config/sendgrid-config.service'; -import { ControlledError } from '../../common/errors/controlled'; +import { ConflictError, ServerError } from '../../common/errors'; @Injectable() export class SendGridService { @@ -24,10 +24,7 @@ export class SendGridService { } if (!SENDGRID_API_KEY_REGEX.test(this.sendgridConfigService.apiKey)) { - throw new ControlledError( - ErrorSendGrid.InvalidApiKey, - HttpStatus.CONFLICT, - ); + throw new ConflictError(ErrorSendGrid.InvalidApiKey); } this.mailService.setApiKey(this.sendgridConfigService.apiKey); @@ -61,10 +58,7 @@ export class SendGridService { return; } catch (error) { this.logger.error(error, SendGridService.name); - throw new ControlledError( - ErrorSendGrid.EmailNotSent, - HttpStatus.BAD_REQUEST, - ); + throw new ServerError(ErrorSendGrid.EmailNotSent); } } } diff --git a/packages/apps/job-launcher/server/src/modules/storage/storage.errors.ts b/packages/apps/job-launcher/server/src/modules/storage/storage.errors.ts deleted file mode 100644 index b4eecc4ce4..0000000000 --- a/packages/apps/job-launcher/server/src/modules/storage/storage.errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BaseError } from '../../common/errors/base'; - -export class FileDownloadError extends BaseError { - public readonly location: string; - - constructor(location: string, cause?: unknown) { - super('Failed to download file', cause); - - this.location = location; - } -} - -export class InvalidFileUrl extends FileDownloadError { - constructor(url: string) { - super(url); - this.message = 'Invalid file URL'; - } -} - -export class FileNotFoundError extends FileDownloadError { - constructor(location: string) { - super(location); - this.message = 'File not found'; - } -} diff --git a/packages/apps/job-launcher/server/src/modules/storage/storage.service.spec.ts b/packages/apps/job-launcher/server/src/modules/storage/storage.service.spec.ts index 9ac3883148..d751cc26dd 100644 --- a/packages/apps/job-launcher/server/src/modules/storage/storage.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/storage/storage.service.spec.ts @@ -14,10 +14,11 @@ jest.mock('minio', () => { jest.mock('axios'); -import { Encryption, EncryptionUtils, HttpStatus } from '@human-protocol/sdk'; +import { Encryption, EncryptionUtils } from '@human-protocol/sdk'; import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import axios from 'axios'; +import stringify from 'json-stable-stringify'; import { MOCK_FILE_URL, MOCK_MANIFEST, @@ -31,18 +32,12 @@ import { MOCK_S3_USE_SSL, mockConfig, } from '../../../test/constants'; -import { StorageService } from './storage.service'; -import stringify from 'json-stable-stringify'; -import { ErrorBucket } from '../../common/constants/errors'; -import { hashString } from '../../common/utils'; -import { ContentType } from '../../common/enums/storage'; import { S3ConfigService } from '../../common/config/s3-config.service'; -import { ControlledError } from '../../common/errors/controlled'; -import { - FileDownloadError, - FileNotFoundError, - InvalidFileUrl, -} from './storage.errors'; +import { ErrorBucket, ErrorStorage } from '../../common/constants/errors'; +import { ContentType } from '../../common/enums/storage'; +import { ServerError, ValidationError } from '../../common/errors'; +import { hashString } from '../../common/utils'; +import { StorageService } from './storage.service'; describe('StorageService', () => { let storageService: StorageService; @@ -98,7 +93,10 @@ describe('StorageService', () => { thrownError = error; } - expect(thrownError).toBeInstanceOf(InvalidFileUrl); + expect(thrownError).toBeInstanceOf(ValidationError); + expect(thrownError.message).toContain( + `${ErrorStorage.InvalidUrl}: ${url}`, + ); }, ); @@ -122,11 +120,13 @@ describe('StorageService', () => { thrownError = error; } - expect(thrownError).toBeInstanceOf(FileNotFoundError); - expect(thrownError.location).toBe(testUrl); + expect(thrownError).toBeInstanceOf(ServerError); + expect(thrownError.message).toContain( + `${ErrorStorage.NotFound}: ${testUrl}`, + ); }); - it('throws if netrowk error', async () => { + it('throws if network error', async () => { const testUrl = 'https://network-error.io'; const testError = new Error('ECONNRESET :443'); (axios.get as jest.Mock).mockImplementationOnce((url) => { @@ -145,9 +145,10 @@ describe('StorageService', () => { thrownError = error; } - expect(thrownError).toBeInstanceOf(FileDownloadError); - expect(thrownError.location).toBe(testUrl); - expect(thrownError.cause).toBe(testError); + expect(thrownError).toBeInstanceOf(ServerError); + expect(thrownError.message).toContain( + `${ErrorStorage.FailedToDownload}: ${testUrl}`, + ); }); it('returns response as buffer', async () => { @@ -203,9 +204,7 @@ describe('StorageService', () => { await expect( storageService.uploadJsonLikeData(MOCK_MANIFEST), - ).rejects.toThrow( - new ControlledError(ErrorBucket.NotExist, HttpStatus.BAD_REQUEST), - ); + ).rejects.toThrow(new ServerError(ErrorBucket.NotExist)); }); it('should fail if the file cannot be uploaded', async () => { @@ -218,9 +217,7 @@ describe('StorageService', () => { await expect( storageService.uploadJsonLikeData(MOCK_MANIFEST), - ).rejects.toThrow( - new ControlledError('File not uploaded', HttpStatus.BAD_REQUEST), - ); + ).rejects.toThrow(new ServerError(ErrorStorage.FileNotUploaded)); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/storage/storage.service.ts b/packages/apps/job-launcher/server/src/modules/storage/storage.service.ts index dae1d7218f..972d094a34 100644 --- a/packages/apps/job-launcher/server/src/modules/storage/storage.service.ts +++ b/packages/apps/job-launcher/server/src/modules/storage/storage.service.ts @@ -1,19 +1,14 @@ import { Encryption, EncryptionUtils } from '@human-protocol/sdk'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import axios from 'axios'; -import * as Minio from 'minio'; import stringify from 'json-stable-stringify'; -import { ErrorBucket } from '../../common/constants/errors'; +import * as Minio from 'minio'; +import { S3ConfigService } from '../../common/config/s3-config.service'; +import { ErrorBucket, ErrorStorage } from '../../common/constants/errors'; import { ContentType, Extension } from '../../common/enums/storage'; +import { ServerError, ValidationError } from '../../common/errors'; import { UploadedFile } from '../../common/interfaces'; -import { S3ConfigService } from '../../common/config/s3-config.service'; -import { ControlledError } from '../../common/errors/controlled'; import { hashString } from '../../common/utils'; -import { - FileDownloadError, - FileNotFoundError, - InvalidFileUrl, -} from './storage.errors'; @Injectable() export class StorageService { @@ -43,7 +38,7 @@ export class StorageService { public static async downloadFileFromUrl(url: string): Promise { if (!this.isValidUrl(url)) { - throw new InvalidFileUrl(url); + throw new ValidationError(`${ErrorStorage.InvalidUrl}: ${url}`); } try { @@ -54,9 +49,9 @@ export class StorageService { return Buffer.from(data); } catch (error) { if (error.response?.status === HttpStatus.NOT_FOUND) { - throw new FileNotFoundError(url); + throw new ServerError(`${ErrorStorage.NotFound}: ${url}`); } - throw new FileDownloadError(url, error.cause || error.message); + throw new ServerError(`${ErrorStorage.FailedToDownload}: ${url}`); } } @@ -106,7 +101,7 @@ export class StorageService { data: string | object, ): Promise { if (!(await this.minioClient.bucketExists(this.s3ConfigService.bucket))) { - throw new ControlledError(ErrorBucket.NotExist, HttpStatus.BAD_REQUEST); + throw new ServerError(ErrorBucket.NotExist); } let fileContents: string; @@ -141,7 +136,7 @@ export class StorageService { hash, }; } catch (_error) { - throw new ControlledError('File not uploaded', HttpStatus.BAD_REQUEST); + throw new ServerError(ErrorStorage.FileNotUploaded); } } } diff --git a/packages/apps/job-launcher/server/src/modules/user/user.service.spec.ts b/packages/apps/job-launcher/server/src/modules/user/user.service.spec.ts index 3aa229aad5..542c5f4e96 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.service.spec.ts @@ -1,3 +1,5 @@ +jest.mock('@human-protocol/sdk'); + import { createMock } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; @@ -8,8 +10,6 @@ import { UserEntity } from './user.entity'; import { UserRepository } from './user.repository'; import { UserService } from './user.service'; -jest.mock('@human-protocol/sdk'); - describe('UserService', () => { let userService: UserService; let userRepository: UserRepository; diff --git a/packages/apps/job-launcher/server/src/modules/web3/web3.service.spec.ts b/packages/apps/job-launcher/server/src/modules/web3/web3.service.spec.ts index ba65357123..96daa6a720 100644 --- a/packages/apps/job-launcher/server/src/modules/web3/web3.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/web3/web3.service.spec.ts @@ -1,9 +1,11 @@ import { ChainId, OperatorUtils, Role } from '@human-protocol/sdk'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; +import { NetworkConfigService } from '../../common/config/network-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ErrorWeb3 } from '../../common/constants/errors'; import { Web3Env } from '../../common/enums/web3'; -import { Web3Service } from './web3.service'; +import { ConflictError, ValidationError } from '../../common/errors'; import { MOCK_ADDRESS, MOCK_EXCHANGE_ORACLE_URL, @@ -11,11 +13,8 @@ import { MOCK_REPUTATION_ORACLES, mockConfig, } from './../../../test/constants'; -import { NetworkConfigService } from '../../common/config/network-config.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ControlledError } from '../../common/errors/controlled'; -import { HttpStatus } from '@nestjs/common'; import { OracleDataDto } from './web3.dto'; +import { Web3Service } from './web3.service'; jest.mock('@human-protocol/sdk', () => { const actualSdk = jest.requireActual('@human-protocol/sdk'); @@ -70,9 +69,7 @@ describe('Web3Service', () => { new Web3ConfigService(configService), new NetworkConfigService(configService), ), - ).toThrow( - new ControlledError(ErrorWeb3.NoValidNetworks, HttpStatus.BAD_REQUEST), - ); + ).toThrow(new Error(ErrorWeb3.NoValidNetworks)); }); }); @@ -92,7 +89,7 @@ describe('Web3Service', () => { const invalidChainId = ChainId.POLYGON; expect(() => web3Service.getSigner(invalidChainId)).toThrow( - new ControlledError(ErrorWeb3.InvalidChainId, HttpStatus.BAD_REQUEST), + new ValidationError(ErrorWeb3.InvalidChainId), ); }); }); @@ -137,9 +134,7 @@ describe('Web3Service', () => { await expect( web3Service.calculateGasPrice(ChainId.POLYGON_AMOY), - ).rejects.toThrow( - new ControlledError(ErrorWeb3.GasPriceError, HttpStatus.CONFLICT), - ); + ).rejects.toThrow(new ConflictError(ErrorWeb3.GasPriceError)); }); }); @@ -153,7 +148,7 @@ describe('Web3Service', () => { it('should throw an error for an invalid chainId', () => { const invalidChainId = ChainId.POLYGON; expect(() => web3Service.validateChainId(invalidChainId)).toThrow( - new ControlledError(ErrorWeb3.InvalidChainId, HttpStatus.BAD_REQUEST), + new ValidationError(ErrorWeb3.InvalidChainId), ); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/web3/web3.service.ts b/packages/apps/job-launcher/server/src/modules/web3/web3.service.ts index 548e6d6a58..b05e971802 100644 --- a/packages/apps/job-launcher/server/src/modules/web3/web3.service.ts +++ b/packages/apps/job-launcher/server/src/modules/web3/web3.service.ts @@ -1,11 +1,11 @@ -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { ChainId, OperatorUtils, Role } from '@human-protocol/sdk'; +import { Injectable, Logger } from '@nestjs/common'; import { Wallet, ethers } from 'ethers'; import { NetworkConfigService } from '../../common/config/network-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ErrorWeb3 } from '../../common/constants/errors'; -import { ControlledError } from '../../common/errors/controlled'; +import { ConflictError, ValidationError } from '../../common/errors'; import { AvailableOraclesDto, OracleDataDto } from './web3.dto'; -import { ChainId, OperatorUtils, Role } from '@human-protocol/sdk'; @Injectable() export class Web3Service { @@ -20,10 +20,7 @@ export class Web3Service { const privateKey = this.web3ConfigService.privateKey; if (!this.networkConfigService.networks.length) { - throw new ControlledError( - ErrorWeb3.NoValidNetworks, - HttpStatus.BAD_REQUEST, - ); + throw new Error(ErrorWeb3.NoValidNetworks); } for (const network of this.networkConfigService.networks) { @@ -39,10 +36,7 @@ export class Web3Service { public validateChainId(chainId: number): void { if (!this.signers[chainId]) { - throw new ControlledError( - ErrorWeb3.InvalidChainId, - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError(ErrorWeb3.InvalidChainId); } } @@ -54,7 +48,7 @@ export class Web3Service { if (gasPrice) { return gasPrice * BigInt(multiplier); } - throw new ControlledError(ErrorWeb3.GasPriceError, HttpStatus.CONFLICT); + throw new ConflictError(ErrorWeb3.GasPriceError); } public getOperatorAddress(): string { diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts index ef91574786..35c82ff14d 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts @@ -1,16 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WebhookController } from './webhook.controller'; -import { WebhookService } from './webhook.service'; -import { WebhookRepository } from './webhook.repository'; -import { Web3Service } from '../web3/web3.service'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { JobService } from '../job/job.service'; -import { EventType } from '../../common/enums/webhook'; -import { ChainId } from '@human-protocol/sdk'; -import { WebhookDataDto } from './webhook.dto'; +jest.mock('@human-protocol/sdk'); + import { createMock } from '@golevelup/ts-jest'; -import { BadRequestException, HttpStatus } from '@nestjs/common'; +import { ChainId } from '@human-protocol/sdk'; +import { HttpService } from '@nestjs/axios'; +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; import { MOCK_ADDRESS, MOCK_CVAT_JOB_SIZE, @@ -19,6 +14,7 @@ import { MOCK_CVAT_VAL_SIZE, MOCK_EXPIRES_IN, MOCK_HCAPTCHA_SITE_KEY, + MOCK_MAX_RETRY_COUNT, MOCK_PGP_PASSPHRASE, MOCK_PGP_PRIVATE_KEY, MOCK_PRIVATE_KEY, @@ -34,14 +30,18 @@ import { MOCK_STRIPE_API_VERSION, MOCK_STRIPE_APP_INFO_URL, MOCK_STRIPE_SECRET_KEY, - MOCK_MAX_RETRY_COUNT, } from '../../../test/constants'; import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ControlledError } from '../../common/errors/controlled'; +import { EventType } from '../../common/enums/webhook'; +import { ValidationError } from '../../common/errors'; import { JobRepository } from '../job/job.repository'; - -jest.mock('@human-protocol/sdk'); +import { JobService } from '../job/job.service'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookController } from './webhook.controller'; +import { WebhookDataDto } from './webhook.dto'; +import { WebhookRepository } from './webhook.repository'; +import { WebhookService } from './webhook.service'; describe('WebhookController', () => { let webhookController: WebhookController; @@ -163,17 +163,12 @@ describe('WebhookController', () => { jest .spyOn(jobService, 'escrowFailedWebhook') .mockImplementation(async () => { - throw new ControlledError( - 'Invalid manifest URL', - HttpStatus.BAD_REQUEST, - ); + throw new ValidationError('Invalid manifest URL'); }); await expect( webhookController.processWebhook(invalidDto), - ).rejects.toThrow( - new ControlledError('Invalid manifest URL', HttpStatus.BAD_REQUEST), - ); + ).rejects.toThrow(new ValidationError('Invalid manifest URL')); expect(jobService.escrowFailedWebhook).toHaveBeenCalledWith(invalidDto); }); diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts index 5d70b8157d..5456323842 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts @@ -1,8 +1,18 @@ +jest.mock('@human-protocol/sdk', () => ({ + ...jest.requireActual('@human-protocol/sdk'), + EscrowClient: { + build: jest.fn(), + }, +})); + +import { faker } from '@faker-js/faker/.'; import { createMock } from '@golevelup/ts-jest'; import { ChainId, EscrowClient, KVStoreUtils } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; +import { HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; +import { of, throwError } from 'rxjs'; import { MOCK_ADDRESS, MOCK_EXCHANGE_ORACLE_ADDRESS, @@ -10,34 +20,24 @@ import { MOCK_MAX_RETRY_COUNT, mockConfig, } from '../../../test/constants'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { HEADER_SIGNATURE_KEY } from '../../common/constants'; import { ErrorWebhook } from '../../common/constants/errors'; +import { FortuneJobType } from '../../common/enums/job'; import { EventType, OracleType, WebhookStatus, } from '../../common/enums/webhook'; +import { ServerError, ValidationError } from '../../common/errors'; +import { JobRepository } from '../job/job.repository'; +import { JobService } from '../job/job.service'; import { Web3Service } from '../web3/web3.service'; +import { WebhookDataDto } from './webhook.dto'; import { WebhookEntity } from './webhook.entity'; import { WebhookRepository } from './webhook.repository'; import { WebhookService } from './webhook.service'; -import { of } from 'rxjs'; -import { HEADER_SIGNATURE_KEY } from '../../common/constants'; -import { JobService } from '../job/job.service'; -import { WebhookDataDto } from './webhook.dto'; -import { HttpStatus } from '@nestjs/common'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { ControlledError } from '../../common/errors/controlled'; -import { JobRepository } from '../job/job.repository'; -import { FortuneJobType } from '../../common/enums/job'; -import { faker } from '@faker-js/faker/.'; - -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - EscrowClient: { - build: jest.fn(), - }, -})); describe('WebhookService', () => { let webhookService: WebhookService, @@ -122,9 +122,7 @@ describe('WebhookService', () => { .mockResolvedValue(''); await expect( (webhookService as any).sendWebhook(webhookEntity), - ).rejects.toThrow( - new ControlledError(ErrorWebhook.UrlNotFound, HttpStatus.NOT_FOUND), - ); + ).rejects.toThrow(new ServerError(ErrorWebhook.UrlNotFound)); }); it('should handle error if any exception is thrown', async () => { @@ -132,15 +130,11 @@ describe('WebhookService', () => { .spyOn(webhookService as any, 'getExchangeOracleWebhookUrl') .mockResolvedValue(MOCK_EXCHANGE_ORACLE_WEBHOOK_URL); jest.spyOn(httpService as any, 'post').mockImplementation(() => { - return of({ - data: undefined, - }); + return throwError(() => new Error('HTTP request failed')); }); await expect( (webhookService as any).sendWebhook(webhookEntity), - ).rejects.toThrow( - new ControlledError(ErrorWebhook.NotSent, HttpStatus.NOT_FOUND), - ); + ).rejects.toThrow(new ServerError('HTTP request failed')); }); it('should successfully process a fortune webhook', async () => { @@ -382,10 +376,7 @@ describe('WebhookService', () => { }; await expect(webhookService.handleWebhook(webhook)).rejects.toThrow( - new ControlledError( - 'Invalid webhook event type: escrow_canceled', - HttpStatus.BAD_REQUEST, - ), + new ValidationError(`Invalid webhook event type: ${webhook.eventType}`), ); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts index 7a6076ee20..4bb967ca39 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts @@ -1,30 +1,33 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ChainId, EscrowClient, KVStoreKeys, KVStoreUtils, } from '@human-protocol/sdk'; +import { HttpService } from '@nestjs/axios'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; -import { signMessage } from '../../common/utils/signature'; -import { WebhookRepository } from './webhook.repository'; -import { firstValueFrom } from 'rxjs'; import { HEADER_SIGNATURE_KEY } from '../../common/constants'; -import { HttpService } from '@nestjs/axios'; -import { Web3Service } from '../web3/web3.service'; -import { WebhookStatus } from '../../common/enums/webhook'; import { ErrorWebhook } from '../../common/constants/errors'; -import { WebhookEntity } from './webhook.entity'; -import { WebhookDataDto } from './webhook.dto'; +import { EventType, WebhookStatus } from '../../common/enums/webhook'; +import { ServerError, ValidationError } from '../../common/errors'; import { CaseConverter } from '../../common/utils/case-converter'; -import { EventType } from '../../common/enums/webhook'; -import { JobService } from '../job/job.service'; -import { ControlledError } from '../../common/errors/controlled'; +import { formatAxiosError } from '../../common/utils/http'; +import { signMessage } from '../../common/utils/signature'; import { JobRepository } from '../job/job.repository'; +import { JobService } from '../job/job.service'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookDataDto } from './webhook.dto'; +import { WebhookEntity } from './webhook.entity'; +import { WebhookRepository } from './webhook.repository'; + @Injectable() export class WebhookService { + private readonly logger = new Logger(WebhookService.name); + constructor( @Inject(Web3Service) private readonly web3Service: Web3Service, @@ -52,7 +55,7 @@ export class WebhookService { // Check if the webhook URL was found. if (!webhookUrl) { - throw new ControlledError(ErrorWebhook.UrlNotFound, HttpStatus.NOT_FOUND); + throw new ServerError(ErrorWebhook.UrlNotFound); } // Build the webhook data object based on the oracle type. @@ -75,13 +78,16 @@ export class WebhookService { } // Make the HTTP request to the webhook. - const { status } = await firstValueFrom( - this.httpService.post(webhookUrl, webhookData, config), - ); - - // Check if the request was successful. - if (status !== HttpStatus.CREATED && status !== HttpStatus.OK) { - throw new ControlledError(ErrorWebhook.NotSent, HttpStatus.NOT_FOUND); + try { + await firstValueFrom( + this.httpService.post(webhookUrl, webhookData, config), + ); + } catch (error) { + const formattedError = formatAxiosError(error); + this.logger.error('Webhook not sent', { + error: formattedError, + }); + throw new Error(formattedError.message); } } @@ -144,9 +150,8 @@ export class WebhookService { break; default: - throw new ControlledError( + throw new ValidationError( `Invalid webhook event type: ${webhook.eventType}`, - HttpStatus.BAD_REQUEST, ); } } @@ -158,10 +163,7 @@ export class WebhookService { ); if (!jobEntity) { - throw new ControlledError( - ErrorWebhook.InvalidEscrow, - HttpStatus.BAD_REQUEST, - ); + throw new Error(ErrorWebhook.InvalidEscrow); } const webhookEntity = new WebhookEntity(); diff --git a/packages/apps/reputation-oracle/server/.env.example b/packages/apps/reputation-oracle/server/.env.example index 9c49d7a92f..952da47ff0 100644 --- a/packages/apps/reputation-oracle/server/.env.example +++ b/packages/apps/reputation-oracle/server/.env.example @@ -85,3 +85,5 @@ ABUSE_SLACK_SIGNING_SECRET= # NDA NDA_URL= +# HUMAN App secret key for auth in RepO +HUMAN_APP_SECRET_KEY=sk_example_1VwUpBMO8H0v4Pmu4TPiWFEwuMguW4PkozSban4Rfbc diff --git a/packages/apps/reputation-oracle/server/ENV.md b/packages/apps/reputation-oracle/server/ENV.md deleted file mode 100644 index df141c4294..0000000000 --- a/packages/apps/reputation-oracle/server/ENV.md +++ /dev/null @@ -1,164 +0,0 @@ -# Environment Variables - -### The private key used for signing JSON Web Tokens (JWT). Required -JWT_PRIVATE_KEY= - -### The public key used for verifying JSON Web Tokens (JWT). Required -JWT_PUBLIC_KEY= - -### The expiration time (in seconds) for access tokens. Default: 600 -JWT_ACCESS_TOKEN_EXPIRES_IN="600" - -### The expiration time (in seconds) for refresh tokens. Default: 3600 -JWT_REFRESH_TOKEN_EXPIRES_IN="3600" - -### The expiration time (in seconds) for email verification tokens. Default: 86400 -VERIFY_EMAIL_TOKEN_EXPIRES_IN="86400" - -### The expiration time (in seconds) for forgot password tokens. Default: 86400 -FORGOT_PASSWORD_TOKEN_EXPIRES_IN="86400" - -### Human APP email. -HUMAN_APP_EMAIL= - -### The URL for connecting to the PostgreSQL database. -POSTGRES_URL= - -### The hostname or IP address of the PostgreSQL database server. Default: '127.0.0.1' -POSTGRES_HOST="127.0.0.1" - -### The port number on which the PostgreSQL database server is listening. Default: 5432 -POSTGRES_PORT="5432" - -### The username for authenticating with the PostgreSQL database. Default: 'operator' -POSTGRES_USER="operator" - -### The password for authenticating with the PostgreSQL database. Default: 'qwerty' -POSTGRES_PASSWORD="qwerty" - -### The name of the PostgreSQL database to connect to. Default: 'reputation-oracle' -POSTGRES_DATABASE="reputation-oracle" - -### Indicates whether to use SSL for connections to the PostgreSQL database. Default: false -POSTGRES_SSL="false" - -### The logging level for PostgreSQL operations (e.g., 'debug', 'info'). Default: 'log,info,warn,error' -POSTGRES_LOGGING="log,info,warn,error" - -### The site key for the hCaptcha service, used for client-side verification. Required -HCAPTCHA_SITE_KEY= - -### The API key for the hCaptcha service, used for server-side verification and operations. Required -HCAPTCHA_API_KEY= - -### The secret key for the hCaptcha service, used for server-side authentication. Required -HCAPTCHA_SECRET= - -### The URL for hCaptcha API endpoints used for protection and verification. Default: 'https://api.hcaptcha.com' -HCAPTCHA_PROTECTION_URL="https://api.hcaptcha.com" - -### The URL for hCaptcha labeling service, used for managing and accessing labeler accounts. Default: 'https://foundation-accounts.hmt.ai' -HCAPTCHA_LABELING_URL="https://foundation-accounts.hmt.ai" - -### The default language code for the hCaptcha labeler interface. Default: 'en' -HCAPTCHA_DEFAULT_LABELER_LANG="en" - -### The API key for the KYC service, used for authentication with the KYC provider's API. KYC_API_KEY_DISABLED (a constant indicating that the API key is disabled) Required -KYC_API_KEY= - -### The private key associated with the KYC API, used for secure server-to-server communication. Required -KYC_API_PRIVATE_KEY= - -### The base URL for the KYC provider's API, which is used to send verification requests and retrieve results. Default: 'https://stationapi.veriff.com/v1' -KYC_BASE_URL="https://stationapi.veriff.com/v1" - -### The RPC URL for the Sepolia network. -RPC_URL_SEPOLIA= - -### The RPC URL for the Polygon network. -RPC_URL_POLYGON= - -### The RPC URL for the Polygon Amoy network. -RPC_URL_POLYGON_AMOY= - -### The RPC URL for the BSC Mainnet network. -RPC_URL_BSC_MAINNET= - -### The RPC URL for the BSC Testnet network. -RPC_URL_BSC_TESTNET= - -### The RPC URL for the Localhost network. -RPC_URL_LOCALHOST= - -### Indicates whether PGP encryption should be used. Default: false -PGP_ENCRYPT="false" - -### The private key used for PGP encryption or decryption. -PGP_PRIVATE_KEY= - -### The passphrase associated with the PGP private key. -PGP_PASSPHRASE= - -### The threshold value that defines the lower boundary of reputation level. Users with a reputation below this value are considered to have a low reputation. Default: 300 -REPUTATION_LEVEL_LOW="300" - -### The threshold value that defines the upper boundary of reputation level. Users with a reputation above this value are considered to have a high reputation. Default: 700 -REPUTATION_LEVEL_HIGH="700" - -### The endpoint URL for connecting to the S3 service. Default: '127.0.0.1' -S3_ENDPOINT="127.0.0.1" - -### The port number for connecting to the S3 service. Default: 9000 -S3_PORT="9000" - -### The access key ID used to authenticate requests to the S3 service. Required -S3_ACCESS_KEY= - -### The secret access key used to authenticate requests to the S3 service. Required -S3_SECRET_KEY= - -### The name of the S3 bucket where files will be stored. Default: 'reputation' -S3_BUCKET="reputation" - -### Indicates whether to use SSL (HTTPS) for connections to the S3 service. Default: false -S3_USE_SSL="false" - -### The API key used for authenticating requests to the SendGrid API. Default: 'sendgrid-disabled' -SENDGRID_API_KEY="sendgrid-disabled" - -### The email address that will be used as the sender's address in emails sent via SendGrid. Default: 'app@humanprotocol.org' -SENDGRID_FROM_EMAIL="app@humanprotocol.org" - -### The name that will be used as the sender's name in emails sent via SendGrid. Default: 'Human Protocol' -SENDGRID_FROM_NAME="Human Protocol" - -### The environment in which the server is running (e.g., 'development', 'production'). Default: 'development' -NODE_ENV="development" - -### The hostname or IP address on which the server will run. Default: 'localhost' -HOST="localhost" - -### The port number on which the server will listen for incoming connections. Default: 5003 -PORT="5003" - -### The URL of the frontend application that the server will communicate with. Default: 'http://localhost:3001' -FE_URL="http://localhost:3001" - -### The secret key used for session encryption and validation. Default: 'session_key' -SESSION_SECRET="session_key" - -### The maximum number of retry attempts for certain operations. Default: 5 -MAX_RETRY_COUNT="5" - -### The minimum validity period (in days) for a qualification. Default: 1 day -QUALIFICATION_MIN_VALIDITY="1 day" - -### The environment in which the Web3 application is running. Default: 'testnet' -WEB3_ENV="testnet" - -### The private key used for signing transactions. Required -WEB3_PRIVATE_KEY= - -### Multiplier for gas price adjustments. Default: 1 -GAS_PRICE_MULTIPLIER="1" - diff --git a/packages/apps/reputation-oracle/server/src/common/decorators/index.ts b/packages/apps/reputation-oracle/server/src/common/decorators/index.ts index 8d3100f921..6657877613 100644 --- a/packages/apps/reputation-oracle/server/src/common/decorators/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/decorators/index.ts @@ -1,11 +1,18 @@ import { Reflector } from '@nestjs/core'; import { UserRole } from '../../modules/user'; +/** + * Decorator for HTTP endpoints to bypass JWT auth guard + * where JWT auth not needed + */ export const Public = Reflector.createDecorator({ key: 'isPublic', transform: () => true, }); +/** + * Decorator to specify the list of roles accepted by RolesAuthGuard + */ export const Roles = Reflector.createDecorator({ key: 'roles', }); diff --git a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts index a570882d4c..47341fbb54 100644 --- a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts @@ -61,9 +61,9 @@ export class AuthConfigService { } /** - * Human APP email. + * HUMAN App secret key for machine-to-machine communication */ - get humanAppEmail(): string { - return this.configService.getOrThrow('HUMAN_APP_EMAIL'); + get humanAppSecretKey(): string { + return this.configService.getOrThrow('HUMAN_APP_SECRET_KEY'); } } diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index 8577786884..fe23793630 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -79,7 +79,7 @@ export const envValidator = Joi.object({ KYC_API_PRIVATE_KEY: Joi.string().required(), KYC_BASE_URL: Joi.string().uri({ scheme: ['http', 'https'] }), // Human App - HUMAN_APP_EMAIL: Joi.string().email().required(), + HUMAN_APP_SECRET_KEY: Joi.string().required(), // Slack notifications ABUSE_SLACK_WEBHOOK_URL: Joi.string() .uri({ scheme: ['http', 'https'] }) diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1747219638888-cascadeDeletion.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1747219638888-cascadeDeletion.ts new file mode 100644 index 0000000000..b652d84bda --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1747219638888-cascadeDeletion.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CascadeDeletion1747219638888 implements MigrationInterface { + name = 'CascadeDeletion1747219638888'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."tokens" DROP CONSTRAINT "FK_8769073e38c365f315426554ca5"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."kycs" DROP CONSTRAINT "FK_bbfe1fa864841e82cff1be09e8b"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_bfa80c2767c180533958bf9c971"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."abuses" DROP CONSTRAINT "FK_8136cf4f4cef59bdb54d17c714d"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."site_keys" DROP CONSTRAINT "FK_266dc68bd3412cb17b5d927b30c"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."tokens" ADD CONSTRAINT "FK_8769073e38c365f315426554ca5" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."kycs" ADD CONSTRAINT "FK_bbfe1fa864841e82cff1be09e8b" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_bfa80c2767c180533958bf9c971" FOREIGN KEY ("qualification_id") REFERENCES "hmt"."qualifications"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."abuses" ADD CONSTRAINT "FK_8136cf4f4cef59bdb54d17c714d" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."site_keys" ADD CONSTRAINT "FK_266dc68bd3412cb17b5d927b30c" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."site_keys" DROP CONSTRAINT "FK_266dc68bd3412cb17b5d927b30c"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."abuses" DROP CONSTRAINT "FK_8136cf4f4cef59bdb54d17c714d"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_bfa80c2767c180533958bf9c971"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" DROP CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."kycs" DROP CONSTRAINT "FK_bbfe1fa864841e82cff1be09e8b"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."tokens" DROP CONSTRAINT "FK_8769073e38c365f315426554ca5"`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."site_keys" ADD CONSTRAINT "FK_266dc68bd3412cb17b5d927b30c" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."abuses" ADD CONSTRAINT "FK_8136cf4f4cef59bdb54d17c714d" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_bfa80c2767c180533958bf9c971" FOREIGN KEY ("qualification_id") REFERENCES "hmt"."qualifications"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."user_qualifications" ADD CONSTRAINT "FK_6b49cc36c9a6ed1f393840709d5" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."kycs" ADD CONSTRAINT "FK_bbfe1fa864841e82cff1be09e8b" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."tokens" ADD CONSTRAINT "FK_8769073e38c365f315426554ca5" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1747228037203-RemoveHumanAppRole.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1747228037203-RemoveHumanAppRole.ts new file mode 100644 index 0000000000..44c63d4368 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1747228037203-RemoveHumanAppRole.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveHumanAppRole1747228037203 implements MigrationInterface { + name = 'RemoveHumanAppRole1747228037203'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "hmt"."users_role_enum" RENAME TO "users_role_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "hmt"."users_role_enum" AS ENUM('operator', 'worker', 'admin')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."users" ALTER COLUMN "role" TYPE "hmt"."users_role_enum" USING "role"::"text"::"hmt"."users_role_enum"`, + ); + await queryRunner.query(`DROP TYPE "hmt"."users_role_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "hmt"."users_role_enum_old" AS ENUM('operator', 'worker', 'human_app', 'admin')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."users" ALTER COLUMN "role" TYPE "hmt"."users_role_enum_old" USING "role"::"text"::"hmt"."users_role_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "hmt"."users_role_enum"`); + await queryRunner.query( + `ALTER TYPE "hmt"."users_role_enum_old" RENAME TO "users_role_enum"`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1747301407259-AddDbTtlCronJob.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1747301407259-AddDbTtlCronJob.ts new file mode 100644 index 0000000000..520c0a2bf1 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1747301407259-AddDbTtlCronJob.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDbTtlCronJob1747301407259 implements MigrationInterface { + name = 'AddDbTtlCronJob1747301407259'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum" RENAME TO "cron-jobs_cron_job_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum" AS ENUM('process-pending-incoming-webhook', 'process-pending-outgoing-webhook', 'process-pending-escrow-completion-tracking', 'process-paid-escrow-completion-tracking', 'process-awaiting-escrow-payouts', 'process-requested-abuse', 'process-classified-abuse', 'delete-expired-database-records')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."cron-jobs" ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE "hmt"."cron-jobs_cron_job_type_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum_old" AS ENUM('process-pending-incoming-webhook', 'process-pending-outgoing-webhook', 'process-pending-escrow-completion-tracking', 'process-paid-escrow-completion-tracking', 'process-awaiting-escrow-payouts', 'process-requested-abuse', 'process-classified-abuse')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."cron-jobs" ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum_old" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "hmt"."cron-jobs_cron_job_type_enum"`); + await queryRunner.query( + `ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum_old" RENAME TO "cron-jobs_cron_job_type_enum"`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.spec.ts b/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.spec.ts index 289f6a54d7..4ed04ef52d 100644 --- a/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.spec.ts +++ b/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.spec.ts @@ -7,15 +7,11 @@ import { } from '../../../test/mock-creators/nest'; import { HCaptchaGuard } from './hcaptcha.guard'; -import { AuthConfigService } from '../../config'; import { HCaptchaService } from './hcaptcha.service'; const mockHCaptchaService = { verifyToken: jest.fn(), }; -const mockAuthConfigService: Partial = { - humanAppEmail: faker.internet.email(), -}; describe('HCaptchaGuard', () => { afterEach(() => { @@ -29,7 +25,6 @@ describe('HCaptchaGuard', () => { beforeAll(() => { hCaptchaGuard = new HCaptchaGuard( mockHCaptchaService as unknown as HCaptchaService, - mockAuthConfigService as unknown as AuthConfigService, ); }); @@ -37,52 +32,12 @@ describe('HCaptchaGuard', () => { executionContextMock = createExecutionContextMock(); }); - it('should return true and skip verify if human app signin', async () => { - const request = { - path: '/auth/web2/signin', - body: { - email: mockAuthConfigService.humanAppEmail, - }, - }; - executionContextMock.__getRequest.mockReturnValueOnce(request); - - const result = await hCaptchaGuard.canActivate( - executionContextMock as unknown as ExecutionContext, - ); - - expect(result).toBe(true); - }); - - it('should verify and return true if not human app signin', async () => { + it('should verify and return true', async () => { const testToken = faker.string.alphanumeric(); const request = { path: '/auth/web2/signin', body: { - email: faker.internet.email(), - h_captcha_token: testToken, - }, - }; - executionContextMock.__getRequest.mockReturnValueOnce(request); - - mockHCaptchaService.verifyToken.mockResolvedValueOnce(true); - - const result = await hCaptchaGuard.canActivate( - executionContextMock as unknown as ExecutionContext, - ); - - expect(result).toBe(true); - expect(mockHCaptchaService.verifyToken).toHaveBeenCalledTimes(1); - expect(mockHCaptchaService.verifyToken).toHaveBeenCalledWith(testToken); - }); - - it('should verify and return true if not signin route', async () => { - const testToken = faker.string.alphanumeric(); - - const request = { - path: `/not-signin-route`, - body: { - email: mockAuthConfigService.humanAppEmail, h_captcha_token: testToken, }, }; @@ -101,9 +56,7 @@ describe('HCaptchaGuard', () => { it('should throw bad request exception if token is not provided', async () => { const request = { - body: { - email: mockAuthConfigService.humanAppEmail, - }, + body: {}, }; executionContextMock.__getRequest.mockReturnValueOnce(request); diff --git a/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.ts b/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.ts index c7d83ecb53..27bbc93d76 100644 --- a/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.ts +++ b/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/hcaptcha.guard.ts @@ -6,15 +6,11 @@ import { HttpException, } from '@nestjs/common'; import { Request } from 'express'; -import { AuthConfigService } from '../../config/auth-config.service'; import { HCaptchaService } from './hcaptcha.service'; @Injectable() export class HCaptchaGuard implements CanActivate { - constructor( - private readonly hCaptchaService: HCaptchaService, - private readonly authConfigSerice: AuthConfigService, - ) {} + constructor(private readonly hCaptchaService: HCaptchaService) {} async canActivate(context: ExecutionContext): Promise { const request: Request = context.switchToHttp().getRequest(); @@ -25,15 +21,6 @@ export class HCaptchaGuard implements CanActivate { * so we need to access body params as is */ const hCaptchaToken = body['h_captcha_token']; - // TODO: Remove 27-45 lines once we figure out how to replace human app user - if (request.path === '/auth/web2/signin') { - const email = body['email']; - // Checking email here to avoid unnecessary db calls - if (email === this.authConfigSerice.humanAppEmail) { - return true; - } - } - if (!hCaptchaToken) { throw new HttpException( 'hCaptcha token not provided', diff --git a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.entity.ts b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.entity.ts index 717cae7df9..1f5749b370 100644 --- a/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/abuse/abuse.entity.ts @@ -32,7 +32,7 @@ export class AbuseEntity extends BaseEntity { amount: number | null; @JoinColumn() - @ManyToOne('UserEntity', { nullable: false }) + @ManyToOne('UserEntity', { nullable: false, onDelete: 'CASCADE' }) user?: UserEntity; @Column({ type: 'int' }) diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts index 986cfa8175..1bac4d8e30 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { ApiTags, ApiBody, ApiBearerAuth, + ApiHeader, } from '@nestjs/swagger'; import { Body, @@ -15,6 +16,7 @@ import { UseInterceptors, UseFilters, HttpCode, + Headers, } from '@nestjs/common'; import { Public } from '../../common/decorators'; @@ -22,9 +24,8 @@ import { RequestWithUser } from '../../common/types'; import { HCaptchaGuard } from '../../integrations/hcaptcha/hcaptcha.guard'; import { AuthService } from './auth.service'; -import { TokenRepository } from './token.repository'; -import { TokenType } from './token.entity'; import { AuthControllerErrorsFilter } from './auth.error-filter'; +import { HEADER_M2M_AUTH } from './constants'; import { ForgotPasswordDto, SuccessAuthDto, @@ -36,7 +37,10 @@ import { Web2SignInDto, Web3SignInDto, Web3SignUpDto, + SuccessM2mAuthDto, } from './dto'; +import { TokenRepository } from './token.repository'; +import { TokenType } from './token.entity'; @ApiTags('Auth') @Controller('/auth') @@ -134,6 +138,32 @@ export class AuthController { return authTokens; } + @ApiOperation({ + summary: 'M2M signin', + description: 'Endpoint for machine-to-machine authentication', + }) + @ApiHeader({ + name: HEADER_M2M_AUTH, + description: + 'Basic auth with base64url secret key as username only credential', + required: true, + example: 'sk_example_a-base64urlSafe-string-Xi8dQHy1SvcPm307lps', + }) + @ApiResponse({ + status: 200, + description: 'Service authenticated successfully', + type: SuccessAuthDto, + }) + @Public() + @Post('/m2m/signin') + @HttpCode(200) + async m2mSignIn( + @Headers(HEADER_M2M_AUTH) secretKey: string, + ): Promise { + const accessToken = await this.authService.m2mSignin(secretKey); + return { accessToken }; + } + @ApiBody({ type: RefreshDto }) @ApiOperation({ summary: 'Refresh token', diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts index a3634ddede..b50e209ecd 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts @@ -10,6 +10,7 @@ export enum AuthErrorMessage { INVALID_EMAIL_TOKEN = 'Email token is not valid', INVALID_WEB3_SIGNATURE = 'Invalid signature', INVALID_ADDRESS = 'Invalid address', + INVALID_SECRET_KEY = 'Invalid secret key', } export class AuthError extends BaseError { diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts index 225e5a0210..4780b784c1 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts @@ -31,5 +31,6 @@ import { TokenRepository } from './token.repository'; ], providers: [JwtHttpStrategy, AuthService, TokenRepository], controllers: [AuthController], + exports: [TokenRepository], }) export class AuthModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index fefa53161c..91852d8c61 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -37,14 +37,14 @@ import { TokenRepository } from './token.repository'; const mockKVStoreUtils = jest.mocked(KVStoreUtils); const { publicKey, privateKey } = generateES256Keys(); -const mockAuthConfigService = { +const mockAuthConfigService: Omit = { jwtPrivateKey: privateKey, jwtPublicKey: publicKey, accessTokenExpiresIn: 600, refreshTokenExpiresIn: 3600000, verifyEmailTokenExpiresIn: 86400000, forgotPasswordExpiresIn: 86400000, - humanAppEmail: faker.internet.email(), + humanAppSecretKey: faker.string.alphanumeric({ length: 42 }), }; const mockEmailService = createMock(); @@ -588,6 +588,36 @@ describe('AuthService', () => { }); }); + describe('m2mSignin', () => { + it('should throw AuthError(AuthErrorMessage.INVALID_SECRET_KEY) if invalid secret', async () => { + const invalidSecretKey = faker.string.alphanumeric({ length: 42 }); + + await expect(service.m2mSignin(invalidSecretKey)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_SECRET_KEY, + ), + ); + }); + + it('should signin human app', async () => { + const accessToken = await service.m2mSignin( + mockAuthConfigService.humanAppSecretKey, + ); + + const decodedAccessToken = await jwtService.verifyAsync(accessToken, { + secret: mockAuthConfigService.jwtPrivateKey, + }); + + expect(omit(decodedAccessToken, ['exp', 'iat'])).toEqual({ + email: 'human-app@hmt.ai', + role: 'human_app', + user_id: 'human_app', + status: 'active', + reputation_network: mockWeb3ConfigService.operatorAddress, + }); + }); + }); + describe('auth', () => { it('should generate jwt payload for worker', async () => { const user = generateWorkerUser(); diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index bc7b374ff4..0dad0671a0 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -38,6 +38,7 @@ import { InvalidOperatorRoleError, InvalidOperatorUrlError, } from './auth.error'; +import { HUMAN_APP_IDENTIFIER } from './constants'; import { TokenEntity, TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; import type { AuthTokens } from './types'; @@ -204,6 +205,31 @@ export class AuthService { return this.web3Auth(userEntity); } + async m2mSignin(secretKey: string): Promise { + let jwtPayload: Record | undefined; + + if (this.authConfigService.humanAppSecretKey === secretKey) { + jwtPayload = { + // email precense is part of jwt validation in ExcO + email: 'human-app@hmt.ai', + status: UserStatus.ACTIVE, + user_id: HUMAN_APP_IDENTIFIER, + // specific role value is checked to grant machine-level access + role: HUMAN_APP_IDENTIFIER, + reputation_network: this.web3ConfigService.operatorAddress, + }; + } + + if (!jwtPayload) { + throw new AuthError(AuthErrorMessage.INVALID_SECRET_KEY); + } + + const accessToken = await this.jwtService.signAsync(jwtPayload, { + expiresIn: this.authConfigService.accessTokenExpiresIn, + }); + return accessToken; + } + async auth(userEntity: Web2UserEntity | UserEntity): Promise { let hCaptchaSiteKey: string | undefined; const hCaptchaSiteKeys = await this.siteKeyRepository.findByUserAndType( diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/constants.ts b/packages/apps/reputation-oracle/server/src/modules/auth/constants.ts new file mode 100644 index 0000000000..605ff92270 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/auth/constants.ts @@ -0,0 +1,3 @@ +export const HEADER_M2M_AUTH = 'human-m2m-auth-key'; + +export const HUMAN_APP_IDENTIFIER = 'human_app'; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts index 4b67d4e5cb..3e1eab400e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts @@ -33,10 +33,13 @@ export class Web3SignInDto { export class SuccessAuthDto { @ApiProperty({ name: 'access_token' }) - @IsString() accessToken: string; @ApiProperty({ name: 'refresh_token' }) - @IsString() refreshToken: string; } + +export class SuccessM2mAuthDto { + @ApiProperty({ name: 'access_token' }) + accessToken: string; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/index.ts b/packages/apps/reputation-oracle/server/src/modules/auth/index.ts index faa5c33607..068607f17b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/index.ts @@ -1 +1,2 @@ export { AuthModule } from './auth.module'; +export { TokenRepository } from './token.repository'; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts b/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts index af522263e9..ec0d33f30b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts @@ -7,7 +7,7 @@ import { LOGOUT_PATH, RESEND_EMAIL_VERIFICATION_PATH, } from '../../common/constants'; -import { UserStatus } from '../user'; +import { UserRole, UserStatus } from '../user'; import { AuthConfigService } from '../../config'; @Injectable() @@ -26,8 +26,8 @@ export class JwtHttpStrategy extends PassportStrategy( async validate( @Req() request: any, - payload: { user_id: number; status: UserStatus }, - ): Promise<{ id: number }> { + payload: { user_id: number; status: UserStatus; role: UserRole }, + ): Promise<{ id: number; role: UserRole }> { if ( payload.status !== UserStatus.ACTIVE && request.url !== RESEND_EMAIL_VERIFICATION_PATH && @@ -36,6 +36,6 @@ export class JwtHttpStrategy extends PassportStrategy( throw new UnauthorizedException('User not active'); } - return { id: payload.user_id }; + return { id: payload.user_id, role: payload.role }; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts index 259ea90a2d..261a18d0aa 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts @@ -34,7 +34,7 @@ export class TokenEntity extends BaseEntity { expiresAt: Date; @JoinColumn() - @ManyToOne('UserEntity', { persistence: false }) + @ManyToOne('UserEntity', { persistence: false, onDelete: 'CASCADE' }) user?: UserEntity; @Column({ type: 'int' }) diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts b/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts index e4f3fd3a4d..f5fa8c85da 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { DataSource, FindManyOptions } from 'typeorm'; +import { DataSource, FindManyOptions, LessThan } from 'typeorm'; import { BaseRepository } from '../../database'; import { TokenEntity, TokenType } from './token.entity'; @@ -48,4 +48,10 @@ export class TokenRepository extends BaseRepository { ): Promise { await this.delete({ type, userId }); } + + async deleteExpired(): Promise { + await this.delete({ + expiresAt: LessThan(new Date()), + }); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/constants.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/constants.ts index 43b19067f2..4a6a72a35d 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/constants.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/constants.ts @@ -6,4 +6,5 @@ export enum CronJobType { ProcessAwaitingEscrowPayouts = 'process-awaiting-escrow-payouts', ProcessRequestedAbuse = 'process-requested-abuse', ProcessClassifiedAbuse = 'process-classified-abuse', + DeleteExpiredDbRecords = 'delete-expired-database-records', } diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts index 1fd4374fab..22d8bd274f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AbuseModule } from '../abuse'; +import { AuthModule } from '../auth'; import { EscrowCompletionModule } from '../escrow-completion'; import { IncomingWebhookModule, OutgoingWebhookModule } from '../webhook'; @@ -13,6 +14,7 @@ import { CronJobRepository } from './cron-job.repository'; OutgoingWebhookModule, EscrowCompletionModule, AbuseModule, + AuthModule, ], providers: [CronJobService, CronJobRepository], }) diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts index fb493997bf..8ce3e88c17 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts @@ -3,6 +3,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { AbuseService } from '../abuse'; +import { TokenRepository } from '../auth'; import { EscrowCompletionService } from '../escrow-completion'; import { IncomingWebhookService, OutgoingWebhookService } from '../webhook'; @@ -17,6 +18,7 @@ const mockedIncomingWebhookService = createMock(); const mockedOutgoingWebhookService = createMock(); const mockedEscrowCompletionService = createMock(); const mockedAbuseService = createMock(); +const mockedTokenRepository = createMock(); describe('CronJobService', () => { let service: CronJobService; @@ -45,6 +47,10 @@ describe('CronJobService', () => { provide: AbuseService, useValue: mockedAbuseService, }, + { + provide: TokenRepository, + useValue: mockedTokenRepository, + }, ], }).compile(); @@ -300,11 +306,63 @@ describe('CronJobService', () => { spyOnIsCronJobRunning.mockResolvedValueOnce(false); spyOnStartCronJob.mockResolvedValueOnce(cronJob); - mockedIncomingWebhookService.processPendingIncomingWebhooks.mockRejectedValueOnce( + processorMock.mockRejectedValueOnce(new Error(faker.lorem.sentence())); + + await (service as any)[method](); + + expect(service.completeCronJob).toHaveBeenCalledTimes(1); + expect(service.completeCronJob).toHaveBeenCalledWith(cronJob); + }); + }); + + describe('deleteExpiredDatabaseRecords', () => { + const cronJobType = 'delete-expired-database-records' as CronJobType; + let cronJob: CronJobEntity; + + beforeEach(() => { + cronJob = generateCronJob({ + cronJobType, + }); + }); + + it('should skip processing if a cron job is already running', async () => { + spyOnIsCronJobRunning.mockResolvedValueOnce(true); + + await service.deleteExpiredDatabaseRecords(); + + expect(spyOnIsCronJobRunning).toHaveBeenCalledTimes(1); + expect(spyOnIsCronJobRunning).toHaveBeenCalledWith(cronJobType); + + expect(mockedTokenRepository.deleteExpired).not.toHaveBeenCalled(); + + expect(spyOnStartCronJob).not.toHaveBeenCalled(); + expect(spyOnCompleteCronJob).not.toHaveBeenCalled(); + }); + + it(`should process ${cronJobType} and complete the cron job`, async () => { + spyOnIsCronJobRunning.mockResolvedValueOnce(false); + spyOnStartCronJob.mockResolvedValueOnce(cronJob); + + await service.deleteExpiredDatabaseRecords(); + + expect(service.startCronJob).toHaveBeenCalledTimes(1); + expect(service.startCronJob).toHaveBeenCalledWith(cronJobType); + + expect(mockedTokenRepository.deleteExpired).toHaveBeenCalledTimes(1); + + expect(service.completeCronJob).toHaveBeenCalledTimes(1); + expect(service.completeCronJob).toHaveBeenCalledWith(cronJob); + }); + + it('should complete the cron job when processing fails', async () => { + spyOnIsCronJobRunning.mockResolvedValueOnce(false); + spyOnStartCronJob.mockResolvedValueOnce(cronJob); + + mockedTokenRepository.deleteExpired.mockRejectedValueOnce( new Error(faker.lorem.sentence()), ); - await (service as any)[method](); + await service.deleteExpiredDatabaseRecords(); expect(service.completeCronJob).toHaveBeenCalledTimes(1); expect(service.completeCronJob).toHaveBeenCalledWith(cronJob); diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts index 148c91f33f..9d4ce0dbd2 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts @@ -4,6 +4,7 @@ import { Cron } from '@nestjs/schedule'; import logger from '../../logger'; import { AbuseService } from '../abuse'; +import { TokenRepository } from '../auth'; import { EscrowCompletionService } from '../escrow-completion'; import { IncomingWebhookService, OutgoingWebhookService } from '../webhook'; @@ -21,6 +22,7 @@ export class CronJobService { private readonly outgoingWebhookService: OutgoingWebhookService, private readonly escrowCompletionService: EscrowCompletionService, private readonly abuseService: AbuseService, + private readonly tokenRepository: TokenRepository, ) {} /** @@ -230,4 +232,27 @@ export class CronJobService { this.logger.info('Process classified abuses STOP'); await this.completeCronJob(cronJob); } + + @Cron('29,58 * * * *') + async deleteExpiredDatabaseRecords(): Promise { + const isCronJobRunning = await this.isCronJobRunning( + CronJobType.DeleteExpiredDbRecords, + ); + + if (isCronJobRunning) { + return; + } + + this.logger.info('Delete expired DB records START'); + const cronJob = await this.startCronJob(CronJobType.DeleteExpiredDbRecords); + + try { + await this.tokenRepository.deleteExpired(); + } catch (e) { + this.logger.error('Error deleting expired DB records', e); + } + + this.logger.info('Delete expired DB records STOP'); + await this.completeCronJob(cronJob); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts index 9689a9af7b..ec5c62409c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts @@ -27,6 +27,7 @@ export class KycEntity extends BaseEntity { @JoinColumn() @OneToOne('UserEntity', (user: UserEntity) => user.kyc, { persistence: false, + onDelete: 'CASCADE', }) user?: UserEntity; diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error-filter.ts index a8a0599270..29cd162877 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error-filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error-filter.ts @@ -27,10 +27,9 @@ export class QualificationErrorFilter implements ExceptionFilter { if (exception.message === QualificationErrorMessage.NOT_FOUND) { status = HttpStatus.NOT_FOUND; } else if ( - [ - QualificationErrorMessage.NO_WORKERS_FOUND, - QualificationErrorMessage.CANNOT_DETELE_ASSIGNED_QUALIFICATION, - ].includes(exception.message as QualificationErrorMessage) + [QualificationErrorMessage.NO_WORKERS_FOUND].includes( + exception.message as QualificationErrorMessage, + ) ) { status = HttpStatus.UNPROCESSABLE_ENTITY; } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts index 08c8bcb797..b33fbbd6a8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.error.ts @@ -4,7 +4,6 @@ export enum QualificationErrorMessage { INVALID_EXPIRATION_TIME = 'Qualification should be valid till at least %minExpirationDate%', NOT_FOUND = 'Qualification not found', NO_WORKERS_FOUND = 'Workers not found', - CANNOT_DETELE_ASSIGNED_QUALIFICATION = 'Cannot delete qualification because it is assigned to users', } export class QualificationError extends BaseError { diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts index db7321753b..275a3973c8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts @@ -3,6 +3,7 @@ import { createMock } from '@golevelup/ts-jest'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; +import { generateFutureDate } from '../../../test/fixtures/date'; import { generateEthWallet } from '../../../test/fixtures/web3'; import { ServerConfigService } from '../../config'; import { UserStatus, UserRepository } from '../user'; @@ -15,7 +16,6 @@ import { } from './qualification.error'; import { QualificationRepository } from './qualification.repository'; import { QualificationService } from './qualification.service'; -import { UserQualificationEntity } from './user-qualification.entity'; import { UserQualificationRepository } from './user-qualification.repository'; const mockQualificationRepository = createMock(); @@ -55,7 +55,7 @@ describe('QualificationService', () => { }); describe('createQualification', () => { - it.each([faker.date.future({ years: 1 }), undefined])( + it.each([generateFutureDate(2), undefined])( 'should create a new qualification', async (expiresAt) => { const newQualification = { @@ -141,35 +141,6 @@ describe('QualificationService', () => { new QualificationError(QualificationErrorMessage.NOT_FOUND, reference), ); }); - - it('should throw CANNOT_DETELE_ASSIGNED_QUALIFICATION error', async () => { - const reference = faker.string.uuid(); - const qualificationEntity = { - reference, - title: faker.string.alpha(), - description: faker.string.alpha(), - }; - - const mockUserQualificationEntity = { - userId: faker.number.int(), - qualificationId: faker.number.int(), - }; - - mockQualificationRepository.findByReference.mockResolvedValueOnce( - qualificationEntity as QualificationEntity, - ); - - mockUserQualificationRepository.findByQualification.mockResolvedValueOnce( - [mockUserQualificationEntity] as UserQualificationEntity[], - ); - - await expect(service.deleteQualification(reference)).rejects.toThrow( - new QualificationError( - QualificationErrorMessage.CANNOT_DETELE_ASSIGNED_QUALIFICATION, - reference, - ), - ); - }); }); describe('assign', () => { diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts index 7f42c2b2b6..ef24fa2681 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts @@ -94,17 +94,6 @@ export class QualificationService { ); } - const userQualifications = - await this.userQualificationRepository.findByQualification( - qualificationEntity.id, - ); - if (userQualifications.length > 0) { - throw new QualificationError( - QualificationErrorMessage.CANNOT_DETELE_ASSIGNED_QUALIFICATION, - reference, - ); - } - await this.qualificationRepository.deleteOne(qualificationEntity); } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts index 63743184be..2bdf653c43 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts @@ -8,7 +8,9 @@ import type { UserEntity } from '../user'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'user_qualifications' }) @Index(['user', 'qualification'], { unique: true }) export class UserQualificationEntity extends BaseEntity { - @ManyToOne('UserEntity', (user: UserEntity) => user.userQualifications) + @ManyToOne('UserEntity', (user: UserEntity) => user.userQualifications, { + onDelete: 'CASCADE', + }) user?: UserEntity; @Column({ type: 'int' }) @@ -17,6 +19,7 @@ export class UserQualificationEntity extends BaseEntity { @ManyToOne( 'QualificationEntity', (qualification: QualificationEntity) => qualification.userQualifications, + { onDelete: 'CASCADE' }, ) qualification?: QualificationEntity; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts index 58fd6b07b6..090c1e9546 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts @@ -24,6 +24,7 @@ export class SiteKeyEntity extends BaseEntity { @ManyToOne('UserEntity', (user: UserEntity) => user.siteKeys, { persistence: false, + onDelete: 'CASCADE', }) @JoinColumn() user?: UserEntity; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts index f6e33eb36f..6d4f3ce22c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts @@ -18,7 +18,6 @@ export enum UserStatus { export enum Role { OPERATOR = 'operator', WORKER = 'worker', - HUMAN_APP = 'human_app', ADMIN = 'admin', } diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts index b7db3afcc6..7d78951c6b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts @@ -38,9 +38,7 @@ export class UserService { ) {} static isWeb2UserRole(userRole: string): boolean { - return [UserRole.ADMIN, UserRole.HUMAN_APP, UserRole.WORKER].includes( - userRole as UserRole, - ); + return [UserRole.ADMIN, UserRole.WORKER].includes(userRole as UserRole); } async findWeb2UserByEmail(email: string): Promise { diff --git a/packages/apps/reputation-oracle/server/test/fixtures/date.ts b/packages/apps/reputation-oracle/server/test/fixtures/date.ts new file mode 100644 index 0000000000..6e159eb99f --- /dev/null +++ b/packages/apps/reputation-oracle/server/test/fixtures/date.ts @@ -0,0 +1,13 @@ +export function generateFutureDate(daysFromNow = 1): Date { + /** + * setDate will use integer part if float is passed + * so round it here to be explicit + */ + const _daysFromNow = Math.max(Math.floor(daysFromNow), 1); + const currentDate = new Date(); + const futureDate = new Date(); + + futureDate.setDate(currentDate.getDate() + _daysFromNow); + + return futureDate; +} diff --git a/packages/apps/staking/src/assets/styles/favicon.ico b/packages/apps/staking/src/assets/favicon.ico similarity index 100% rename from packages/apps/staking/src/assets/styles/favicon.ico rename to packages/apps/staking/src/assets/favicon.ico diff --git a/packages/apps/staking/src/assets/logo.svg b/packages/apps/staking/src/assets/logo.svg deleted file mode 100644 index 229d24a86f..0000000000 --- a/packages/apps/staking/src/assets/logo.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/packages/apps/staking/src/assets/react.svg b/packages/apps/staking/src/assets/react.svg deleted file mode 100644 index 6c87de9bb3..0000000000 --- a/packages/apps/staking/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/apps/staking/src/assets/styles/_const.scss b/packages/apps/staking/src/assets/styles/_const.scss deleted file mode 100644 index 8290739e30..0000000000 --- a/packages/apps/staking/src/assets/styles/_const.scss +++ /dev/null @@ -1,18 +0,0 @@ -$maWhite: #F6F7FE; -$primary: #320A8D; -$white: #fff; -$sky: #858EC6; -$skyOpacity: #DADEF0CC; -$secondary: #6309FF; -$lilacSachet: #AEB4D9; -$ghostWhite: #F9FAFF; -$whiteSolid: #F6F5FC; -$groundwaterOpacity: #1406B20A; -$medium: #FFB300; -$mediumBorder: #FFD54F; -$low: #ED6C02; -$lowBorder: #ED6C0280; -$high: #0AD397; -$highBorder: #2E7D3280; -$soon: #304FFE; -$soonBorder: #8C9EFF; \ No newline at end of file diff --git a/packages/apps/staking/src/assets/styles/_footer.scss b/packages/apps/staking/src/assets/styles/_footer.scss deleted file mode 100644 index 79de8106be..0000000000 --- a/packages/apps/staking/src/assets/styles/_footer.scss +++ /dev/null @@ -1,53 +0,0 @@ -@use 'const'; - -footer { - - @media(max-width: 600px) { - flex-direction: column-reverse; - justify-content: center; - align-items: center; - background-color: const.$maWhite; - margin-top: 32px; - } - - .footer-wrapper { - display: flex; - padding: 32px 44px; - flex-direction: row; - justify-content: space-between; - align-items: stretch; - - @media (min-width: 601px) and (max-width: 900px) { - padding: 32px 24px; - } - - @media (max-width: 600px) { - flex-direction: column-reverse; - padding: 32px 16px; - } - } - - .footer-link { - gap: 24px; - margin-bottom: 24px; - align-items: flex-start; - } - - .footer-icon { - justify-content: flex-start; - gap: 30px; - margin-bottom: 0; - align-items: center; - - @media (max-width: 600px) { - justify-content: space-between; - align-items: flex-start; - margin-bottom: 32px; - } - } - - .footer-link, .footer-icon { - display: flex; - flex-wrap: wrap; - } -} diff --git a/packages/apps/staking/src/assets/styles/_page-wrapper.scss b/packages/apps/staking/src/assets/styles/_page-wrapper.scss deleted file mode 100644 index 15a1952ae5..0000000000 --- a/packages/apps/staking/src/assets/styles/_page-wrapper.scss +++ /dev/null @@ -1,40 +0,0 @@ -@use 'const'; - -.layout { - min-height: 100dvh; - - & .MuiToolbar-root { - padding: 0; - } - - .container { - margin-left: auto; - margin-right: auto; - padding: 0px 56px; - - @media (min-width: 601px) and (max-width: 1200px) { - padding: 0px 40px; - } - - @media (max-width: 600px) { - padding: 0px 24px - } - } -} - -.violet-header { - border-radius: 20px; - background-size: 100% 100%; - background: linear-gradient(to bottom, const.$primary 254px, const.$maWhite 1px); - min-height: calc(100dvh - 212px); - padding-top: 32px; - - @media (max-width: 600px) { - border-radius: 0; - height: auto; - margin-left: -24px; - margin-right: -24px; - padding-top: 16px; - padding-bottom: 32px; - } -} \ No newline at end of file diff --git a/packages/apps/staking/src/assets/styles/color-palette.ts b/packages/apps/staking/src/assets/styles/color-palette.ts deleted file mode 100644 index 3e54c5766f..0000000000 --- a/packages/apps/staking/src/assets/styles/color-palette.ts +++ /dev/null @@ -1,69 +0,0 @@ -export const colorPalette = { - white: '#F9FAFF', - whiteBackground: '#FFF', - whiteSolid: '#F6F5FC', - skyOpacity: '#DADEF0CC', - link: '#0000EE', - linkHover: '#1406B2', - linkVisited: '#551A8B', - primary: { - main: '#320a8d', - light: '#320a8d', - }, - secondary: { - main: '#6309ff', - light: '#1406B280', - dark: '#14062b', - }, - info: { - main: '#eeeeee', - light: '#f5f5f5', - dark: '#bdbdbd', - }, - success: { - main: '#0AD397', - light: '#2E7D3280', - }, - warning: { - main: '#FFB300', - light: '#FFD54F', - }, - error: { - main: '#fa2a75', - light: '#F20D5F', - }, - fog: { - main: '#858EC6', - light: '#CBCFE6', - dark: '#E5E7F3', - }, - overlay: { - light: '#1406B20A', - }, - sky: { - main: '#858ec6', - light: '#858ec6', - dark: '#858ec6', - contrastText: '#858ec6', - }, - ocean: { - main: '#304FFE', - light: '#8C9EFF', - dark: '#03A9F4', - }, - orange: { - main: '#ED6C02', - light: '#ED6C0280', - }, - textSecondary: { - main: '#858ec6', - light: '#858ec6', - dark: '#858ec6', - contrastText: '#858ec6', - }, - table: { - main: '#FFFFFF01', - selected: '#1406B21F', - secondary: '#1406B20A', - }, -} as const; diff --git a/packages/apps/staking/src/assets/styles/main.scss b/packages/apps/staking/src/assets/styles/main.scss deleted file mode 100644 index 233a0f6bb3..0000000000 --- a/packages/apps/staking/src/assets/styles/main.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use 'const'; -@use 'page-wrapper'; -@use 'footer'; \ No newline at end of file diff --git a/packages/apps/staking/src/components/Account/index.tsx b/packages/apps/staking/src/components/Account/index.tsx index 8bceaf5c9d..23eac3b8d9 100644 --- a/packages/apps/staking/src/components/Account/index.tsx +++ b/packages/apps/staking/src/components/Account/index.tsx @@ -19,8 +19,8 @@ const Account: FC = () => { const { data: ensName } = useEnsName({ address }); const { data: ensAvatar } = useEnsAvatar({ name: ensName! }); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const { isDarkMode, breakpoints } = useTheme(); + const isMobile = useMediaQuery(breakpoints.down('md')); const formattedAddress = formatAddress(address); @@ -31,16 +31,18 @@ const Account: FC = () => { - {!isConnected && } {isConnected && } + + { anchor="right" open={mobileMenuOpen} onClose={() => toggleDrawer(false)} - PaperProps={{ sx: { width: '75%' } }} + PaperProps={{ sx: { width: '75%', bgcolor: 'background.default' } }} > { > Stake HMT + @@ -205,7 +194,7 @@ const DefaultHeader: FC = () => { open={stakeModalOpen} onClose={() => setStakeModalOpen(false)} /> - + ); }; diff --git a/packages/apps/staking/src/components/HeaderMenu/index.tsx b/packages/apps/staking/src/components/HeaderMenu/index.tsx new file mode 100644 index 0000000000..aa99556f64 --- /dev/null +++ b/packages/apps/staking/src/components/HeaderMenu/index.tsx @@ -0,0 +1,117 @@ +import { FC, useState } from 'react'; + +import { Box, Button, Link, Popover, styled } from '@mui/material'; + +import { ChevronIcon } from '../../icons'; +import { ROUTES } from '../../constants'; + +const NavLink = styled(Link)(({ theme }) => { + const { isDarkMode, palette } = theme; + const color = isDarkMode ? palette.text.primary : palette.primary.main; + + return { + color, + padding: '6px 8px', + fontSize: '14px', + lineHeight: '150%', + letterSpacing: '0.1px', + fontWeight: 600, + textDecoration: 'none', + cursor: 'pointer', + + '&:visited, &:hover': { + color, + }, + + '@media (min-width: 900px) and (max-width: 1200px)': { + padding: '6px 4px', + fontSize: '12px', + }, + }; +}); + +const PopoverArrow = () => ( + +); + +const HeaderMenu: FC = () => { + const [anchorEl, setAnchorEl] = useState(null); + + const open = !!anchorEl; + + const handleClose = () => setAnchorEl(null); + + return ( + <> + + + + + + Human Website + + + Dashboard + + KV Store + Staking Overview + + + + ); +}; + +export default HeaderMenu; diff --git a/packages/apps/staking/src/components/LockedAmountCard/index.tsx b/packages/apps/staking/src/components/LockedAmountCard/index.tsx index 67b2246e27..570e416e1d 100644 --- a/packages/apps/staking/src/components/LockedAmountCard/index.tsx +++ b/packages/apps/staking/src/components/LockedAmountCard/index.tsx @@ -1,9 +1,9 @@ import { FC } from 'react'; + import { Box, Typography } from '@mui/material'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import { useAccount } from 'wagmi'; -import { colorPalette } from '../../assets/styles/color-palette'; import { useStakeContext } from '../../contexts/stake'; import CustomTooltip from '../CustomTooltip'; import CardWrapper from '../CardWrapper'; @@ -20,13 +20,10 @@ const LockedAmountCard: FC = () => { title="Tokens currently locked until a certain block" arrow > - + - + Locked Amount HMT diff --git a/packages/apps/staking/src/components/ModalState/Error.tsx b/packages/apps/staking/src/components/ModalState/Error.tsx index 31d6e250f5..2f43af698d 100644 --- a/packages/apps/staking/src/components/ModalState/Error.tsx +++ b/packages/apps/staking/src/components/ModalState/Error.tsx @@ -1,7 +1,6 @@ import { FC } from 'react'; import { Box, Typography } from '@mui/material'; -import { colorPalette } from '../../assets/styles/color-palette'; import { CloseIcon } from '../../icons'; const ModalError: FC = () => { @@ -13,13 +12,13 @@ const ModalError: FC = () => { alignItems="center" mb={2} p="3px" - color={colorPalette.whiteBackground} - bgcolor={colorPalette.error.main} + color="background.default" + bgcolor="error.main" borderRadius={100} > - + An error occurred, please try again. diff --git a/packages/apps/staking/src/components/ModalState/Success.tsx b/packages/apps/staking/src/components/ModalState/Success.tsx index e064a2f744..5e593b2edf 100644 --- a/packages/apps/staking/src/components/ModalState/Success.tsx +++ b/packages/apps/staking/src/components/ModalState/Success.tsx @@ -2,7 +2,6 @@ import { FC, PropsWithChildren } from 'react'; import { Box } from '@mui/material'; import { SuccessIcon } from '../../icons'; -import { colorPalette } from '../../assets/styles/color-palette'; const ModalSuccess: FC = ({ children }) => { return ( @@ -13,8 +12,8 @@ const ModalSuccess: FC = ({ children }) => { alignItems="center" mb={2} p="3px" - color={colorPalette.whiteBackground} - bgcolor={colorPalette.success.main} + color="background.default" + bgcolor="success.main" borderRadius={100} > diff --git a/packages/apps/staking/src/components/NetworkStatus/index.tsx b/packages/apps/staking/src/components/NetworkStatus/index.tsx index 2d43f3f3e4..8381380659 100644 --- a/packages/apps/staking/src/components/NetworkStatus/index.tsx +++ b/packages/apps/staking/src/components/NetworkStatus/index.tsx @@ -9,7 +9,7 @@ const NetworkStatus: React.FC = () => { return ( - + Network @@ -21,7 +21,7 @@ const NetworkStatus: React.FC = () => { fontSize: { xs: 24, sm: 18, lg: 24 }, fontWeight: 400, lineHeight: 1.5, - color: 'primary.main', + color: 'text.primary', marginLeft: 1, }} > diff --git a/packages/apps/staking/src/components/NetworkSwitcher/index.tsx b/packages/apps/staking/src/components/NetworkSwitcher/index.tsx index b11fe8bfd3..9c9ea4a1c5 100644 --- a/packages/apps/staking/src/components/NetworkSwitcher/index.tsx +++ b/packages/apps/staking/src/components/NetworkSwitcher/index.tsx @@ -26,7 +26,16 @@ const NetworkSwitcher = () => { diff --git a/packages/apps/staking/src/components/Wallet/WalletModal.tsx b/packages/apps/staking/src/components/Wallet/WalletModal.tsx index f3d32161a0..af08454020 100644 --- a/packages/apps/staking/src/components/Wallet/WalletModal.tsx +++ b/packages/apps/staking/src/components/Wallet/WalletModal.tsx @@ -1,12 +1,5 @@ import CloseIcon from '@mui/icons-material/Close'; -import { - Box, - Button, - Dialog, - IconButton, - Typography, - useTheme, -} from '@mui/material'; +import { Box, Button, Dialog, IconButton, Typography } from '@mui/material'; import { useConnect } from 'wagmi'; import coinbaseSvg from '../../assets/coinbase.svg'; import metaMaskSvg from '../../assets/metamask.svg'; @@ -27,29 +20,24 @@ export default function WalletModal({ }) { const { connect, connectors, error } = useConnect(); - const theme = useTheme(); - return ( - + Connect
your wallet
@@ -59,11 +47,11 @@ export default function WalletModal({
@@ -77,12 +65,13 @@ export default function WalletModal({ justifyContent: 'space-between', px: 2, py: 3, - background: '#f6f7fe', - color: theme.palette.text.secondary, + bgcolor: 'background.grey', + color: 'text.secondary', border: `1px solid transparent`, '&:hover': { - color: theme.palette.text.primary, - border: `1px solid ${theme.palette.primary.main}`, + color: 'text.primary', + border: '1px solid', + borderColor: 'primary.main', }, }} key={connector.id} diff --git a/packages/apps/staking/src/components/WithdrawableAmountCard/index.tsx b/packages/apps/staking/src/components/WithdrawableAmountCard/index.tsx index 1e10f6e8a6..16b4a5ae39 100644 --- a/packages/apps/staking/src/components/WithdrawableAmountCard/index.tsx +++ b/packages/apps/staking/src/components/WithdrawableAmountCard/index.tsx @@ -3,7 +3,6 @@ import { Box, Button, Typography } from '@mui/material'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import { useAccount } from 'wagmi'; -import { colorPalette } from '../../assets/styles/color-palette'; import { useStakeContext } from '../../contexts/stake'; import CustomTooltip from '../CustomTooltip'; import CardWrapper from '../CardWrapper'; @@ -29,11 +28,11 @@ const WithdrawableAmountCard: FC = () => { - + Withdrawable Amount HMT > = ({ slotProps={{ backdrop: { sx: { - backgroundColor: 'rgba(240, 242, 252, 0.90)', + bgcolor: 'backdropColor', }, }, }} @@ -36,7 +36,7 @@ const BaseModal: FC> = ({ py: 5, px: 4, width: 500, - backgroundColor: '#ffffff', + bgcolor: 'background.default', borderRadius: 4, position: 'relative', boxShadow: '0px 0px 10px 0px rgba(50, 10, 141, 0.05)', @@ -47,6 +47,7 @@ const BaseModal: FC> = ({ onClick={onClose} sx={{ p: 0, + color: 'text.primary', position: 'absolute', top: '40px', right: '32px', diff --git a/packages/apps/staking/src/components/modals/KVStoreModal.tsx b/packages/apps/staking/src/components/modals/KVStoreModal.tsx index e9d9877974..778b6b9149 100644 --- a/packages/apps/staking/src/components/modals/KVStoreModal.tsx +++ b/packages/apps/staking/src/components/modals/KVStoreModal.tsx @@ -16,7 +16,6 @@ import { MenuItem, Select, TextField, - Tooltip, Typography, } from '@mui/material'; @@ -44,7 +43,7 @@ type Field = { const SuccessState: FC = () => ( - + You have successfully edited your KV Store @@ -261,6 +260,13 @@ const KVStoreModal: FC = ({ open, onClose, initialData, onSave }) => { value={item.isCustom ? 'custom' : item.key} onChange={(e) => handleKeyChange(index, e.target.value)} disabled={initialData.some((data) => data.key === item.key)} + MenuProps={{ + PaperProps: { + sx: { + bgcolor: 'background.default', + }, + }, + }} > Select Key @@ -298,6 +304,13 @@ const KVStoreModal: FC = ({ open, onClose, initialData, onSave }) => { label="Value" value={item.value} onChange={(e) => handleValueChange(index, e.target.value)} + MenuProps={{ + PaperProps: { + sx: { + bgcolor: 'background.default', + }, + }, + }} > {Object.entries(Role).map(([roleKey, roleValue]) => ( @@ -316,6 +329,13 @@ const KVStoreModal: FC = ({ open, onClose, initialData, onSave }) => { label="Value" value={item.value} onChange={(e) => handleValueChange(index, e.target.value)} + MenuProps={{ + PaperProps: { + sx: { + bgcolor: 'background.default', + }, + }, + }} > {Object.entries(OperatorCategory).map( ([categoryKey, categoryValue]) => ( @@ -338,11 +358,9 @@ const KVStoreModal: FC = ({ open, onClose, initialData, onSave }) => { )} - - handleDelete(index)}> - - - + handleDelete(index)}> + + ))} diff --git a/packages/apps/staking/src/components/modals/StakeModal.tsx b/packages/apps/staking/src/components/modals/StakeModal.tsx index 72201b1908..adbe75e84e 100644 --- a/packages/apps/staking/src/components/modals/StakeModal.tsx +++ b/packages/apps/staking/src/components/modals/StakeModal.tsx @@ -30,12 +30,8 @@ const SuccessState: FC<{ amount: number | string }> = ({ amount }) => ( justifyContent="center" py={1} > - - You have successfully staked - - - {amount} HMT - + You have successfully staked + {amount} HMT ); @@ -100,7 +96,7 @@ const StakeModal: FC = ({ open, onClose }) => { const renderIdleState = () => { return ( <> - + Available amount: {tokenBalance} HMT @@ -136,6 +132,8 @@ const StakeModal: FC = ({ open, onClose }) => { fontSize: '0.75rem', padding: '4px 10px', minWidth: 'unset', + border: 'none', + boxShadow: 'none', }} > Max diff --git a/packages/apps/staking/src/components/modals/UnstakeModal.tsx b/packages/apps/staking/src/components/modals/UnstakeModal.tsx index dc0cbea850..fb8c4a55c0 100644 --- a/packages/apps/staking/src/components/modals/UnstakeModal.tsx +++ b/packages/apps/staking/src/components/modals/UnstakeModal.tsx @@ -30,12 +30,10 @@ const SuccessState: FC<{ amount: number | string }> = ({ amount }) => ( justifyContent="center" py={1} > - + You have successfully unstaked - - {amount} HMT - + {amount} HMT ); @@ -107,7 +105,7 @@ const UnstakeModal: FC = ({ open, onClose }) => { const renderIdleState = () => { return ( <> - + Available Amount: {availableAmount} HMT diff --git a/packages/apps/staking/src/components/modals/WithdrawModal.tsx b/packages/apps/staking/src/components/modals/WithdrawModal.tsx index 069e6f0e08..ece6d6a9f6 100644 --- a/packages/apps/staking/src/components/modals/WithdrawModal.tsx +++ b/packages/apps/staking/src/components/modals/WithdrawModal.tsx @@ -18,10 +18,10 @@ const IdleState: FC<{ withdrawableAmount: number | string }> = ({ withdrawableAmount, }) => ( <> - + Withdraw amount: - + {withdrawableAmount} HMT @@ -36,12 +36,10 @@ const SuccessState: FC<{ amount: number }> = ({ amount }) => ( justifyContent="center" py={1} > - + You have successfully withdrawn - - {amount} HMT - + {amount} HMT ); diff --git a/packages/apps/staking/src/icons/index.tsx b/packages/apps/staking/src/icons/index.tsx index 0d964fad30..2bd90954a1 100644 --- a/packages/apps/staking/src/icons/index.tsx +++ b/packages/apps/staking/src/icons/index.tsx @@ -1,6 +1,49 @@ import { FC } from 'react'; import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; +export const LogoIcon: FC = (props) => { + return ( + + + + + + + + + ); +}; + export const OverviewIcon: FC = (props) => { return ( @@ -74,6 +117,83 @@ export const OverviewIcon: FC = (props) => { ); }; +export const DarkOverviewIcon: FC = (props) => { + return ( + + + + + + + + + + + + + + + ); +}; + export const KVStoreIcon: FC = (props) => { return ( @@ -176,6 +296,73 @@ export const KVStoreIcon: FC = (props) => { ); }; +export const DarkKvstoreIcon: FC = (props) => { + return ( + + + + + + + + + + + + + + ); +}; + export const DiscordIcon: FC = (props) => { return ( @@ -227,7 +414,7 @@ export const ChevronIcon: FC = (props) => { return ( @@ -244,3 +431,25 @@ export const PowerIcon: FC = (props) => { ); }; + +export const LightModeIcon: FC = (props) => { + return ( + + + + ); +}; + +export const DarkModeIcon: FC = (props) => { + return ( + + + + ); +}; diff --git a/packages/apps/staking/src/index.css b/packages/apps/staking/src/index.css deleted file mode 100644 index addebdf52a..0000000000 --- a/packages/apps/staking/src/index.css +++ /dev/null @@ -1,4 +0,0 @@ -a { - color: #320a8d; - text-decoration: none; -} diff --git a/packages/apps/staking/src/main.tsx b/packages/apps/staking/src/main.tsx index 42ad0ec619..2f42de77ec 100644 --- a/packages/apps/staking/src/main.tsx +++ b/packages/apps/staking/src/main.tsx @@ -1,24 +1,24 @@ -import CssBaseline from '@mui/material/CssBaseline'; -import { ThemeProvider } from '@mui/material/styles'; import React from 'react'; + +import CssBaseline from '@mui/material/CssBaseline'; import ReactDOM from 'react-dom/client'; import { BrowserRouter as Router } from 'react-router-dom'; -import App from './App'; + import SnackbarProvider from './providers/SnackProvider'; -import './index.css'; -import theme from './theme'; import { WagmiProvider } from './providers/WagmiProvider'; import { QueryClientProvider } from './providers/QueryClientProvider'; -import './assets/styles/main.scss'; -import 'simplebar-react/dist/simplebar.min.css'; import { StakeProvider } from './contexts/stake'; import { KVStoreProvider } from './contexts/kvstore'; +import ThemeProvider from './providers/ThemeProvider'; + +import App from './App'; +import 'simplebar-react/dist/simplebar.min.css'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + diff --git a/packages/apps/staking/src/pages/Dashboard/index.tsx b/packages/apps/staking/src/pages/Dashboard/index.tsx index 30e8af4b69..389a8d020d 100644 --- a/packages/apps/staking/src/pages/Dashboard/index.tsx +++ b/packages/apps/staking/src/pages/Dashboard/index.tsx @@ -7,39 +7,73 @@ import LockedAmountCard from '../../components/LockedAmountCard'; import PageWrapper from '../../components/PageWrapper'; import StakedAmountCard from '../../components/StakedAmountCard'; import WithdrawableAmountCard from '../../components/WithdrawableAmountCard'; -import { OverviewIcon } from '../../icons'; +import { DarkOverviewIcon, OverviewIcon } from '../../icons'; const Dashboard: FC = () => { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const { isDarkMode, breakpoints, palette } = useTheme(); + const isMobile = useMediaQuery(breakpoints.down('sm')); return ( - - + + - + {isDarkMode ? ( + + ) : ( + + )} Staking Overview + + diff --git a/packages/apps/staking/src/pages/KVStore/index.tsx b/packages/apps/staking/src/pages/KVStore/index.tsx index 897e7a09b4..df243fdbed 100644 --- a/packages/apps/staking/src/pages/KVStore/index.tsx +++ b/packages/apps/staking/src/pages/KVStore/index.tsx @@ -3,28 +3,33 @@ import { Box, Typography, useTheme } from '@mui/material'; import PageWrapper from '../../components/PageWrapper'; import KVStoreTable from '../../components/Tables/kvstore'; -import { KVStoreIcon } from '../../icons'; +import { DarkKvstoreIcon, KVStoreIcon } from '../../icons'; const KVStore: FC = () => { - const theme = useTheme(); + const { isDarkMode, palette } = useTheme(); return ( - - + {isDarkMode ? ( + + ) : ( + + )} + KV Store diff --git a/packages/apps/staking/src/providers/ThemeProvider.tsx b/packages/apps/staking/src/providers/ThemeProvider.tsx new file mode 100644 index 0000000000..a79e8d0cf7 --- /dev/null +++ b/packages/apps/staking/src/providers/ThemeProvider.tsx @@ -0,0 +1,71 @@ +import { + FC, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +import { + CssBaseline, + PaletteMode, + ThemeProvider as MuiThemeProvider, +} from '@mui/material'; + +import { createAppTheme } from '../theme'; + +const THEME_STORAGE_KEY = 'app-theme-mode'; + +const ThemeProvider: FC = ({ children }) => { + const [mode, setMode] = useState(() => { + const savedMode = localStorage.getItem(THEME_STORAGE_KEY) as PaletteMode; + if (savedMode) return savedMode; + + if ( + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches + ) { + return 'dark'; + } + return 'light'; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + setMode(e.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + useEffect(() => { + localStorage.setItem(THEME_STORAGE_KEY, mode); + }, [mode]); + + const toggleColorMode = useCallback(() => { + setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light')); + }, []); + + const theme = useMemo(() => createAppTheme(mode), [mode]); + + const extendedTheme = useMemo( + () => ({ + ...theme, + isDarkMode: mode === 'dark', + toggleColorMode, + }), + [theme, mode, toggleColorMode] + ); + + return ( + + + {children} + + ); +}; + +export default ThemeProvider; diff --git a/packages/apps/staking/src/theme.ts b/packages/apps/staking/src/theme.ts index e382bb6f3b..afaab45d8f 100644 --- a/packages/apps/staking/src/theme.ts +++ b/packages/apps/staking/src/theme.ts @@ -1,359 +1,367 @@ -import { createTheme } from '@mui/material/styles'; import { + createTheme, PaletteColorOptions, PaletteColor, -} from '@mui/material/styles/createPalette'; -import { ThemeOptions } from '@mui/material'; -import { colorPalette } from './assets/styles/color-palette'; -import { CSSProperties } from 'react'; - -declare module '@mui/material/Typography' { - interface TypographyPropsVariantOverrides { - ['Components/Button Small']: true; - ['Components/Button Large']: true; - ['Components/Chip']: true; - ['Components/Table Header']: true; - ['H6-Mobile']: true; - body3: true; - } -} +} from '@mui/material/styles'; declare module '@mui/material/styles' { - interface TypographyVariants { - ['Components/Button Small']: CSSProperties; - ['Components/Button Large']: CSSProperties; - ['Components/Chip']: CSSProperties; - ['Components/Table Header']: CSSProperties; - ['H6-Mobile']: CSSProperties; - body3: CSSProperties; + interface Theme { + toggleColorMode: () => void; + isDarkMode: boolean; } - - // allow configuration using `createTheme` - interface TypographyVariantsOptions { - ['Components/Button Small']?: CSSProperties; - ['Components/Button Large']?: CSSProperties; - ['Components/Chip']?: CSSProperties; - ['Components/Table Header']?: CSSProperties; - ['H6-Mobile']: CSSProperties; - body3?: CSSProperties; + interface ThemeOptions { + toggleColorMode?: () => void; + isDarkMode?: boolean; } } declare module '@mui/material/styles' { + interface TypeBackground { + grey?: string; + } interface Palette { - sky: PaletteColor; white: PaletteColor; - textSecondary: PaletteColor; + elevation: { + light: string; + medium: string; + dark: string; + }; + link: { + main: string; + hover: string; + visited: string; + }; } interface PaletteOptions { - sky?: PaletteColorOptions; white?: PaletteColorOptions; - textSecondary?: PaletteColorOptions; + elevation?: { + light: string; + medium: string; + dark: string; + }; + link?: { + main: string; + hover: string; + visited: string; + }; } } declare module '@mui/material/Button' { interface ButtonPropsColorOverrides { - sky: true; white: true; - textSecondary: true; } } declare module '@mui/material/IconButton' { interface IconButtonPropsColorOverrides { - sky: true; white: true; - textSecondary: true; } } declare module '@mui/material/SvgIcon' { interface SvgIconPropsColorOverrides { - sky: true; white: true; - textSecondary: true; } } -const theme: ThemeOptions = createTheme({ - palette: { - primary: { - main: colorPalette.primary.main, - light: colorPalette.primary.light, - }, - info: { - main: colorPalette.info.main, - light: colorPalette.info.light, - dark: colorPalette.info.dark, - }, - secondary: { - main: colorPalette.secondary.main, - light: colorPalette.secondary.light, - }, - text: { - primary: colorPalette.primary.main, - secondary: colorPalette.fog.main, - }, - sky: { - main: colorPalette.sky.main, - light: colorPalette.sky.light, - dark: colorPalette.sky.dark, - contrastText: colorPalette.sky.contrastText, - }, - white: { - main: '#fff', - light: '#fff', - dark: '#fff', - contrastText: '#fff', - }, - textSecondary: colorPalette.textSecondary, - }, - typography: { - fontFamily: 'Inter, Arial, sans-serif', - h1: { - fontSize: 32, - fontWeight: 600, - }, - h2: { - fontSize: 34, - fontWeight: 600, - }, - h3: { - fontSize: 24, - fontWeight: 500, - }, - h4: { - fontSize: 20, - fontWeight: 500, - }, - h5: { - fontSize: 18, - fontWeight: 600, - }, - h6: { - fontSize: 20, - fontWeight: 500, - }, - 'H6-Mobile': { - fontSize: '20px', - fontWeight: 500, - lineHeight: '32px', - letterSpacing: '0.15px', - textAlign: 'left', - }, - body1: { - fontSize: 16, - fontWeight: 400, - }, - body2: { - fontSize: 14, - fontWeight: 500, - }, - body3: { - fontSize: '12px', - fontWeight: 400, - lineHeight: '19.92px', - letterSpacing: '0.4px', - textAlign: 'left', - }, - 'Components/Button Small': { - fontSize: '13px', - fontWeight: 600, - lineHeight: '22px', - letterSpacing: '0.1px', - textAlign: 'left', - }, - 'Components/Button Large': { - fontSize: '15px', - fontWeight: 600, - lineHeight: '26px', - letterSpacing: '0.1px', - textAlign: 'left', - }, - 'Components/Chip': { - fontSize: '13px', - fontWeight: 400, - lineHeight: '18px', - letterSpacing: '0.16px', - textAlign: 'left', - }, - 'Components/Table Header': { - fontFamily: 'Roboto', - fontSize: '14px', - fontWeight: 500, - lineHeight: '24px', - letterSpacing: '0.17px', - textAlign: 'left', - }, - subtitle1: { - fontSize: 12, - }, - subtitle2: { - fontSize: 14, - fontWeight: 600, - lineHeight: '21.9px', - }, - caption: { - fontSize: 10, - }, - }, - components: { - MuiButton: { - styleOverrides: { - root: { - cursor: 'pointer', - fontWeight: 600, - textTransform: 'none', - - '&:disabled': { - backgroundColor: '#FBFBFE', - color: 'rgba(203, 207, 232, 0.86)', - border: 'none', - }, - }, +export const createAppTheme = (mode: 'light' | 'dark') => { + const isLightMode = mode === 'light'; + return createTheme({ + palette: { + mode, + ...(mode === 'light' + ? { + primary: { + main: '#320a8d', + light: '#6309ff', + contrastText: '#f9faff', + }, + secondary: { + main: '#6309ff', + }, + text: { + primary: '#320a8d', + secondary: '#858ec6', + }, + white: { + main: '#ffffff', + light: '#ffffff', + dark: '#ffffff', + contrastText: '#ffffff', + }, + background: { + default: '#ffffff', + grey: '#f6f7fe', + }, + backdropColor: 'rgba(240, 242, 252, 0.90)', + link: { + main: '#0000ee', + hover: '#1406b2', + visited: '#7022b8', + }, + success: { + main: '#0ad397', + light: '#2e7d3280', + }, + warning: { + main: '#ffb300', + light: '#ffd54f', + }, + error: { + main: '#fa2a75', + light: '#f20d5f', + }, + } + : { + primary: { + main: '#cdc7ff', + light: '#320a8d', + contrastText: 'rgba(0, 0, 0, 0.87)', + }, + secondary: { + main: '#6309ff', + }, + text: { + primary: '#d4cfff', + secondary: '#858ec6', + }, + white: { + main: '#ffffff', + light: '#ffffff', + dark: '#ffffff', + contrastText: '#ffffff', + }, + background: { + default: '#1d1340', + }, + backdropColor: 'rgba(16, 7, 53, 0.80)', + link: { + main: '#5757f8', + hover: '#5757f8', + visited: '#9d4ff7', + }, + success: { + main: '#0ad397', + light: '#2e7d3280', + }, + warning: { + main: '#ffb300', + light: '#ffd54f', + }, + error: { + main: '#fa2a75', + light: '#f20d5f', + }, + elevation: { + light: + 'linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.07) 100%), #100735', + medium: + 'linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.05) 100%), #100735', + dark: 'linear-gradient(180deg, rgba(255, 255, 255, 0.01) 0%, rgba(255, 255, 255, 0.01) 100%), #100735', + }, + }), + }, + typography: { + fontFamily: 'Inter, Arial, sans-serif', + h1: { + fontSize: 32, + fontWeight: 600, }, - }, - MuiTooltip: { - styleOverrides: { - tooltip: { - backgroundColor: colorPalette.secondary.main, - color: colorPalette.whiteSolid, - }, - arrow: { - color: colorPalette.secondary.main, - }, + h2: { + fontSize: 34, + fontWeight: 600, }, - }, - MuiIconButton: { - styleOverrides: { - sizeMedium: { - color: colorPalette.primary.main, - }, + h3: { + fontSize: 24, + fontWeight: 600, + }, + h4: { + fontSize: 20, + fontWeight: 600, + }, + h5: { + fontSize: 18, + fontWeight: 600, + }, + h6: { + fontSize: 20, + fontWeight: 500, + }, + body1: { + fontSize: 16, + fontWeight: 400, + }, + body2: { + fontSize: 14, + fontWeight: 500, + }, + subtitle1: { + fontSize: 12, + }, + subtitle2: { + fontSize: 14, + fontWeight: 600, + lineHeight: '21.9px', + }, + caption: { + fontSize: 10, }, }, - MuiSelect: { - styleOverrides: { - root: { - borderRadius: 4, - borderWidth: 2, - color: colorPalette.primary.main, - '& .MuiOutlinedInput-notchedOutline': { - borderColor: colorPalette.primary.main, - borderWidth: 2, - }, - '&:hover .MuiOutlinedInput-notchedOutline': { - borderColor: colorPalette.primary.main, - }, - '&.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: colorPalette.primary.main, - }, - '& .MuiSvgIcon-root': { - color: colorPalette.primary.main, + components: { + MuiButton: { + styleOverrides: { + root: { + cursor: 'pointer', + fontWeight: 600, + textTransform: 'none', + transition: 'none', + + '&:disabled': { + backgroundColor: isLightMode + ? '#fbfbfe' + : 'rgba(255, 255, 255, 0.12)', + color: isLightMode + ? 'rgba(203, 207, 232, 0.86)' + : 'rgba(255, 255, 255, 0.30)', + border: 'none', + }, }, }, }, - }, - MuiTypography: { - styleOverrides: { - root: { - wordBreak: 'break-word', + MuiTooltip: { + styleOverrides: { + tooltip: ({ theme }) => ({ + backgroundColor: theme.palette.secondary.main, + color: theme.palette.white.main, + }), + arrow: ({ theme }) => ({ + color: theme.palette.secondary.main, + }), }, }, - }, - MuiOutlinedInput: { - styleOverrides: { - root: { - backgroundColor: colorPalette.white, + MuiIconButton: { + styleOverrides: { + sizeMedium: ({ theme }) => ({ + color: theme.palette.primary.main, + }), }, }, - }, - MuiMenuItem: { - styleOverrides: { - root: { - '&:hover': { - backgroundColor: '#1406B207', - }, + MuiSelect: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: 4, + borderWidth: 1, + color: theme.palette.primary.main, + '& .MuiOutlinedInput-notchedOutline': { + borderWidth: 1, + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.primary.main, + borderWidth: 1, + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.primary.main, + borderWidth: 1, + }, + '& .MuiSvgIcon-root': { + color: theme.palette.primary.main, + }, + }), }, }, - }, - MuiTablePagination: { - styleOverrides: { - toolbar: { - '@media (max-width: 440px)': { - display: 'grid', - gridTemplateColumns: '1fr 3fr 2fr', - gridTemplateRows: 'auto auto', - gridAutoFlow: 'row', + MuiTypography: { + styleOverrides: { + root: { + wordBreak: 'break-word', }, }, - selectLabel: { - '@media (max-width: 440px)': { - gridColumn: '2 / 3', - gridRow: '1', - whiteSpace: 'nowrap', - color: colorPalette.fog.main, - justifySelf: 'end', - marginBottom: `17px`, - position: 'relative', - right: '-38px', - }, - '&:focus': { - background: 'inherit', + }, + MuiMenuItem: { + styleOverrides: { + root: { + '&:hover': { + backgroundColor: '#1406B207', + }, }, }, - input: { - '@media (max-width: 440px)': { - gridColumn: '3 / 3', - gridRow: '1', - marginRight: '8px', - width: '48px', - justifySelf: 'flex-end', + }, + MuiTablePagination: { + styleOverrides: { + toolbar: { + '@media (max-width: 440px)': { + display: 'grid', + gridTemplateColumns: '1fr 3fr 2fr', + gridTemplateRows: 'auto auto', + gridAutoFlow: 'row', + }, }, - }, - select: { - '&:focus': { - background: 'inherit', + selectLabel: { + '@media (max-width: 440px)': { + gridColumn: '2 / 3', + gridRow: '1', + whiteSpace: 'nowrap', + color: 'text.secondary', + justifySelf: 'end', + marginBottom: `17px`, + position: 'relative', + right: '-38px', + }, + '&:focus': { + background: 'inherit', + }, }, - }, - displayedRows: { - '@media (max-width: 440px)': { - gridColumn: '2 / 3', - gridRow: '2', - justifySelf: 'end', - position: 'relative', - right: '-12px', + input: { + '@media (max-width: 440px)': { + gridColumn: '3 / 3', + gridRow: '1', + marginRight: '8px', + width: '48px', + justifySelf: 'flex-end', + }, }, - }, - actions: { - '@media (max-width: 440px)': { - gridColumn: '3 / 3', - gridRow: '2', - justifySelf: 'end', - marginLeft: 0, - minWidth: '90px', + select: { + '&:focus': { + background: 'inherit', + }, + }, + displayedRows: { + '@media (max-width: 440px)': { + gridColumn: '2 / 3', + gridRow: '2', + justifySelf: 'end', + position: 'relative', + right: '-12px', + }, }, - button: { - marginLeft: '5px', + actions: { + '@media (max-width: 440px)': { + gridColumn: '3 / 3', + gridRow: '2', + justifySelf: 'end', + marginLeft: 0, + minWidth: '90px', + }, + button: { + marginLeft: '5px', + }, }, }, }, - }, - MuiLink: { - styleOverrides: { - root: { - textDecoration: 'none', - color: colorPalette.link, - '&:hover': { - color: `${colorPalette.linkHover}!important`, - }, - '&:visited': { - color: colorPalette.linkVisited, - }, + MuiLink: { + styleOverrides: { + root: ({ theme }) => ({ + textDecoration: 'none', + color: theme.palette.link.main, + '&:hover': { + color: theme.palette.link.hover, + }, + '&:visited': { + color: theme.palette.link.visited, + }, + }), }, }, }, - }, -}); - -export default theme; + }); +}; diff --git a/packages/sdk/typescript/subgraph/README.md b/packages/sdk/typescript/subgraph/README.md index 58a3c2f424..f41e99f4ed 100644 --- a/packages/sdk/typescript/subgraph/README.md +++ b/packages/sdk/typescript/subgraph/README.md @@ -101,4 +101,4 @@ You can find networks configuration in the directory `config`. Each JSON file is ## License -This project is licensed under the MIT License. See the [LICENSE](https://github.com/humanprotocol/human-protocol/blob/main/LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](https://github.com/humanprotocol/human-protocol/blob/main/LICENSE) file for details. \ No newline at end of file diff --git a/scripts/cvat/docker-compose.local.yml b/scripts/cvat/docker-compose.local.yml index 3327e18dae..59db252e5b 100644 --- a/scripts/cvat/docker-compose.local.yml +++ b/scripts/cvat/docker-compose.local.yml @@ -47,7 +47,7 @@ x-general-env-variables: recording_oracle_address: &recording_oracle_address ${RECORDING_ORACLE_ADDRESS:?} # OTHER backend_apps_internal_port: &backend_apps_internal_port ${BACKEND_APPS_INTERNAL_PORT:?} - human_app_email: &human_app_email ${HUMAN_APP_EMAIL:?} + human_app_secret_key: &human_app_secret_key ${HUMAN_APP_SECRET_KEY:?} reputation_oracle_jwt_public_key: &reputation_oracle_jwt_public_key ${REPUTATION_ORACLE_JWT_PUBLIC_KEY:?} cvat_oracle_storage_endpoint: &cvat_oracle_storage_endpoint minio:${MINIO_PORT:?} @@ -245,7 +245,7 @@ services: PORT: *backend_apps_internal_port POSTGRES_DATABASE: reputation-oracle S3_BUCKET: *bucket_name_rep_o - HUMAN_APP_EMAIL: *human_app_email + HUMAN_APP_SECRET_KEY: *human_app_secret_key # It is accessed by user, not from container # so put here exposed port, not internal FE_URL: http://localhost:${HUMAN_APP_CLIENT_PORT:?} @@ -279,7 +279,7 @@ services: PORT: *backend_apps_internal_port REDIS_DB: 1 RPC_URL: *rpc_url_polygon_amoy - HUMAN_APP_EMAIL: *human_app_email + HUMAN_APP_SECRET_KEY: *human_app_secret_key REPUTATION_ORACLE_URL: "http://reputation-oracle:${BACKEND_APPS_INTERNAL_PORT:?}" REPUTATION_ORACLE_ADDRESS: *reputation_oracle_address diff --git a/scripts/cvat/env-files/.env.compose b/scripts/cvat/env-files/.env.compose index 9a307825f1..01ac15ef8b 100644 --- a/scripts/cvat/env-files/.env.compose +++ b/scripts/cvat/env-files/.env.compose @@ -5,7 +5,7 @@ REPUTATION_ORACLE_EXPOSED_PORT=5001 HUMAN_APP_CLIENT_PORT=3001 JOB_LAUNCHER_CLIENT_PORT=3002 -HUMAN_APP_EMAIL=human-app@local.app +HUMAN_APP_SECRET_KEY=sk_local_3Hc0kWL9Y8iwyHucW636Cl7tfFZI0rAUljPEbbfDrok REPUTATION_ORACLE_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkPekD3q96LHt4IfvY1UY1YukTeEf K+XryPXhU57nKuhZXBPRrQ+lMDeFpYpHWpGqA/K576n+rDvjbBgHfQiHKg== diff --git a/scripts/cvat/env-files/.env.human-app-server b/scripts/cvat/env-files/.env.human-app-server index 7eef62257c..f87f8c2440 100644 --- a/scripts/cvat/env-files/.env.human-app-server +++ b/scripts/cvat/env-files/.env.human-app-server @@ -4,7 +4,6 @@ HCAPTCHA_LABELING_VERIFY_API_URL=disabled HCAPTCHA_LABELING_API_KEY=disabled # Should be the one you will use to access human-app-server API ALLOWED_HOST=localhost:5002 -HUMAN_APP_PASSWORD=HumanAppPassword # CORS CORS_ALLOWED_ORIGIN=* CORS_ALLOWED_HEADERS='Content-Type,Authorization,X-Requested-With,Accept,Origin' diff --git a/yarn.lock b/yarn.lock index c4a2f91771..e10b08aa6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4029,6 +4029,7 @@ __metadata: eslint: "npm:^8.55.0" eslint-config-prettier: "npm:^9.1.0" eslint-plugin-prettier: "npm:^5.2.1" + ethers: "npm:~6.13.5" jest: "npm:29.7.0" joi: "npm:^17.13.3" pg: "npm:8.13.1"