From 929c57ef73b3daa5b85bd216e716a92b6849c5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 27 Feb 2025 13:21:24 +0100 Subject: [PATCH 1/4] add NDA module and related functionality --- .../reputation-oracle/server/.env.example | 4 + .../reputation-oracle/server/package.json | 2 +- .../server/src/app.module.ts | 4 + .../server/src/common/enums/reputation.ts | 1 - .../server/src/config/auth-config.service.ts | 7 ++ .../migrations/1740657822938-addNDA.ts | 17 ++++ .../src/modules/auth/auth.service.spec.ts | 1 + .../server/src/modules/auth/auth.service.ts | 1 + .../server/src/modules/nda/nda.controller.ts | 78 +++++++++++++++++++ .../server/src/modules/nda/nda.dto.ts | 9 +++ .../src/modules/nda/nda.error.filter.ts | 30 +++++++ .../server/src/modules/nda/nda.error.ts | 14 ++++ .../server/src/modules/nda/nda.module.ts | 12 +++ .../src/modules/nda/nda.service.spec.ts | 73 +++++++++++++++++ .../server/src/modules/nda/nda.service.ts | 28 +++++++ .../server/src/modules/user/user.entity.ts | 3 + .../server/test/constants.ts | 2 + 17 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts diff --git a/packages/apps/reputation-oracle/server/.env.example b/packages/apps/reputation-oracle/server/.env.example index 790d265d0b..0ffe4427b5 100644 --- a/packages/apps/reputation-oracle/server/.env.example +++ b/packages/apps/reputation-oracle/server/.env.example @@ -77,3 +77,7 @@ SYNAPS_WEBHOOK_SECRET= SYNAPS_BASE_URL= SYNAPS_STEP_DOCUMENT_ID= + +# NDA +NDA_URL= + diff --git a/packages/apps/reputation-oracle/server/package.json b/packages/apps/reputation-oracle/server/package.json index abb7266a4a..dae34fdbc4 100644 --- a/packages/apps/reputation-oracle/server/package.json +++ b/packages/apps/reputation-oracle/server/package.json @@ -19,7 +19,7 @@ "migration:show": "yarn build && typeorm-ts-node-commonjs migration:show -d typeorm.config.ts", "docker:db:up": "docker compose up -d postgres && yarn migration:run", "docker:db:down": "docker compose down postgres", - "setup:local": "ts-node ./test/setup.ts", + "setup:local": "ts-node ./scripts/setup-kv-store.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index 64f1d360cd..9ec3f31de5 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -23,6 +23,8 @@ import { EscrowCompletionModule } from './modules/escrow-completion/escrow-compl import { WebhookIncomingModule } from './modules/webhook/webhook-incoming.module'; import { WebhookOutgoingModule } from './modules/webhook/webhook-outgoing.module'; import { EmailModule } from './modules/email/module'; +import { UserModule } from './modules/user/user.module'; +import { NDAModule } from './modules/nda/nda.module'; import Environment from './utils/environment'; @Module({ @@ -71,6 +73,8 @@ import Environment from './utils/environment'; QualificationModule, EscrowCompletionModule, EmailModule, + UserModule, + NDAModule, ], }) export class AppModule {} diff --git a/packages/apps/reputation-oracle/server/src/common/enums/reputation.ts b/packages/apps/reputation-oracle/server/src/common/enums/reputation.ts index 792f6458c1..1c65e7ac88 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/reputation.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/reputation.ts @@ -4,7 +4,6 @@ export enum ReputationEntityType { EXCHANGE_ORACLE = 'exchange_oracle', RECORDING_ORACLE = 'recording_oracle', REPUTATION_ORACLE = 'reputation_oracle', - CREDENTIAL_VALIDATOR = 'credential_validator', } export enum ReputationLevel { 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 81c24aa0e1..2a52f577dc 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 @@ -68,4 +68,11 @@ export class AuthConfigService { get humanAppEmail(): string { return this.configService.getOrThrow('HUMAN_APP_EMAIL'); } + + /** + * Latest NDA Url. + */ + get latestNdaUrl(): string { + return this.configService.getOrThrow('NDA_URL'); + } } diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts new file mode 100644 index 0000000000..1cd1219733 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNDA1740657822938 implements MigrationInterface { + name = 'AddNDA1740657822938'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."users" ADD "nda_signed" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."users" DROP COLUMN "nda_signed"`, + ); + } +} 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 1fe447ed0d..737f736ed2 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 @@ -318,6 +318,7 @@ describe('AuthService', () => { wallet_address: userEntity.evmAddress, kyc_status: userEntity.kyc?.status, reputation_network: MOCK_ADDRESS, + nda_signed: false, qualifications: [], role: userEntity.role, }, 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 822f3491e6..e8c3ca777a 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 @@ -164,6 +164,7 @@ export class AuthService { wallet_address: userEntity.evmAddress, role: userEntity.role, kyc_status: userEntity.kyc?.status, + nda_signed: userEntity.ndaSigned === this.authConfigService.latestNdaUrl, reputation_network: this.web3Service.getOperatorAddress(), qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts new file mode 100644 index 0000000000..51e7b26136 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Get, + Post, + Body, + Req, + UseFilters, + UseGuards, +} from '@nestjs/common'; +import { NDAService } from './nda.service'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from 'src/common/guards'; +import { RequestWithUser } from 'src/common/interfaces/request'; +import { NDAErrorFilter } from './nda.error.filter'; +import { AuthConfigService } from 'src/config/auth-config.service'; +import { NDASignatureDto } from './nda.dto'; + +@ApiTags('NDA') +@UseFilters(NDAErrorFilter) +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('nda') +export class NDAController { + constructor( + private readonly ndaService: NDAService, + private readonly authConfigService: AuthConfigService, + ) {} + + @ApiOperation({ + summary: 'Retrieves latest NDA URL', + description: + 'Retrieves the latest NDA URL that users must sign to join the oracle', + }) + @ApiResponse({ + status: 200, + description: 'URL retrieved successfully', + type: String, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @Get('latest') + getLatestNDA() { + return this.authConfigService.latestNdaUrl; + } + + @ApiOperation({ + summary: 'Sign NDA', + description: + 'Signs the NDA with the provided URL. The URL must match the latest NDA URL.', + }) + @ApiBody({ type: NDASignatureDto }) + @ApiResponse({ + status: 200, + description: 'NDA signed successfully', + type: String, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @ApiResponse({ + status: 400, + description: 'Bad Request. User has already signed the NDA.', + }) + @Post('sign') + async signNDA(@Req() request: RequestWithUser, @Body() nda: NDASignatureDto) { + await this.ndaService.signNDA(request.user, nda); + return { message: 'NDA signed successfully' }; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts new file mode 100644 index 0000000000..43a7c5c916 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsUrl } from 'class-validator'; + +export class NDASignatureDto { + @ApiProperty({ name: 'url' }) + @IsString() + @IsUrl() + public url: string; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts new file mode 100644 index 0000000000..d6ce368f16 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts @@ -0,0 +1,30 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import { NDAError } from './nda.error'; +import logger from '../../logger'; + +@Catch(NDAError) +export class NDAErrorFilter implements ExceptionFilter { + private readonly logger = logger.child({ context: NDAErrorFilter.name }); + + catch(exception: NDAError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const status = HttpStatus.BAD_REQUEST; + + this.logger.error('NDA error', exception); + + return response.status(status).json({ + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts new file mode 100644 index 0000000000..d253c6f8c8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts @@ -0,0 +1,14 @@ +import { BaseError } from '../../common/errors/base'; + +export enum NDAErrorMessage { + INVALID_NDA = 'Invalid NDA URL', + NDA_EXISTS = 'User has already signed the NDA', +} + +export class NDAError extends BaseError { + userId: number; + constructor(message: NDAErrorMessage, userId: number) { + super(message); + this.userId = userId; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts new file mode 100644 index 0000000000..ce331c815c --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { NDAController } from './nda.controller'; +import { NDAService } from './nda.service'; +import { UserModule } from '../user/user.module'; +import { UserRepository } from '../user/user.repository'; + +@Module({ + imports: [UserModule], + controllers: [NDAController], + providers: [NDAService, UserRepository], +}) +export class NDAModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts new file mode 100644 index 0000000000..ee706a5570 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts @@ -0,0 +1,73 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NDAService } from './nda.service'; +import { UserRepository } from '../user/user.repository'; +import { AuthConfigService } from '../../config/auth-config.service'; +import { NDASignatureDto } from './nda.dto'; +import { NDAError, NDAErrorMessage } from './nda.error'; +import { faker } from '@faker-js/faker/.'; + +const mockUserRepository = { + updateOne: jest.fn(), +}; +const validNdaUrl = faker.internet.url(); +const mockAuthConfigService = { + latestNdaUrl: validNdaUrl, +}; + +describe('NDAService', () => { + let service: NDAService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NDAService, + { provide: UserRepository, useValue: mockUserRepository }, + { provide: AuthConfigService, useValue: mockAuthConfigService }, + ], + }).compile(); + + service = module.get(NDAService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('signNDA', () => { + const user: any = { + id: 1, + email: faker.internet.email(), + password: 'password', + ndaSigned: undefined, + }; + + const nda: NDASignatureDto = { + url: validNdaUrl, + }; + + it('should sign the NDA if the URL is valid and the user has not signed it yet', async () => { + await service.signNDA(user, nda); + + expect(user.ndaSigned).toBe(validNdaUrl); + expect(mockUserRepository.updateOne).toHaveBeenCalledWith(user); + }); + + it('should throw an error if the NDA URL is invalid', async () => { + const invalidNda: NDASignatureDto = { + url: faker.internet.url(), + }; + + await expect(service.signNDA(user, invalidNda)).rejects.toThrow( + new NDAError(NDAErrorMessage.INVALID_NDA, user.id), + ); + }); + + it('should throw an error if the user has already signed the NDA', async () => { + user.ndaSigned = mockAuthConfigService.latestNdaUrl; + + await expect(service.signNDA(user, nda)).rejects.toThrow( + new NDAError(NDAErrorMessage.NDA_EXISTS, user.id), + ); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts new file mode 100644 index 0000000000..778be702a0 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { AuthConfigService } from '../../config/auth-config.service'; +import { UserEntity } from '../user/user.entity'; +import { UserRepository } from '../user/user.repository'; +import { NDASignatureDto } from './nda.dto'; +import { NDAError, NDAErrorMessage } from './nda.error'; + +@Injectable() +export class NDAService { + constructor( + private readonly userRepository: UserRepository, + private readonly authConfigService: AuthConfigService, + ) {} + + async signNDA(user: UserEntity, nda: NDASignatureDto) { + const ndaUrl = this.authConfigService.latestNdaUrl; + if (nda.url !== ndaUrl) { + throw new NDAError(NDAErrorMessage.INVALID_NDA, user.id); + } + if (user.ndaSigned === ndaUrl) { + throw new NDAError(NDAErrorMessage.NDA_EXISTS, user.id); + } + + user.ndaSigned = nda.url; + + await this.userRepository.updateOne(user); + } +} 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 285d3b3df8..86a7496411 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 @@ -48,4 +48,7 @@ export class UserEntity extends BaseEntity implements IUser { (userQualification) => userQualification.user, ) public userQualifications: UserQualificationEntity[]; + + @Column({ type: 'varchar', nullable: true }) + public ndaSigned?: string; } diff --git a/packages/apps/reputation-oracle/server/test/constants.ts b/packages/apps/reputation-oracle/server/test/constants.ts index 552af71a55..bb3f720b0b 100644 --- a/packages/apps/reputation-oracle/server/test/constants.ts +++ b/packages/apps/reputation-oracle/server/test/constants.ts @@ -98,6 +98,7 @@ export const MOCK_WEB3_RPC_URL = 'http://localhost:8545'; export const MOCK_QUALIFICATION_MIN_VALIDITY = 100; export const MOCK_FE_URL = 'http://localhost:3001'; export const MOCK_KYC_API_PRIVATE_KEY = 'api-private-key'; +export const MOCK_NDA_URL = 'https://staging.humanprotocol.org/nda'; export const mockConfig: any = { S3_ACCESS_KEY: MOCK_S3_ACCESS_KEY, @@ -128,6 +129,7 @@ export const mockConfig: any = { HCAPTCHA_LABELING_URL: MOCK_HCAPTCHA_LABELING_URL, HCAPTCHA_DEFAULT_LABELER_LANG: MOCK_HCAPTCHA_DEFAULT_LABELER_LANG, KYC_API_PRIVATE_KEY: MOCK_KYC_API_PRIVATE_KEY, + NDA_URL: MOCK_NDA_URL, }; export const MOCK_BACKOFF_INTERVAL_SECONDS = 120; From c767287fd6d41c6dc86bb9a69f753fd4089db2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 13:53:58 +0100 Subject: [PATCH 2/4] refactor: rename nda_signed to nda_signed_url and update related logic --- .../migrations/1740657822938-addNDA.ts | 4 +-- .../server/src/modules/auth/auth.service.ts | 3 +- .../server/src/modules/nda/nda.controller.ts | 10 ++---- .../server/src/modules/nda/nda.module.ts | 3 +- .../src/modules/nda/nda.service.spec.ts | 31 ++++++++++--------- .../server/src/modules/nda/nda.service.ts | 6 ++-- .../server/src/modules/user/user.entity.ts | 2 +- 7 files changed, 27 insertions(+), 32 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts index 1cd1219733..f013edba35 100644 --- a/packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1740657822938-addNDA.ts @@ -5,13 +5,13 @@ export class AddNDA1740657822938 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `ALTER TABLE "hmt"."users" ADD "nda_signed" character varying`, + `ALTER TABLE "hmt"."users" ADD "nda_signed_url" character varying`, ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( - `ALTER TABLE "hmt"."users" DROP COLUMN "nda_signed"`, + `ALTER TABLE "hmt"."users" DROP COLUMN "nda_signed_url"`, ); } } 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 5ca739f0ce..87fb8e980b 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 @@ -155,7 +155,8 @@ export class AuthService { wallet_address: userEntity.evmAddress, role: userEntity.role, kyc_status: userEntity.kyc?.status, - nda_signed: userEntity.ndaSigned === this.authConfigService.latestNdaUrl, + nda_signed: + userEntity.ndaSignedUrl === this.authConfigService.latestNdaUrl, reputation_network: operatorAddress, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts index 51e7b26136..2c4da13cfd 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts @@ -6,6 +6,7 @@ import { Req, UseFilters, UseGuards, + HttpCode, } from '@nestjs/common'; import { NDAService } from './nda.service'; import { @@ -42,10 +43,6 @@ export class NDAController { description: 'URL retrieved successfully', type: String, }) - @ApiResponse({ - status: 401, - description: 'Unauthorized. Missing or invalid credentials.', - }) @Get('latest') getLatestNDA() { return this.authConfigService.latestNdaUrl; @@ -62,10 +59,7 @@ export class NDAController { description: 'NDA signed successfully', type: String, }) - @ApiResponse({ - status: 401, - description: 'Unauthorized. Missing or invalid credentials.', - }) + @HttpCode(200) @ApiResponse({ status: 400, description: 'Bad Request. User has already signed the NDA.', diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts index ce331c815c..7aaa8a5435 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { NDAController } from './nda.controller'; import { NDAService } from './nda.service'; import { UserModule } from '../user/user.module'; -import { UserRepository } from '../user/user.repository'; @Module({ imports: [UserModule], controllers: [NDAController], - providers: [NDAService, UserRepository], + providers: [NDAService], }) export class NDAModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts index ee706a5570..2639f917a1 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts @@ -5,6 +5,7 @@ import { AuthConfigService } from '../../config/auth-config.service'; import { NDASignatureDto } from './nda.dto'; import { NDAError, NDAErrorMessage } from './nda.error'; import { faker } from '@faker-js/faker/.'; +import { UserEntity } from '../user/user.entity'; const mockUserRepository = { updateOne: jest.fn(), @@ -16,6 +17,7 @@ const mockAuthConfigService = { describe('NDAService', () => { let service: NDAService; + let user: Pick; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -27,6 +29,12 @@ describe('NDAService', () => { }).compile(); service = module.get(NDAService); + + user = { + id: faker.number.int(), + email: faker.internet.email(), + ndaSignedUrl: undefined, + }; }); afterEach(() => { @@ -34,21 +42,14 @@ describe('NDAService', () => { }); describe('signNDA', () => { - const user: any = { - id: 1, - email: faker.internet.email(), - password: 'password', - ndaSigned: undefined, - }; - const nda: NDASignatureDto = { url: validNdaUrl, }; it('should sign the NDA if the URL is valid and the user has not signed it yet', async () => { - await service.signNDA(user, nda); + await service.signNDA(user as any, nda); - expect(user.ndaSigned).toBe(validNdaUrl); + expect(user.ndaSignedUrl).toBe(validNdaUrl); expect(mockUserRepository.updateOne).toHaveBeenCalledWith(user); }); @@ -57,17 +58,17 @@ describe('NDAService', () => { url: faker.internet.url(), }; - await expect(service.signNDA(user, invalidNda)).rejects.toThrow( + await expect(service.signNDA(user as any, invalidNda)).rejects.toThrow( new NDAError(NDAErrorMessage.INVALID_NDA, user.id), ); }); - it('should throw an error if the user has already signed the NDA', async () => { - user.ndaSigned = mockAuthConfigService.latestNdaUrl; + it('should return ok if the user has already signed the NDA', async () => { + user.ndaSignedUrl = mockAuthConfigService.latestNdaUrl; + await service.signNDA(user as any, nda); - await expect(service.signNDA(user, nda)).rejects.toThrow( - new NDAError(NDAErrorMessage.NDA_EXISTS, user.id), - ); + expect(user.ndaSignedUrl).toBe(validNdaUrl); + expect(mockUserRepository.updateOne).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts index 778be702a0..f5e9ca87fa 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts @@ -17,11 +17,11 @@ export class NDAService { if (nda.url !== ndaUrl) { throw new NDAError(NDAErrorMessage.INVALID_NDA, user.id); } - if (user.ndaSigned === ndaUrl) { - throw new NDAError(NDAErrorMessage.NDA_EXISTS, user.id); + if (user.ndaSignedUrl === ndaUrl) { + return; } - user.ndaSigned = nda.url; + user.ndaSignedUrl = nda.url; await this.userRepository.updateOne(user); } 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 86a7496411..f6e8fcd99e 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 @@ -50,5 +50,5 @@ export class UserEntity extends BaseEntity implements IUser { public userQualifications: UserQualificationEntity[]; @Column({ type: 'varchar', nullable: true }) - public ndaSigned?: string; + public ndaSignedUrl?: string; } From cb6c9a0674b7f948180143eb5e1ba1149b779cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Tue, 4 Mar 2025 11:56:32 +0100 Subject: [PATCH 3/4] [Human App] NDA (#3146) * feat: add NDA module with service, controller, and related functionality * feat: add HTTP status code to signNDA endpoint and update tests to use ndaServiceMock * Move user within the signNDA tests as it is not being modified --- .../apps/human-app/server/src/app.module.ts | 4 ++ .../common/config/gateway-config.service.ts | 10 ++++ .../enums/reputation-oracle-endpoints.ts | 2 + .../reputation-oracle.gateway.ts | 26 +++++++++ .../reputation-oracle.mapper.profile.ts | 10 ++++ .../server/src/modules/nda/model/nda.model.ts | 34 +++++++++++ .../server/src/modules/nda/nda.controller.ts | 56 +++++++++++++++++++ .../src/modules/nda/nda.mapper.profile.ts | 31 ++++++++++ .../server/src/modules/nda/nda.module.ts | 11 ++++ .../server/src/modules/nda/nda.service.ts | 21 +++++++ .../modules/nda/spec/nda.controller.spec.ts | 53 ++++++++++++++++++ .../src/modules/nda/spec/nda.fixtures.ts | 33 +++++++++++ .../src/modules/nda/spec/nda.service.mock.ts | 4 ++ .../src/modules/nda/nda.service.spec.ts | 12 ++-- 14 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 packages/apps/human-app/server/src/modules/nda/model/nda.model.ts create mode 100644 packages/apps/human-app/server/src/modules/nda/nda.controller.ts create mode 100644 packages/apps/human-app/server/src/modules/nda/nda.mapper.profile.ts create mode 100644 packages/apps/human-app/server/src/modules/nda/nda.module.ts create mode 100644 packages/apps/human-app/server/src/modules/nda/nda.service.ts create mode 100644 packages/apps/human-app/server/src/modules/nda/spec/nda.controller.spec.ts create mode 100644 packages/apps/human-app/server/src/modules/nda/spec/nda.fixtures.ts create mode 100644 packages/apps/human-app/server/src/modules/nda/spec/nda.service.mock.ts diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 58ec0021da..b220b001f5 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -43,6 +43,8 @@ import { EnvironmentConfigService } from './common/config/environment-config.ser import { ForbidUnauthorizedHostMiddleware } from './common/middleware/host-check.middleware'; import { HealthModule } from './modules/health/health.module'; import { UiConfigurationModule } from './modules/ui-configuration/ui-configuration.module'; +import { NDAModule } from './modules/nda/nda.module'; +import { NDAController } from './modules/nda/nda.controller'; const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); @@ -125,6 +127,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); CronJobModule, HealthModule, UiConfigurationModule, + NDAModule, ], controllers: [ AppController, @@ -137,6 +140,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); HCaptchaController, RegisterAddressController, TokenRefreshController, + NDAController, ], exports: [HttpModule], providers: [EnvironmentConfigService], 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 1feec7deec..e07bf927e6 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 @@ -110,6 +110,16 @@ export class GatewayConfigService { method: HttpMethod.GET, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.GET_LATEST_NDA]: { + endpoint: '/nda/latest', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.SIGN_NDA]: { + endpoint: '/nda/sign', + method: HttpMethod.POST, + headers: this.JSON_HEADER, + }, } as Record, }, [ExternalApiName.HCAPTCHA_LABELING_STATS]: { 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 389f079865..c929a56aa6 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 @@ -16,6 +16,8 @@ export enum ReputationOracleEndpoints { KYC_ON_CHAIN = 'kyc_on_chain', REGISTRATION_IN_EXCHANGE_ORACLE = 'registration_in_exchange_oracle', GET_REGISTRATION_IN_EXCHANGE_ORACLES = 'get_registration_in_exchange_oracles', + GET_LATEST_NDA = 'GET_LATEST_NDA', + SIGN_NDA = 'SIGN_NDA', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', 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 ad6f09bb7b..8e5e3fa593 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 @@ -76,6 +76,13 @@ import { SigninOperatorData, SigninOperatorResponse, } from '../../modules/user-operator/model/operator-signin.model'; +import { + GetNDACommand, + GetNDAResponse, + SignNDACommand, + SignNDAData, + SignNDAResponse, +} from '../../modules/nda/model/nda.model'; @Injectable() export class ReputationOracleGateway { @@ -329,6 +336,25 @@ export class ReputationOracleGateway { return this.handleRequestToReputationOracle(options); } + async getLatestNDA(command: GetNDACommand) { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.GET_LATEST_NDA, + undefined, + command.token, + ); + return this.handleRequestToReputationOracle(options); + } + + async sendSignedNDA(command: SignNDACommand) { + const data = this.mapper.map(command, SignNDACommand, SignNDAData); + const options = this.getEndpointOptions( + ReputationOracleEndpoints.SIGN_NDA, + data, + command.token, + ); + return this.handleRequestToReputationOracle(options); + } + sendKycOnChain(token: string) { const options = this.getEndpointOptions( ReputationOracleEndpoints.KYC_ON_CHAIN, diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts index 000f361747..9e551d17ca 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts @@ -57,6 +57,7 @@ import { SigninOperatorCommand, SigninOperatorData, } from '../../modules/user-operator/model/operator-signin.model'; +import { SignNDACommand, SignNDAData } from '../../modules/nda/model/nda.model'; @Injectable() export class ReputationOracleProfile extends AutomapperProfile { @@ -143,6 +144,15 @@ export class ReputationOracleProfile extends AutomapperProfile { destination: new SnakeCaseNamingConvention(), }), ); + createMap( + mapper, + SignNDACommand, + SignNDAData, + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); }; } } diff --git a/packages/apps/human-app/server/src/modules/nda/model/nda.model.ts b/packages/apps/human-app/server/src/modules/nda/model/nda.model.ts new file mode 100644 index 0000000000..f4a16cbfd1 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/model/nda.model.ts @@ -0,0 +1,34 @@ +import { AutoMap } from '@automapper/classes'; +import { IsString, IsUrl } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GetNDACommand { + token: string; +} + +export class GetNDAResponse { + url: string; +} + +export class SignNDADto { + @AutoMap() + @IsUrl() + @IsString() + @ApiProperty({ example: 'string' }) + url: string; +} + +export class SignNDACommand { + @AutoMap() + url: string; + token: string; +} + +export class SignNDAData { + @AutoMap() + url: string; +} + +export class SignNDAResponse { + message: string; +} diff --git a/packages/apps/human-app/server/src/modules/nda/nda.controller.ts b/packages/apps/human-app/server/src/modules/nda/nda.controller.ts new file mode 100644 index 0000000000..ed886df9b1 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/nda.controller.ts @@ -0,0 +1,56 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { + Body, + Controller, + Get, + HttpCode, + Post, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Authorization } from '../../common/config/params-decorators'; +import { GetNDACommand, SignNDACommand, SignNDADto } from './model/nda.model'; +import { NDAService } from './nda.service'; + +@Controller('/nda') +@ApiTags('NDA') +@UsePipes(new ValidationPipe()) +@ApiBearerAuth() +export class NDAController { + @InjectMapper() private readonly mapper: Mapper; + + constructor( + private readonly ndaService: NDAService, + @InjectMapper() mapper: Mapper, + ) { + this.mapper = mapper; + } + + @ApiOperation({ + summary: 'Retrieves latest NDA URL', + description: + 'Retrieves the latest NDA URL that users must sign to join the oracle', + }) + @Get('/') + async getLatestNDA(@Authorization() token: string) { + const command = new GetNDACommand(); + command.token = token; + return this.ndaService.getLatestNDA(command); + } + + @ApiOperation({ + summary: 'Sign NDA', + description: + 'Signs the NDA with the provided URL. The URL must match the latest NDA URL.', + }) + @HttpCode(200) + @Post('sign') + async signNDA(@Body() dto: SignNDADto, @Authorization() token: string) { + const command = this.mapper.map(dto, SignNDADto, SignNDACommand); + command.token = token; + await this.ndaService.signNDA(command); + return { message: 'NDA signed successfully' }; + } +} diff --git a/packages/apps/human-app/server/src/modules/nda/nda.mapper.profile.ts b/packages/apps/human-app/server/src/modules/nda/nda.mapper.profile.ts new file mode 100644 index 0000000000..babf7e792e --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/nda.mapper.profile.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { + CamelCaseNamingConvention, + createMap, + Mapper, + namingConventions, + SnakeCaseNamingConvention, +} from '@automapper/core'; +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { SignNDACommand, SignNDADto } from './model/nda.model'; + +@Injectable() +export class SignNDAProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap( + mapper, + SignNDADto, + SignNDACommand, + namingConventions({ + source: new SnakeCaseNamingConvention(), + destination: new CamelCaseNamingConvention(), + }), + ); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/nda/nda.module.ts b/packages/apps/human-app/server/src/modules/nda/nda.module.ts new file mode 100644 index 0000000000..c02d7c2784 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/nda.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { NDAService } from 'src/modules/nda/nda.service'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; +import { SignNDAProfile } from './nda.mapper.profile'; + +@Module({ + imports: [ReputationOracleModule], + providers: [NDAService, SignNDAProfile], + exports: [NDAService], +}) +export class NDAModule {} diff --git a/packages/apps/human-app/server/src/modules/nda/nda.service.ts b/packages/apps/human-app/server/src/modules/nda/nda.service.ts new file mode 100644 index 0000000000..d73f8f2279 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/nda.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { + GetNDACommand, + GetNDAResponse, + SignNDACommand, + SignNDAResponse, +} from './model/nda.model'; + +@Injectable() +export class NDAService { + constructor(private readonly gateway: ReputationOracleGateway) {} + + async getLatestNDA(command: GetNDACommand): Promise { + return this.gateway.getLatestNDA(command); + } + + async signNDA(command: SignNDACommand): Promise { + return await this.gateway.sendSignedNDA(command); + } +} diff --git a/packages/apps/human-app/server/src/modules/nda/spec/nda.controller.spec.ts b/packages/apps/human-app/server/src/modules/nda/spec/nda.controller.spec.ts new file mode 100644 index 0000000000..333e9c79ca --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/spec/nda.controller.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; + +import { NDAController } from '../nda.controller'; +import { NDAService } from '../nda.service'; +import { ndaServiceMock } from './nda.service.mock'; +import { + NDA_TOKEN, + signNDACommandFixture, + signNDADtoFixture, +} from './nda.fixtures'; +import { SignNDAProfile } from '../nda.mapper.profile'; + +describe('NDAController', () => { + let controller: NDAController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NDAController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [NDAService, SignNDAProfile], + }) + .overrideProvider(NDAService) + .useValue(ndaServiceMock) + .compile(); + + controller = module.get(NDAController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('nda', () => { + it('should call service signNDA method with proper fields set', async () => { + const dto = signNDADtoFixture; + const command = signNDACommandFixture; + await controller.signNDA(dto, NDA_TOKEN); + expect(ndaServiceMock.signNDA).toHaveBeenCalledWith(command); + }); + + it('should call service getLatestNDA method with proper fields set', async () => { + const token = NDA_TOKEN; + await controller.getLatestNDA(token); + expect(ndaServiceMock.getLatestNDA).toHaveBeenCalledWith({ token }); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/nda/spec/nda.fixtures.ts b/packages/apps/human-app/server/src/modules/nda/spec/nda.fixtures.ts new file mode 100644 index 0000000000..1b05d57e95 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/spec/nda.fixtures.ts @@ -0,0 +1,33 @@ +import { + GetNDACommand, + GetNDAResponse, + SignNDACommand, + SignNDAData, + SignNDADto, + SignNDAResponse, +} from '../model/nda.model'; + +const URL = 'http://some_url.com'; +export const NDA_TOKEN = 'my_access_token'; + +export const signNDADtoFixture: SignNDADto = { + url: URL, +}; +export const signNDACommandFixture: SignNDACommand = { + url: URL, + token: NDA_TOKEN, +}; +export const signNDADataFixture: SignNDAData = { + url: URL, +}; +export const signNDAResponseFixture: SignNDAResponse = { + message: 'NDA signed successfully', +}; + +export const getNDACommandFixture: GetNDACommand = { + token: NDA_TOKEN, +}; + +export const getNDAResponseFixture: GetNDAResponse = { + url: 'URL', +}; diff --git a/packages/apps/human-app/server/src/modules/nda/spec/nda.service.mock.ts b/packages/apps/human-app/server/src/modules/nda/spec/nda.service.mock.ts new file mode 100644 index 0000000000..2496b40db9 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/nda/spec/nda.service.mock.ts @@ -0,0 +1,4 @@ +export const ndaServiceMock = { + getLatestNDA: jest.fn(), + signNDA: jest.fn(), +}; diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts index 2639f917a1..c21b3ee1bc 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts @@ -17,7 +17,6 @@ const mockAuthConfigService = { describe('NDAService', () => { let service: NDAService; - let user: Pick; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -29,12 +28,6 @@ describe('NDAService', () => { }).compile(); service = module.get(NDAService); - - user = { - id: faker.number.int(), - email: faker.internet.email(), - ndaSignedUrl: undefined, - }; }); afterEach(() => { @@ -45,6 +38,11 @@ describe('NDAService', () => { const nda: NDASignatureDto = { url: validNdaUrl, }; + const user: Pick = { + id: faker.number.int(), + email: faker.internet.email(), + ndaSignedUrl: undefined, + }; it('should sign the NDA if the URL is valid and the user has not signed it yet', async () => { await service.signNDA(user as any, nda); From 14e1dd7629632384b0292ed07c0a8db7254b99e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 4 Mar 2025 13:18:02 +0100 Subject: [PATCH 4/4] Clean user instance for each test --- .../server/src/modules/nda/nda.service.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts index c21b3ee1bc..3bf04e2006 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts @@ -35,14 +35,17 @@ describe('NDAService', () => { }); describe('signNDA', () => { + let user: Pick; + beforeEach(async () => { + user = { + id: faker.number.int(), + email: faker.internet.email(), + ndaSignedUrl: undefined, + }; + }); const nda: NDASignatureDto = { url: validNdaUrl, }; - const user: Pick = { - id: faker.number.int(), - email: faker.internet.email(), - ndaSignedUrl: undefined, - }; it('should sign the NDA if the URL is valid and the user has not signed it yet', async () => { await service.signNDA(user as any, nda);