From a9e39ba433068d1d6224f369d9c803ac34b93bb2 Mon Sep 17 00:00:00 2001 From: portuu3 <61605646+portuu3@users.noreply.github.com> Date: Wed, 9 Aug 2023 18:05:24 +0200 Subject: [PATCH] Job Launcher - Refactor auth (#740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Auth refactor JL * authentication fixes * remove jwt interface * fix tests and clean code * fix review comments * Refactored services and removed unnecessary nesting (#747) * database and payment fixes (#743) Co-authored-by: Eugene Voronov * use of access and refresh tokens for auth * fix tests * make sure token contains bearer * rename jwt prefix --------- Co-authored-by: Francisco López Co-authored-by: eugenvoronov <104138627+eugenvoronov@users.noreply.github.com> Co-authored-by: Eugene Voronov --- .../apps/job-launcher/server/.env.example | 51 ++- .../job-launcher/server/docker-compose.yml | 49 ++- .../apps/job-launcher/server/package.json | 48 +-- .../job-launcher/server/src/app.module.ts | 8 +- .../server/src/common/config/env.ts | 5 +- .../server/src/common/constants/errors.ts | 3 + .../server/src/common/constants/index.ts | 2 + .../server/src/common/constants/network.ts | 59 --- .../server/src/common/constants/payment.ts | 2 + .../server/src/common/decorators/index.ts | 1 - .../server/src/common/decorators/role.ts | 6 - .../server/src/common/enums/auth.ts | 4 - .../server/src/common/guards/index.ts | 3 +- .../guards/{jwt.http.ts => jwt.auth.ts} | 2 +- .../server/src/common/guards/roles.ts | 26 -- .../server/src/common/interfaces/auth.ts | 6 - .../server/src/common/interfaces/index.ts | 2 +- .../server/src/common/interfaces/payment.ts | 3 - .../server/src/common/interfaces/payments.ts | 3 + .../server/src/common/test/dto.ts | 19 - .../server/src/common/test/entity.ts | 17 - .../server/src/common/test/index.ts | 2 - .../server/src/common/types/index.ts | 1 + .../server/src/common/types/request.ts | 3 + .../server/src/common/utils/index.ts | 30 ++ .../migrations/1677845576145-addShema.ts | 12 - .../1677845804077-installExtension.ts | 11 - .../migrations/1677867973538-addUserTable.ts | 72 ---- .../migrations/1677867985970-addAuthTable.ts | 87 ---- .../migrations/1677867996573-addTokenTable.ts | 80 ---- .../migrations/1685352934954-addJobTable.ts | 92 ----- .../1685352955107-addPaymentTable.ts | 99 ----- .../1691485394906-InitialMigration.ts | 197 +++++++++ ...h.jwt.controller.ts => auth.controller.ts} | 68 ++-- .../server/src/modules/auth/auth.dto.ts | 30 +- .../server/src/modules/auth/auth.entity.ts | 24 +- .../server/src/modules/auth/auth.module.ts | 11 +- .../src/modules/auth/auth.service.spec.ts | 304 +++++++------- .../server/src/modules/auth/auth.service.ts | 132 +++--- .../src/modules/auth/strategy/jwt.http.ts | 58 ++- .../server/src/modules/auth/token.entity.ts | 4 +- .../server/src/modules/job/job.controller.ts | 15 +- .../server/src/modules/job/job.dto.ts | 28 -- .../server/src/modules/job/job.entity.ts | 11 +- .../src/modules/job/job.service.spec.ts | 169 ++++---- .../server/src/modules/job/job.service.ts | 41 +- .../job/routing-protocol.service.spec.ts | 12 +- .../modules/payment/currency.service.spec.ts | 91 ----- .../src/modules/payment/currency.service.ts | 36 -- .../src/modules/payment/payment.controller.ts | 26 +- .../server/src/modules/payment/payment.dto.ts | 3 +- .../src/modules/payment/payment.entity.ts | 26 +- .../src/modules/payment/payment.module.ts | 5 +- .../modules/payment/payment.service.spec.ts | 381 ++++++++---------- .../src/modules/payment/payment.service.ts | 102 ++--- .../src/modules/user/user.controller.ts | 43 +- .../server/src/modules/user/user.entity.ts | 17 +- .../server/src/modules/user/user.module.ts | 7 +- .../src/modules/user/user.repository.ts | 4 - .../src/modules/user/user.service.spec.ts | 59 ++- .../server/src/modules/user/user.service.ts | 29 +- .../server/{src/common => }/test/constants.ts | 6 +- .../job-launcher/server/typeorm.config.ts | 12 +- .../server/src/common/config/env.ts | 2 +- yarn.lock | 2 +- 65 files changed, 1093 insertions(+), 1670 deletions(-) delete mode 100644 packages/apps/job-launcher/server/src/common/constants/network.ts delete mode 100644 packages/apps/job-launcher/server/src/common/decorators/role.ts delete mode 100644 packages/apps/job-launcher/server/src/common/enums/auth.ts rename packages/apps/job-launcher/server/src/common/guards/{jwt.http.ts => jwt.auth.ts} (93%) delete mode 100644 packages/apps/job-launcher/server/src/common/guards/roles.ts delete mode 100644 packages/apps/job-launcher/server/src/common/interfaces/auth.ts delete mode 100644 packages/apps/job-launcher/server/src/common/interfaces/payment.ts create mode 100644 packages/apps/job-launcher/server/src/common/interfaces/payments.ts delete mode 100644 packages/apps/job-launcher/server/src/common/test/dto.ts delete mode 100644 packages/apps/job-launcher/server/src/common/test/entity.ts delete mode 100644 packages/apps/job-launcher/server/src/common/test/index.ts create mode 100644 packages/apps/job-launcher/server/src/common/types/index.ts create mode 100644 packages/apps/job-launcher/server/src/common/types/request.ts create mode 100644 packages/apps/job-launcher/server/src/common/utils/index.ts delete mode 100644 packages/apps/job-launcher/server/src/database/migrations/1677845576145-addShema.ts delete mode 100644 packages/apps/job-launcher/server/src/database/migrations/1677845804077-installExtension.ts delete mode 100644 packages/apps/job-launcher/server/src/database/migrations/1677867973538-addUserTable.ts delete mode 100644 packages/apps/job-launcher/server/src/database/migrations/1677867985970-addAuthTable.ts delete mode 100644 packages/apps/job-launcher/server/src/database/migrations/1677867996573-addTokenTable.ts delete mode 100644 packages/apps/job-launcher/server/src/database/migrations/1685352934954-addJobTable.ts delete mode 100644 packages/apps/job-launcher/server/src/database/migrations/1685352955107-addPaymentTable.ts create mode 100644 packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts rename packages/apps/job-launcher/server/src/modules/auth/{auth.jwt.controller.ts => auth.controller.ts} (59%) delete mode 100644 packages/apps/job-launcher/server/src/modules/payment/currency.service.spec.ts delete mode 100644 packages/apps/job-launcher/server/src/modules/payment/currency.service.ts rename packages/apps/job-launcher/server/{src/common => }/test/constants.ts (88%) diff --git a/packages/apps/job-launcher/server/.env.example b/packages/apps/job-launcher/server/.env.example index 2326c0d1da..9177defea3 100644 --- a/packages/apps/job-launcher/server/.env.example +++ b/packages/apps/job-launcher/server/.env.example @@ -1,44 +1,41 @@ # General -NODE_ENV= -HOST= -PORT= -FE_URL= -SESSION_SECRET= -PASSWORD_SECRET='$2b$10$EICgM2wYixoJisgqckU9gu' -EMAIL_FROM= +NODE_ENV=development +HOST=localhost +PORT=3000 +FE_URL=http://localhost:3001 +SESSION_SECRET=test +PASSWORD_SECRET=test # Database -DB_TYPE= -POSTGRES_HOST= -POSTGRES_USER= -POSTGRES_PASSWORD= -POSTGRES_DB= -POSTGRES_SYNC= -POSTGRES_PORT= +POSTGRES_HOST=0.0.0.0 +POSTGRES_USER=operator +POSTGRES_PASSWORD=qwerty +POSTGRES_DB=job-launcher +POSTGRES_SYNC=false +POSTGRES_PORT=5432 #Web3 WEB3_PRIVATE_KEY= -JOB_LAUNCHER_FEE= -RECORDING_ORACLE_FEE= -REPUTATION_ORACLE_FEE= +JOB_LAUNCHER_FEE=1 +RECORDING_ORACLE_FEE=1 +REPUTATION_ORACLE_FEE=1 EXCHANGE_ORACLE_ADDRESS= EXCHANGE_ORACLE_WEBHOOK_URL= RECORDING_ORACLE_ADDRESS= REPUTATION_ORACLE_ADDRESS= # Auth -JWT_SECRET= -JWT_ACCESS_TOKEN_EXPIRES_IN= -JWT_REFRESH_TOKEN_EXPIRES_IN= +JWT_SECRET=test-secret +JWT_ACCESS_TOKEN_EXPIRES_IN=1d +JWT_REFRESH_TOKEN_EXPIRES_IN=1d # S3 -S3_ENDPOINT= -S3_PORT= -S3_ACCESS_KEY= -S3_SECRET_KEY= -S3_REGION= -S3_BACKET= -S3_USE_SSL= +S3_ENDPOINT=localhost +S3_PORT=9000 +S3_ACCESS_KEY=access-key +S3_SECRET_KEY=secret-key +S3_BUCKET=manifests +S3_USE_SSL=false # Stripe STRIPE_SECRET_KEY= diff --git a/packages/apps/job-launcher/server/docker-compose.yml b/packages/apps/job-launcher/server/docker-compose.yml index 91bb8474c1..e90cbb2401 100644 --- a/packages/apps/job-launcher/server/docker-compose.yml +++ b/packages/apps/job-launcher/server/docker-compose.yml @@ -1,21 +1,50 @@ -version: '3.7' +version: '3.8' services: postgres: image: postgres:latest restart: always environment: - - POSTGRES_HOST=0.0.0.0 - - POSTGRES_USER=operator - - POSTGRES_PASSWORD=qwerty - - POSTGRES_DB=job-launcher - - POSTGRES_PORT=5435 - - POSTGRES_SYNC=false + - POSTGRES_HOST=${POSTGRES_HOST} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_SYNC=${POSTGRES_SYNC} logging: options: max-size: 10m max-file: "3" ports: - - '5435:5432' - volumes: - - ./db:/var/lib/postgresql/data \ No newline at end of file + - '${POSTGRES_PORT}:${POSTGRES_PORT}' + # volumes: + # - ./db:/var/lib/postgresql/data + minio: + container_name: minio + image: minio/minio:RELEASE.2022-05-26T05-48-41Z + ports: + - 9001:9001 + - 9000:9000 + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + entrypoint: 'sh' + command: + -c "mkdir -p /data/manifests && minio server /data --console-address ':9001'" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 3 + minio-mc: + container_name: minio-mc + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc config host add myminio http://minio:9000 ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; + /usr/bin/mc mb myminio/manifests; + /usr/bin/mc anonymous set public myminio/manifests; + " \ No newline at end of file diff --git a/packages/apps/job-launcher/server/package.json b/packages/apps/job-launcher/server/package.json index 8442783a80..5337d6b0fe 100644 --- a/packages/apps/job-launcher/server/package.json +++ b/packages/apps/job-launcher/server/package.json @@ -13,11 +13,11 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "migration:create": "typeorm-ts-node-commonjs migration:create", - "migration:generate": "typeorm-ts-node-commonjs migration:generate -d typeorm.config.ts", + "migration:generate": "yarn build && typeorm-ts-node-commonjs migration:generate -p -d typeorm.config.ts", "migration:revert": "typeorm-ts-node-commonjs migration:revert -d typeorm.config.ts", "migration:run": "typeorm-ts-node-commonjs migration:run -d typeorm.config.ts", "migration:show": "typeorm-ts-node-commonjs migration:show -d typeorm.config.ts", - "docker:db:up": "docker-compose up -d && yarn migration:run", + "docker:db:up": "docker-compose up -d postgres && yarn build && yarn migration:run", "docker:db:down": "docker-compose down", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", @@ -30,46 +30,47 @@ "@human-protocol/sdk": "*", "@nestjs/axios": "^2.0.0", "@nestjs/common": "^9.4.3", + "@nestjs/config": "^3.0.0", "@nestjs/core": "^9.4.3", - "@nestjs/platform-express": "^9.4.3", - "passport": "^0.6.0", - "@types/passport-jwt": "^3.0.8", - "passport-jwt": "^4.0.1", "@nestjs/jwt": "^10.0.3", - "@nestjs/terminus": "^10.0.1", - "@nestjs/schedule": "^3.0.1", - "typeorm-naming-strategies": "^4.1.0", - "zxcvbn": "^4.4.2", "@nestjs/passport": "^10.0.0", - "@nestjs/typeorm": "^10.0.0", - "ethers": "^5.7.2", - "joi": "^17.9.2", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.2.0", - "nestjs-minio-client": "^2.0.0", + "@nestjs/platform-express": "^9.4.3", + "@nestjs/schedule": "^3.0.1", "@nestjs/swagger": "^7.0.6", - "class-transformer": "^0.5.1", - "bcrypt": "^5.1.0", - "@nestjs/config": "^3.0.0", + "@nestjs/terminus": "^10.0.1", + "@nestjs/typeorm": "^10.0.0", "@types/cookie-parser": "^1.4.3", "@types/express-session": "^1.17.7", - "express-session": "^1.17.3", + "@types/passport-jwt": "^3.0.8", + "bcrypt": "^5.1.0", + "class-transformer": "^0.5.1", "cookie-parser": "^1.4.6", + "ethers": "^5.7.2", + "express-session": "^1.17.3", + "joi": "^17.9.2", + "nestjs-minio-client": "^2.0.0", + "passport": "^0.6.0", + "passport-jwt": "^4.0.1", "pg": "8.11.0", - "typeorm": "^0.3.16" + "reflect-metadata": "^0.1.13", + "rxjs": "^7.2.0", + "typeorm": "^0.3.17", + "typeorm-naming-strategies": "^4.1.0", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@golevelup/ts-jest": "^0.3.7", "@nestjs/cli": "^9.4.3", "@nestjs/schematics": "^9.2.0", "@nestjs/testing": "^9.4.3", + "@types/bcrypt": "^5.0.0", "@types/express": "^4.17.13", "@types/jest": "29.5.1", "@types/node": "18.16.12", "@types/supertest": "^2.0.11", + "@types/zxcvbn": "4.4.1", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", - "@types/zxcvbn": "4.4.1", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", @@ -81,7 +82,6 @@ "ts-loader": "^9.2.3", "ts-node": "^10.0.0", "tsconfig-paths": "4.2.0", - "typescript": "^5.0.0", - "@types/bcrypt": "^5.0.0" + "typescript": "^5.0.0" } } diff --git a/packages/apps/job-launcher/server/src/app.module.ts b/packages/apps/job-launcher/server/src/app.module.ts index 4b82281e68..b4896c0346 100644 --- a/packages/apps/job-launcher/server/src/app.module.ts +++ b/packages/apps/job-launcher/server/src/app.module.ts @@ -4,7 +4,7 @@ import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { AppController } from './app.controller'; import { DatabaseModule } from './database/database.module'; -import { JwtHttpGuard, RolesGuard } from './common/guards'; +import { JwtAuthGuard } from './common/guards'; import { HttpValidationPipe } from './common/pipes'; import { HealthModule } from './modules/health/health.module'; import { AuthModule } from './modules/auth/auth.module'; @@ -18,11 +18,7 @@ import { envValidator } from './common/config'; providers: [ { provide: APP_GUARD, - useClass: JwtHttpGuard, - }, - { - provide: APP_GUARD, - useClass: RolesGuard, + useClass: JwtAuthGuard, }, { provide: APP_PIPE, diff --git a/packages/apps/job-launcher/server/src/common/config/env.ts b/packages/apps/job-launcher/server/src/common/config/env.ts index 4df0364365..348be9369a 100644 --- a/packages/apps/job-launcher/server/src/common/config/env.ts +++ b/packages/apps/job-launcher/server/src/common/config/env.ts @@ -10,7 +10,6 @@ export const ConfigNames = { JWT_SECRET: 'JWT_SECRET', JWT_ACCESS_TOKEN_EXPIRES_IN: 'JWT_ACCESS_TOKEN_EXPIRES_IN', JWT_REFRESH_TOKEN_EXPIRES_IN: 'JWT_REFRESH_TOKEN_EXPIRES_IN', - DB_TYPE: 'DB_TYPE', POSTGRES_HOST: 'POSTGRES_HOST', POSTGRES_USER: 'POSTGRES_USER', POSTGRES_PASSWORD: 'POSTGRES_PASSWORD', @@ -29,7 +28,7 @@ export const ConfigNames = { S3_PORT: 'S3_PORT', S3_ACCESS_KEY: 'S3_ACCESS_KEY', S3_SECRET_KEY: 'S3_SECRET_KEY', - S3_BACKET: 'S3_BACKET', + S3_BUCKET: 'S3_BUCKET', S3_USE_SSL: 'S3_USE_SSL', STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY', STRIPE_API_VERSION: 'STRIPE_API_VERSION', @@ -72,7 +71,7 @@ export const envValidator = Joi.object({ S3_PORT: Joi.string().default(9000), S3_ACCESS_KEY: Joi.string().required(), S3_SECRET_KEY: Joi.string().required(), - S3_BACKET: Joi.string().default('launcher'), + S3_BUCKET: Joi.string().default('launcher'), S3_USE_SSL: Joi.string().default(false), // Stripe STRIPE_SECRET_KEY: Joi.string().default( 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 a259fbe58b..b2e7a50415 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -27,6 +27,8 @@ export enum ErrorEscrow { export enum ErrorUser { NotFound = 'User not found', AccountCannotBeRegistered = 'Account cannot be registered', + BalanceCouldNotBeRetreived = 'User balance could not be retrieved', + InvalidCredentials = 'Invalid credentials', } /** @@ -63,6 +65,7 @@ export enum ErrorPayment { TransactionHasNotEnoughAmountOfConfirmations = 'Transaction has not enough amount of confirmations', UnsupportedToken = 'Unsupported token', InvalidRecipient = 'Invalid recipient', + ChainIdMissing = 'ChainId is missing', } /** diff --git a/packages/apps/job-launcher/server/src/common/constants/index.ts b/packages/apps/job-launcher/server/src/common/constants/index.ts index 27a124e941..e9b63bc9f2 100644 --- a/packages/apps/job-launcher/server/src/common/constants/index.ts +++ b/packages/apps/job-launcher/server/src/common/constants/index.ts @@ -3,3 +3,5 @@ export const COINGECKO_API_URL = 'https://api.coingecko.com/api/v3/simple/price'; export const JOB_RETRIES_COUNT_THRESHOLD = 3; export const TX_CONFIRMATION_TRESHOLD = 1; + +export const JWT_PREFIX = 'bearer '; diff --git a/packages/apps/job-launcher/server/src/common/constants/network.ts b/packages/apps/job-launcher/server/src/common/constants/network.ts deleted file mode 100644 index dbe317d774..0000000000 --- a/packages/apps/job-launcher/server/src/common/constants/network.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Network } from '@ethersproject/providers'; - -export interface NetworkDto { - network: Network; - rpcUrl: string; -} - -interface NetworkMapDto { - [key: string]: NetworkDto; -} - -export const networkMap: NetworkMapDto = { - polygon: { - network: { - chainId: 137, - name: 'matic', - }, - rpcUrl: - 'https://polygon-mainnet.g.alchemy.com/v2/0Lorh5KRkGl5FsRwy2epTg8fEFFoqUfY', - }, - bsc: { - network: { - chainId: 56, - name: 'bnb', - }, - rpcUrl: 'https://bsc-dataseed1.binance.org/', - }, - mumbai: { - network: { - chainId: 80001, - name: 'maticmum', - }, - rpcUrl: - 'https://polygon-mumbai.g.alchemy.com/v2/vKNSJzJf6SW2sdW-05bgFwoyFxUrMzii', - }, - goerli: { - network: { - chainId: 420, - name: 'optimism-goerli', - }, - rpcUrl: 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161', - }, - moonbeam: { - network: { - chainId: 56, - name: 'bnb', - }, - rpcUrl: 'https://rpc.api.moonbeam.network', - }, - bsctest: { - network: { - chainId: 97, - name: 'bnbt', - }, - rpcUrl: 'https://data-seed-prebsc-1-s1.binance.org:8545/', - }, -}; - -export const networks = Object.values(networkMap).map((network) => network); diff --git a/packages/apps/job-launcher/server/src/common/constants/payment.ts b/packages/apps/job-launcher/server/src/common/constants/payment.ts index d52c70d27b..2167747c07 100644 --- a/packages/apps/job-launcher/server/src/common/constants/payment.ts +++ b/packages/apps/job-launcher/server/src/common/constants/payment.ts @@ -1,3 +1,5 @@ +import { ICoingeckoTokenId } from '../interfaces'; + export const CoingeckoTokenId: ICoingeckoTokenId = { hmt: 'human-protocol', }; diff --git a/packages/apps/job-launcher/server/src/common/decorators/index.ts b/packages/apps/job-launcher/server/src/common/decorators/index.ts index 90ef4c155d..b7e8b7187d 100644 --- a/packages/apps/job-launcher/server/src/common/decorators/index.ts +++ b/packages/apps/job-launcher/server/src/common/decorators/index.ts @@ -1,2 +1 @@ -export * from './role'; export * from './public'; diff --git a/packages/apps/job-launcher/server/src/common/decorators/role.ts b/packages/apps/job-launcher/server/src/common/decorators/role.ts deleted file mode 100644 index f588845d12..0000000000 --- a/packages/apps/job-launcher/server/src/common/decorators/role.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const Roles = ( - ...roles: Array -): ((target: any, key?: any, descriptor?: any) => any) => - SetMetadata('roles', [...roles]); diff --git a/packages/apps/job-launcher/server/src/common/enums/auth.ts b/packages/apps/job-launcher/server/src/common/enums/auth.ts deleted file mode 100644 index 9e1900e03f..0000000000 --- a/packages/apps/job-launcher/server/src/common/enums/auth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum AuthStatus { - ACTIVE = 'ACTIVE', - EXPIRED = 'EXPIRED', -} diff --git a/packages/apps/job-launcher/server/src/common/guards/index.ts b/packages/apps/job-launcher/server/src/common/guards/index.ts index 015aecfafb..d12a7e5138 100644 --- a/packages/apps/job-launcher/server/src/common/guards/index.ts +++ b/packages/apps/job-launcher/server/src/common/guards/index.ts @@ -1,2 +1 @@ -export * from './jwt.http'; -export * from './roles'; +export * from './jwt.auth'; diff --git a/packages/apps/job-launcher/server/src/common/guards/jwt.http.ts b/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts similarity index 93% rename from packages/apps/job-launcher/server/src/common/guards/jwt.http.ts rename to packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts index 2b95e213a7..6274d5eed7 100644 --- a/packages/apps/job-launcher/server/src/common/guards/jwt.http.ts +++ b/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts @@ -8,7 +8,7 @@ import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @Injectable() -export class JwtHttpGuard extends AuthGuard('jwt-http') implements CanActivate { +export class JwtAuthGuard extends AuthGuard('jwt-http') implements CanActivate { constructor(private readonly reflector: Reflector) { super(); } diff --git a/packages/apps/job-launcher/server/src/common/guards/roles.ts b/packages/apps/job-launcher/server/src/common/guards/roles.ts deleted file mode 100644 index 7d43b5f115..0000000000 --- a/packages/apps/job-launcher/server/src/common/guards/roles.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -@Injectable() -export class RolesGuard implements CanActivate { - constructor(private readonly reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - const types = this.reflector.getAllAndOverride('roles', [ - context.getHandler(), - context.getClass(), - ]); - - if (!types || !types.length) { - return true; - } - - const request = context.switchToHttp().getRequest(); - - if (!request.user) { - return false; - } - - return types.includes(request.user.type); - } -} diff --git a/packages/apps/job-launcher/server/src/common/interfaces/auth.ts b/packages/apps/job-launcher/server/src/common/interfaces/auth.ts deleted file mode 100644 index e039f79852..0000000000 --- a/packages/apps/job-launcher/server/src/common/interfaces/auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IJwt { - accessToken: string; - accessTokenExpiresAt: number; - refreshToken: string; - refreshTokenExpiresAt: number; -} diff --git a/packages/apps/job-launcher/server/src/common/interfaces/index.ts b/packages/apps/job-launcher/server/src/common/interfaces/index.ts index bd2902e748..9766666c29 100644 --- a/packages/apps/job-launcher/server/src/common/interfaces/index.ts +++ b/packages/apps/job-launcher/server/src/common/interfaces/index.ts @@ -1,4 +1,4 @@ export * from './base'; export * from './job'; export * from './user'; -export * from './auth'; +export * from './payments'; diff --git a/packages/apps/job-launcher/server/src/common/interfaces/payment.ts b/packages/apps/job-launcher/server/src/common/interfaces/payment.ts deleted file mode 100644 index e2bf254b0a..0000000000 --- a/packages/apps/job-launcher/server/src/common/interfaces/payment.ts +++ /dev/null @@ -1,3 +0,0 @@ -interface ICoingeckoTokenId { - [key: string]: string; -} diff --git a/packages/apps/job-launcher/server/src/common/interfaces/payments.ts b/packages/apps/job-launcher/server/src/common/interfaces/payments.ts new file mode 100644 index 0000000000..402a5f4a6d --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/interfaces/payments.ts @@ -0,0 +1,3 @@ +export interface ICoingeckoTokenId { + [key: string]: string; +} diff --git a/packages/apps/job-launcher/server/src/common/test/dto.ts b/packages/apps/job-launcher/server/src/common/test/dto.ts deleted file mode 100644 index 51fd408fb6..0000000000 --- a/packages/apps/job-launcher/server/src/common/test/dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { v4 } from 'uuid'; - -import { UserCreateDto } from 'src/modules/user/user.dto'; -import { UserStatus, UserType } from '../enums/user'; - -export const generateUserCreateDto = ( - data: Partial = {}, -): UserCreateDto => { - return Object.assign( - { - password: 'human', - confirm: 'human', - email: `human+${v4()}@human.com`, - type: UserType.REQUESTER, - status: UserStatus.ACTIVE, - }, - data, - ); -}; diff --git a/packages/apps/job-launcher/server/src/common/test/entity.ts b/packages/apps/job-launcher/server/src/common/test/entity.ts deleted file mode 100644 index 234728f21e..0000000000 --- a/packages/apps/job-launcher/server/src/common/test/entity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { UserStatus, UserType } from '../enums/user'; -import { UserCreateDto } from 'src/modules/user/user.dto'; - -export const generateTestUser = ( - data: Partial = {}, -): Partial => { - return Object.assign( - { - password: 'HUMAN', - email: `human@hmt.ai` as string, - confirm: 'human', - type: UserType.REQUESTER, - status: UserStatus.ACTIVE, - }, - data, - ); -}; diff --git a/packages/apps/job-launcher/server/src/common/test/index.ts b/packages/apps/job-launcher/server/src/common/test/index.ts deleted file mode 100644 index 96ca4cfd9c..0000000000 --- a/packages/apps/job-launcher/server/src/common/test/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './dto'; -export * from './entity'; diff --git a/packages/apps/job-launcher/server/src/common/types/index.ts b/packages/apps/job-launcher/server/src/common/types/index.ts new file mode 100644 index 0000000000..56e4b0555f --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/types/index.ts @@ -0,0 +1 @@ +export * from './request'; diff --git a/packages/apps/job-launcher/server/src/common/types/request.ts b/packages/apps/job-launcher/server/src/common/types/request.ts new file mode 100644 index 0000000000..15b2a7ed78 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/types/request.ts @@ -0,0 +1,3 @@ +export interface RequestWithUser extends Request { + user: any; +} diff --git a/packages/apps/job-launcher/server/src/common/utils/index.ts b/packages/apps/job-launcher/server/src/common/utils/index.ts new file mode 100644 index 0000000000..d2609440aa --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/utils/index.ts @@ -0,0 +1,30 @@ +import { firstValueFrom } from "rxjs"; +import { CoingeckoTokenId } from "../constants/payment"; +import { TokenId } from "../enums/payment"; +import { COINGECKO_API_URL } from "../constants"; +import { NotFoundException } from "@nestjs/common"; +import { ErrorCurrency } from "../constants/errors"; + +export async function getRate(from: string, to: string): Promise { + let reversed = false; + + if (Object.values(TokenId).includes(to as TokenId)) { + [from, to] = [CoingeckoTokenId[to], from]; + } else { + reversed = true; + } + + const { data } = await firstValueFrom( + this.httpService.get( + `${COINGECKO_API_URL}?ids=${from}&vs_currencies=${to}`, + ), + ) as any; + + if (!data[from] || !data[from][to]) { + throw new NotFoundException(ErrorCurrency.PairNotFound); + } + + const rate = data[from][to]; + + return reversed ? 1 / rate : rate; + } \ No newline at end of file diff --git a/packages/apps/job-launcher/server/src/database/migrations/1677845576145-addShema.ts b/packages/apps/job-launcher/server/src/database/migrations/1677845576145-addShema.ts deleted file mode 100644 index f1e2ba10e4..0000000000 --- a/packages/apps/job-launcher/server/src/database/migrations/1677845576145-addShema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; -import { NS } from '../../common/constants'; - -export class addShema1677845576145 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.createSchema(NS, true); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropSchema(NS); - } -} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1677845804077-installExtension.ts b/packages/apps/job-launcher/server/src/database/migrations/1677845804077-installExtension.ts deleted file mode 100644 index d71cc6aa15..0000000000 --- a/packages/apps/job-launcher/server/src/database/migrations/1677845804077-installExtension.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class installExtension1677845804077 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`-- do nothing`); - } -} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1677867973538-addUserTable.ts b/packages/apps/job-launcher/server/src/database/migrations/1677867973538-addUserTable.ts deleted file mode 100644 index daeb5a0357..0000000000 --- a/packages/apps/job-launcher/server/src/database/migrations/1677867973538-addUserTable.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -import { NS } from '../../common/constants'; - -export class addUserTable1677867973538 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE ${NS}.user_type_enum AS ENUM ( - 'OPERATOR', - 'REQUESTER' - ); - `); - - await queryRunner.query(` - CREATE TYPE ${NS}.user_status_enum AS ENUM ( - 'ACTIVE', - 'INACTIVE', - 'PENDING' - ); - `); - - const table = new Table({ - name: `${NS}.user`, - columns: [ - { - name: 'id', - type: 'serial', - isPrimary: true, - }, - { - name: 'password', - type: 'varchar', - }, - { - name: 'email', - type: 'varchar', - isUnique: true, - isNullable: true, - }, - { - name: 'stripe_customer_id', - type: 'varchar', - isUnique: true, - isNullable: true, - }, - { - name: 'type', - type: `${NS}.user_type_enum`, - }, - { - name: 'status', - type: `${NS}.user_status_enum`, - }, - { - name: 'created_at', - type: 'timestamptz', - }, - { - name: 'updated_at', - type: 'timestamptz', - }, - ], - }); - - await queryRunner.createTable(table, true); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(`${NS}.user`); - await queryRunner.query(`DROP TYPE ${NS}.user_type_enum;`); - await queryRunner.query(`DROP TYPE ${NS}.user_status_enum;`); - } -} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1677867985970-addAuthTable.ts b/packages/apps/job-launcher/server/src/database/migrations/1677867985970-addAuthTable.ts deleted file mode 100644 index f9256e8f85..0000000000 --- a/packages/apps/job-launcher/server/src/database/migrations/1677867985970-addAuthTable.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -import { NS } from '../../common/constants'; - -export class addAuthTable1677867985970 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE ${NS}.auth_status_enum AS ENUM ( - 'ACTIVE', - 'EXPIRED' - ); - `); - - const table = new Table({ - name: `${NS}.auth`, - columns: [ - { - name: 'id', - type: 'serial', - isPrimary: true, - }, - { - name: 'refresh_token', - type: 'varchar', - }, - { - name: 'refresh_token_expires_at', - type: 'bigint', - }, - { - name: 'user_id', - type: 'int', - }, - { - name: 'ip', - type: 'varchar', - default: "'0.0.0.0'", - }, - { - name: 'status', - type: `${NS}.auth_status_enum`, - }, - { - name: 'created_at', - type: 'timestamptz', - }, - { - name: 'updated_at', - type: 'timestamptz', - }, - ], - foreignKeys: [ - { - columnNames: ['user_id'], - referencedColumnNames: ['id'], - referencedTableName: `${NS}.user`, - onDelete: 'CASCADE', - }, - ], - }); - - await queryRunner.createTable(table, true); - - await queryRunner.query(` - CREATE OR REPLACE FUNCTION delete_expired_auth() RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - DELETE FROM ${NS}.auth WHERE created_at < NOW() - INTERVAL '30 days'; - RETURN NEW; - END; - $$; - `); - - await queryRunner.query(` - DROP TRIGGER IF EXISTS delete_expired_auth_trigger ON ${NS}.auth; - CREATE TRIGGER delete_expired_auth_trigger - AFTER INSERT ON ${NS}.auth - EXECUTE PROCEDURE delete_expired_auth() - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(`${NS}.auth`); - await queryRunner.query(`DROP TYPE ${NS}.auth_status_enum;`); - await queryRunner.query('DROP FUNCTION delete_expired_auth();'); - } -} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1677867996573-addTokenTable.ts b/packages/apps/job-launcher/server/src/database/migrations/1677867996573-addTokenTable.ts deleted file mode 100644 index f76bf25e84..0000000000 --- a/packages/apps/job-launcher/server/src/database/migrations/1677867996573-addTokenTable.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -import { NS } from '../../common/constants'; - -export class addTokenTable1677867996573 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE ${NS}.token_type_enum AS ENUM ( - 'EMAIL', - 'PASSWORD' - ); - `); - - const table = new Table({ - name: `${NS}.token`, - columns: [ - { - name: 'id', - type: 'serial', - isPrimary: true, - }, - { - name: 'uuid', - type: 'uuid', - isUnique: true, - isGenerated: true, - generationStrategy: 'uuid', - }, - { - name: 'token_type', - type: `${NS}.token_type_enum`, - }, - { - name: 'user_id', - type: 'int', - }, - { - name: 'created_at', - type: 'timestamptz', - }, - { - name: 'updated_at', - type: 'timestamptz', - }, - ], - foreignKeys: [ - { - columnNames: ['user_id'], - referencedColumnNames: ['id'], - referencedTableName: `${NS}.user`, - onDelete: 'CASCADE', - }, - ], - }); - - await queryRunner.createTable(table, true); - - await queryRunner.query(` - CREATE OR REPLACE FUNCTION delete_expired_tokens() RETURNS trigger - LANGUAGE plpgsql - AS $$ - BEGIN - DELETE FROM ${NS}.token WHERE created_at < NOW() - INTERVAL '1 hour'; - RETURN NEW; - END; - $$; - `); - - await queryRunner.query(` - CREATE TRIGGER delete_expired_tokens_trigger - AFTER INSERT ON ${NS}.token - EXECUTE PROCEDURE delete_expired_tokens() - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(`${NS}.token`); - await queryRunner.query(`DROP TYPE ${NS}.token_type_enum;`); - await queryRunner.query('DROP FUNCTION delete_expired_tokens();'); - } -} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1685352934954-addJobTable.ts b/packages/apps/job-launcher/server/src/database/migrations/1685352934954-addJobTable.ts deleted file mode 100644 index 6734b6875e..0000000000 --- a/packages/apps/job-launcher/server/src/database/migrations/1685352934954-addJobTable.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -import { NS } from '../../common/constants'; - -export class addJobTable1685352934954 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE ${NS}.job_status_enum AS ENUM ( - 'PENDING', - 'PAID', - 'LAUNCHED', - 'FAILED', - 'COMPLETED' - ); - `); - - const table = new Table({ - name: `${NS}.job`, - columns: [ - { - name: 'id', - type: 'serial', - isPrimary: true, - }, - { - name: 'user_id', - type: 'int', - }, - { - name: 'chain_id', - type: 'varchar', - }, - { - name: 'manifest_url', - type: 'varchar', - }, - { - name: 'manifest_hash', - type: 'varchar', - }, - { - name: 'escrow_address', - type: 'varchar', - isNullable: true, - }, - { - name: 'fee', - type: 'varchar', - }, - { - name: 'fund_amount', - type: 'varchar', - }, - { - name: 'status', - type: `${NS}.job_status_enum`, - }, - { - name: 'retries_count', - type: 'int', - default: 0, - }, - { - name: 'created_at', - type: 'timestamptz', - }, - { - name: 'updated_at', - type: 'timestamptz', - }, - { - name: 'wait_until', - type: 'timestamptz', - }, - ], - foreignKeys: [ - { - columnNames: ['user_id'], - referencedColumnNames: ['id'], - referencedTableName: `${NS}.user`, - onDelete: 'NO ACTION', - }, - ], - }); - - await queryRunner.createTable(table, true); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(`${NS}.job`); - await queryRunner.query(`DROP TYPE ${NS}.job_status_enum;`); - } -} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1685352955107-addPaymentTable.ts b/packages/apps/job-launcher/server/src/database/migrations/1685352955107-addPaymentTable.ts deleted file mode 100644 index bb90509490..0000000000 --- a/packages/apps/job-launcher/server/src/database/migrations/1685352955107-addPaymentTable.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -import { NS } from '../../common/constants'; - -export class addPaymentTable1685352955107 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE ${NS}.payment_type_enum AS ENUM ( - 'DEPOSIT', - 'REFUND', - 'WITHDRAWAL' - ); - `); - - await queryRunner.query(` - CREATE TYPE ${NS}.payment_source_enum AS ENUM ( - 'FIAT', - 'CRYPTO', - 'BALANCE' - ); - `); - - const table = new Table({ - name: `${NS}.payment`, - columns: [ - { - name: 'id', - type: 'serial', - isPrimary: true, - }, - { - name: 'user_id', - type: 'int', - }, - { - name: 'payment_id', - type: 'varchar', - isNullable: true, - default: null, - }, - { - name: 'transaction_hash', - type: 'varchar', - isNullable: true, - default: null, - }, - { - name: 'client_secret', - type: 'varchar', - isNullable: true, - default: null, - }, - { - name: 'amount', - type: 'varchar', - }, - { - name: 'rate', - type: 'decimal', - }, - { - name: 'currency', - type: 'varchar', - }, - { - name: 'type', - type: `${NS}.payment_type_enum`, - }, - { - name: 'source', - type: `${NS}.payment_source_enum`, - }, - { - name: 'created_at', - type: 'timestamptz', - }, - { - name: 'updated_at', - type: 'timestamptz', - }, - ], - foreignKeys: [ - { - columnNames: ['user_id'], - referencedColumnNames: ['id'], - referencedTableName: `${NS}.user`, - onDelete: 'CASCADE', - }, - ], - }); - - await queryRunner.createTable(table, true); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(`${NS}.payment`); - await queryRunner.query(`DROP TYPE ${NS}.payment_type_enum;`); - await queryRunner.query(`DROP TYPE ${NS}.payment_source_enum;`); - } -} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts new file mode 100644 index 0000000000..ae8f0d1be7 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1691485394906-InitialMigration.ts @@ -0,0 +1,197 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { NS } from '../../common/constants'; + +export class InitialMigration1691485394906 implements MigrationInterface { + name = 'InitialMigration1691485394906'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createSchema(NS, true); + await queryRunner.query(` + CREATE TYPE "hmt"."payments_type_enum" AS ENUM('DEPOSIT', 'REFUND', 'WITHDRAWAL') + `); + await queryRunner.query(` + CREATE TYPE "hmt"."payments_source_enum" AS ENUM('FIAT', 'CRYPTO', 'BALANCE') + `); + await queryRunner.query(` + CREATE TABLE "hmt"."payments" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "transaction" character varying, + "chain_id" integer, + "amount" character varying NOT NULL, + "rate" numeric(5, 2) NOT NULL, + "currency" character varying NOT NULL, + "type" "hmt"."payments_type_enum" NOT NULL, + "source" "hmt"."payments_source_enum" NOT NULL, + "user_id" integer NOT NULL, + CONSTRAINT "PK_197ab7af18c93fbb0c9b28b4a59" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_72b30c486884d4c5be768e0ac9" ON "hmt"."payments" ("transaction") + WHERE ( + chain_Id IS NULL + AND transaction IS NOT NULL + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_9b5d72797ec3ba4991cba5de4c" ON "hmt"."payments" ("chain_id", "transaction") + WHERE ( + chain_Id IS NOT NULL + AND transaction IS NOT NULL + ) + `); + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_status_enum" AS ENUM( + 'PENDING', + 'PAID', + 'LAUNCHED', + 'COMPLETED', + 'FAILED' + ) + `); + await queryRunner.query(` + CREATE TABLE "hmt"."jobs" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "chain_id" integer, + "escrow_address" character varying, + "fee" character varying NOT NULL, + "fund_amount" character varying NOT NULL, + "manifest_url" character varying NOT NULL, + "manifest_hash" character varying NOT NULL, + "status" "hmt"."jobs_status_enum" NOT NULL, + "user_id" integer NOT NULL, + "retries_count" integer NOT NULL DEFAULT '0', + "wait_until" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "PK_cf0a6c42b72fcc7f7c237def345" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_59f6c552b618c432f019500e7c" ON "hmt"."jobs" ("chain_id", "escrow_address") + `); + await queryRunner.query(` + CREATE TYPE "hmt"."tokens_token_type_enum" AS ENUM('EMAIL', 'PASSWORD') + `); + await queryRunner.query(` + CREATE TABLE "hmt"."tokens" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), + "token_type" "hmt"."tokens_token_type_enum" NOT NULL, + "user_id" integer NOT NULL, + CONSTRAINT "UQ_57b0dd7af7c6a0b7d4c3fd5c464" UNIQUE ("uuid"), + CONSTRAINT "REL_8769073e38c365f315426554ca" UNIQUE ("user_id"), + CONSTRAINT "PK_3001e89ada36263dabf1fb6210a" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE TYPE "hmt"."users_type_enum" AS ENUM('OPERATOR', 'REQUESTER') + `); + await queryRunner.query(` + CREATE TYPE "hmt"."users_status_enum" AS ENUM('ACTIVE', 'INACTIVE', 'PENDING') + `); + await queryRunner.query(` + CREATE TABLE "hmt"."users" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "password" character varying NOT NULL, + "email" character varying, + "type" "hmt"."users_type_enum" NOT NULL, + "status" "hmt"."users_status_enum" NOT NULL, + CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), + CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE TABLE "hmt"."auths" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "access_token" character varying NOT NULL, + "refresh_token" character varying NOT NULL, + "user_id" integer NOT NULL, + CONSTRAINT "REL_593ea7ee438b323776029d3185" UNIQUE ("user_id"), + CONSTRAINT "PK_22fc0631a651972ddc9c5a31090" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."payments" + ADD CONSTRAINT "FK_427785468fb7d2733f59e7d7d39" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ADD CONSTRAINT "FK_9027c8f0ba75fbc1ac46647d043" 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 + `); + await queryRunner.query(` + ALTER TABLE "hmt"."auths" + ADD CONSTRAINT "FK_593ea7ee438b323776029d3185f" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "hmt"."auths" DROP CONSTRAINT "FK_593ea7ee438b323776029d3185f" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."tokens" DROP CONSTRAINT "FK_8769073e38c365f315426554ca5" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" DROP CONSTRAINT "FK_9027c8f0ba75fbc1ac46647d043" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."payments" DROP CONSTRAINT "FK_427785468fb7d2733f59e7d7d39" + `); + await queryRunner.query(` + DROP TABLE "hmt"."auths" + `); + await queryRunner.query(` + DROP TABLE "hmt"."users" + `); + await queryRunner.query(` + DROP TYPE "hmt"."users_status_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."users_type_enum" + `); + await queryRunner.query(` + DROP TABLE "hmt"."tokens" + `); + await queryRunner.query(` + DROP TYPE "hmt"."tokens_token_type_enum" + `); + await queryRunner.query(` + DROP INDEX "hmt"."IDX_59f6c552b618c432f019500e7c" + `); + await queryRunner.query(` + DROP TABLE "hmt"."jobs" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_status_enum" + `); + await queryRunner.query(` + DROP INDEX "hmt"."IDX_9b5d72797ec3ba4991cba5de4c" + `); + await queryRunner.query(` + DROP INDEX "hmt"."IDX_72b30c486884d4c5be768e0ac9" + `); + await queryRunner.query(` + DROP TABLE "hmt"."payments" + `); + await queryRunner.query(` + DROP TYPE "hmt"."payments_source_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."payments_type_enum" + `); + await queryRunner.dropSchema(NS); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.jwt.controller.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts similarity index 59% rename from packages/apps/job-launcher/server/src/modules/auth/auth.jwt.controller.ts rename to packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts index 9470158042..44a2814793 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.jwt.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts @@ -3,84 +3,84 @@ import { ClassSerializerInterceptor, Controller, HttpCode, - Ip, Post, + Req, + UseGuards, UseInterceptors, } from '@nestjs/common'; -import { AuthService } from './auth.service'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Public } from '../../common/decorators'; +import { UserCreateDto } from '../user/user.dto'; import { + AuthDto, ForgotPasswordDto, - SignInDto, - LogoutDto, - RefreshDto, ResendEmailVerificationDto, RestorePasswordDto, + SignInDto, VerifyEmailDto, } from './auth.dto'; -import { Public } from '../../common/decorators'; -import { ApiTags } from '@nestjs/swagger'; -import { UserCreateDto } from '../user/user.dto'; -import { IJwt } from '../../common/interfaces/auth'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from 'src/common/guards'; +import { RequestWithUser } from 'src/common/types'; -@Public() @ApiTags('Auth') @Controller('/auth') export class AuthJwtController { constructor(private readonly authService: AuthService) {} + @Public() @Post('/signup') @UseInterceptors(ClassSerializerInterceptor) - public async signup( - @Body() data: UserCreateDto, - @Ip() ip: string, - ): Promise { - const userEntity = await this.authService.signup(data); - return this.authService.auth(userEntity, ip); + public async signup(@Body() data: UserCreateDto): Promise { + await this.authService.signup(data); } + @Public() @Post('/signin') @HttpCode(200) - public signin(@Body() data: SignInDto, @Ip() ip: string): Promise { - return this.authService.signin(data, ip); - } - - @Post('/logout') - @HttpCode(204) - public async logout(@Body() data: LogoutDto): Promise { - await this.authService.logout(data); + public signin(@Body() data: SignInDto): Promise { + return this.authService.signin(data); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) @Post('/refresh') @HttpCode(200) - async refreshToken( - @Body() data: RefreshDto, - @Ip() ip: string, - ): Promise { - return this.authService.refresh(data, ip); + async refreshToken(@Req() request: RequestWithUser): Promise { + return this.authService.auth(request.user); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('/logout') + @HttpCode(204) + public async logout(@Req() request: RequestWithUser): Promise { + await this.authService.logout(request.user); } + @Public() @Post('/forgot-password') @HttpCode(204) public forgotPassword(@Body() data: ForgotPasswordDto): Promise { return this.authService.forgotPassword(data); } + @Public() @Post('/restore-password') @HttpCode(204) public restorePassword(@Body() data: RestorePasswordDto): Promise { return this.authService.restorePassword(data); } + @Public() @Post('/email-verification') @HttpCode(200) - public emailVerification( - @Body() data: VerifyEmailDto, - @Ip() ip: string, - ): Promise { - return this.authService.emailVerification(data, ip); + public async emailVerification(@Body() data: VerifyEmailDto): Promise { + await this.authService.emailVerification(data); } + @Public() @Post('/resend-email-verification') @HttpCode(204) public resendEmailVerification( diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts index 40ecaa70e1..9b2219ab78 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts @@ -1,10 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, Matches, IsString, IsEnum } from 'class-validator'; import { Transform } from 'class-transformer'; +import { IsEmail, IsString, Matches, IsEnum } from 'class-validator'; import { IsConfirm, IsPassword } from '../../common/validators'; -import { UserEntity } from '../user/user.entity'; import { TokenType } from '../auth/token.entity'; -import { AuthStatus } from 'src/common/enums/auth'; +import { UserEntity } from '../user/user.entity'; export class ForgotPasswordDto { @ApiProperty() @@ -24,12 +23,6 @@ export class SignInDto { public password: string; } -export class LogoutDto { - @ApiProperty() - @IsString() - public refreshToken: string; -} - export class ValidatePasswordDto { @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/, { message: @@ -44,12 +37,6 @@ export class ValidatePasswordDto { public confirm: string; } -export class RefreshDto { - @ApiProperty() - @IsString() - public refreshToken: string; -} - export class ResendEmailVerificationDto { @ApiProperty() @IsEmail() @@ -69,17 +56,20 @@ export class VerifyEmailDto { public token: string; } +export class AuthDto { + public refreshToken: string; + public accessToken: string; +} + export class AuthCreateDto { public user: UserEntity; public refreshToken: string; - public refreshTokenExpiresAt: number; - public ip: string; - public status: AuthStatus + public accessToken: string; } export class AuthUpdateDto { - @IsEnum(AuthStatus) - public status: AuthStatus; + public refreshToken: string; + public accessToken: string; } export class TokenCreateDto { diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts index b513bd8d89..1062a7b014 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, + Generated, JoinColumn, OneToOne, PrimaryGeneratedColumn, @@ -9,35 +10,22 @@ import { import { NS } from '../../common/constants'; import { UserEntity } from '../user/user.entity'; import { BaseEntity } from '../../database/base.entity'; -import { AuthStatus } from '../../common/enums/auth'; -@Entity({ schema: NS, name: 'auth' }) +@Entity({ schema: NS, name: 'auths' }) export class AuthEntity extends BaseEntity { @PrimaryGeneratedColumn() public id: number; @Column({ type: 'varchar' }) - public refreshToken: string; - - @Column({ type: 'bigint' }) - public refreshTokenExpiresAt: number; + public accessToken: string; - @Column({ - type: 'enum', - enum: AuthStatus, - }) - public status: AuthStatus; + @Column({ type: 'varchar' }) + public refreshToken: string; @JoinColumn() - @OneToOne((_type) => UserEntity) + @OneToOne(() => UserEntity) public user: UserEntity; @Column({ type: 'int' }) public userId: number; - - @Column({ - type: 'varchar', - select: false, - }) - public ip: string; } diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.module.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.module.ts index e0930e24ba..9fc41b0947 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.module.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.module.ts @@ -6,11 +6,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from '../user/user.module'; import { JwtHttpStrategy } from './strategy'; import { AuthService } from './auth.service'; -import { AuthJwtController } from './auth.jwt.controller'; +import { AuthJwtController } from './auth.controller'; import { AuthEntity } from './auth.entity'; import { TokenEntity } from './token.entity'; import { TokenRepository } from './token.repository'; import { AuthRepository } from './auth.repository'; +import { ConfigNames } from '../../common/config'; @Module({ imports: [ @@ -20,7 +21,13 @@ import { AuthRepository } from './auth.repository'; inject: [ConfigService], imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET', 'secretkey'), + secret: configService.get(ConfigNames.JWT_SECRET, 'secretkey'), + signOptions: { + expiresIn: configService.get( + ConfigNames.JWT_ACCESS_TOKEN_EXPIRES_IN, + 3600, + ), + }, }), }), TypeOrmModule.forFeature([AuthEntity, TokenEntity]), 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 6802e0f37e..6220a49f3f 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 @@ -11,16 +11,22 @@ import { AuthEntity } from './auth.entity'; import { UserService } from '../user/user.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { UserEntity } from '../user/user.entity'; -import { UserStatus } from '../../common/enums/user'; import { AuthRepository } from './auth.repository'; import { ErrorAuth } from '../../common/constants/errors'; -import { MOCK_ACCESS_TOKEN, MOCK_EMAIL, MOCK_EXPIRES_IN, MOCK_HASHED_PASSWORD, MOCK_IP, MOCK_PASSWORD, MOCK_REFRESH_TOKEN } from '../../common/test/constants'; -import { AuthStatus } from '../../common/enums/auth'; -import { IJwt } from '../../common/interfaces/auth'; +import { + MOCK_ACCESS_TOKEN, + MOCK_ACCESS_TOKEN_HASHED, + MOCK_EMAIL, + MOCK_EXPIRES_IN, + MOCK_HASHED_PASSWORD, + MOCK_PASSWORD, + MOCK_REFRESH_TOKEN, + MOCK_REFRESH_TOKEN_HASHED, +} from '../../../test/constants'; import { TokenType } from './token.entity'; import { v4 } from 'uuid'; import { PaymentService } from '../payment/payment.service'; - +import { UserStatus } from '../../common/enums/user'; jest.mock('@human-protocol/sdk'); @@ -41,8 +47,6 @@ describe('AuthService', () => { switch (key) { case 'JWT_ACCESS_TOKEN_EXPIRES_IN': return MOCK_EXPIRES_IN; - case 'JWT_REFRESH_TOKEN_EXPIRES_IN': - return MOCK_EXPIRES_IN; } }), }; @@ -58,7 +62,7 @@ describe('AuthService', () => { { provide: JwtService, useValue: { - sign: jest.fn(), + signAsync: jest.fn(), }, }, { provide: AuthRepository, useValue: createMock() }, @@ -86,52 +90,51 @@ describe('AuthService', () => { email: MOCK_EMAIL, password: MOCK_PASSWORD, }; - + const userEntity: Partial = { id: 1, email: signInDto.email, password: MOCK_HASHED_PASSWORD, - }; - - - const jwt: IJwt = { - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, - accessTokenExpiresAt: MOCK_EXPIRES_IN, - refreshTokenExpiresAt: MOCK_EXPIRES_IN, + status: UserStatus.ACTIVE, }; let getByCredentialsMock: any; - + beforeEach(() => { getByCredentialsMock = jest.spyOn(userService, 'getByCredentials'); - jest.spyOn(authService, 'auth').mockResolvedValue(jwt); + jest.spyOn(authService, 'auth').mockResolvedValue({ + accessToken: MOCK_ACCESS_TOKEN, + refreshToken: MOCK_REFRESH_TOKEN, + }); }); - + afterEach(() => { jest.clearAllMocks(); }); - + it('should sign in the user and return the JWT', async () => { getByCredentialsMock.mockResolvedValue(userEntity as UserEntity); - const result = await authService.signin(signInDto, MOCK_IP); - + const result = await authService.signin(signInDto); + expect(userService.getByCredentials).toHaveBeenCalledWith( signInDto.email, signInDto.password, ); - expect(authService.auth).toHaveBeenCalledWith(userEntity, MOCK_IP); - expect(result).toBe(jwt); + expect(authService.auth).toHaveBeenCalledWith(userEntity); + expect(result).toStrictEqual({ + accessToken: MOCK_ACCESS_TOKEN, + refreshToken: MOCK_REFRESH_TOKEN, + }); }); - + it('should throw UnauthorizedException if user credentials are invalid', async () => { getByCredentialsMock.mockResolvedValue(undefined); - - await expect(authService.signin(signInDto, MOCK_IP)).rejects.toThrow( + + await expect(authService.signin(signInDto)).rejects.toThrow( ErrorAuth.InvalidEmailOrPassword, ); - + expect(userService.getByCredentials).toHaveBeenCalledWith( signInDto.email, signInDto.password, @@ -145,22 +148,21 @@ describe('AuthService', () => { password: MOCK_PASSWORD, confirm: MOCK_PASSWORD, }; - + const userEntity: Partial = { id: 1, email: userCreateDto.email, password: MOCK_HASHED_PASSWORD, }; - + const tokenEntity = { uuid: v4(), tokenType: TokenType.EMAIL, user: userEntity, }; - let createUserMock: any, - createTokenMock: any; - + let createUserMock: any, createTokenMock: any; + beforeEach(() => { createUserMock = jest.spyOn(userService, 'create'); createTokenMock = jest.spyOn(tokenRepository, 'create'); @@ -168,14 +170,14 @@ describe('AuthService', () => { createUserMock.mockResolvedValue(userEntity); createTokenMock.mockResolvedValue(tokenEntity); }); - + afterEach(() => { jest.clearAllMocks(); }); - + it('should create a new user and return the user entity', async () => { const result = await authService.signup(userCreateDto); - + expect(userService.create).toHaveBeenCalledWith(userCreateDto); expect(tokenRepository.create).toHaveBeenCalledWith({ tokenType: TokenType.EMAIL, @@ -184,162 +186,148 @@ describe('AuthService', () => { expect(result).toBe(userEntity); }); }); - describe('logout', () => { let updateAuth: any; - const where = { userId: 1 }; - + const userEntity: Partial = { + id: 1, + }; + const updateResult = {}; - + beforeEach(() => { updateAuth = jest.spyOn(authRepository, 'update'); updateAuth.mockResolvedValue(updateResult); }); - + afterEach(() => { jest.clearAllMocks(); }); - - it('should update the authentication entities based on the given condition', async () => { - const result = await authService.logout(where); - - const expectedUpdateQuery = { ...where, status: AuthStatus.ACTIVE }; - const expectedUpdateValues = { status: AuthStatus.EXPIRED }; - - expect(authRepository.update).toHaveBeenCalledWith(expectedUpdateQuery, expectedUpdateValues); - expect(result).toBe(undefined); - }); - }); - - - describe('refresh', () => { - it('should refresh the JWT for a valid user and return the new JWT', async () => { - const where = { id: 1 }; - - const userEntity: Partial = { - id: 1, - email: MOCK_EMAIL, - password: MOCK_HASHED_PASSWORD, - status: UserStatus.ACTIVE, - }; - - const authEntity: Partial = { - id: 1, - user: userEntity as UserEntity, - refreshTokenExpiresAt: MOCK_EXPIRES_IN, - }; + it('should delete the authentication entities based on email', async () => { + const result = await authService.logout(userEntity as UserEntity); - jest - .spyOn(authRepository, 'findOne') - .mockResolvedValue(authEntity as AuthEntity); - - const jwt: IJwt = { - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, - accessTokenExpiresAt: MOCK_EXPIRES_IN, - refreshTokenExpiresAt: MOCK_EXPIRES_IN, + const expectedUpdateQuery = { + userId: userEntity.id, }; - jest.spyOn(authService, 'auth').mockResolvedValue(jwt); - - const result = await authService.refresh(where, MOCK_IP); - - expect(authRepository.findOne).toHaveBeenCalledWith(where, { - relations: ['user'], - }); - expect(authService.auth).toHaveBeenCalledWith(authEntity.user, MOCK_IP); - expect(result).toBe(jwt); + expect(authRepository.delete).toHaveBeenCalledWith(expectedUpdateQuery); + expect(result).toBe(undefined); }); + }); - it('should throw UnauthorizedException if the refresh token has expired', async () => { - const where = { id: 1 }; - - - const userEntity: Partial = { - id: 1, - email: MOCK_EMAIL, - password: MOCK_HASHED_PASSWORD, - status: UserStatus.ACTIVE, - }; - - const authEntity: Partial = { - id: 1, - user: userEntity as UserEntity, - refreshTokenExpiresAt: Date.now() - 86400 * 1000, - }; - - jest - .spyOn(authRepository, 'findOne') - .mockResolvedValue(authEntity as AuthEntity); + describe('auth', () => { + const userEntity: Partial = { + id: 1, + email: 'user@example.com', + }; - await expect(authService.refresh(where, MOCK_IP)).rejects.toThrow( - ErrorAuth.RefreshTokenHasExpired, - ); + const authEntity: Partial = { + id: 1, + }; - expect(authRepository.findOne).toHaveBeenCalledWith(where, { - relations: ['user'], - }); + let createAuthMock: any; + let updateAuthMock: any; + let jwtSignMock: any; + let hashTokenMock: any; + let logoutMock: any; + beforeEach(() => { + createAuthMock = jest + .spyOn(authRepository, 'create' as any) + .mockResolvedValueOnce(authEntity); + + updateAuthMock = jest + .spyOn(authRepository, 'update' as any) + .mockResolvedValueOnce(authEntity); + + jwtSignMock = jest + .spyOn(jwtService, 'signAsync') + .mockResolvedValueOnce(MOCK_ACCESS_TOKEN) + .mockResolvedValueOnce(MOCK_REFRESH_TOKEN); + + hashTokenMock = jest + .spyOn(authService, 'hashToken') + .mockReturnValueOnce(MOCK_ACCESS_TOKEN_HASHED) + .mockReturnValueOnce(MOCK_REFRESH_TOKEN_HASHED); + logoutMock = jest + .spyOn(authService, 'logout' as any) + .mockResolvedValueOnce(undefined); }); - it('should throw UnauthorizedException if the user is not active', async () => { - const where = { id: 1 }; - - - const userEntity: Partial = { - id: 1, - status: UserStatus.INACTIVE, - }; + afterEach(() => { + jest.clearAllMocks(); + }); - const authEntity: Partial = { - id: 1, - user: userEntity as UserEntity, - refreshTokenExpiresAt: MOCK_EXPIRES_IN, - }; + it('should create authentication tokens and return them', async () => { + const findAuthMock = jest + .spyOn(authRepository, 'findOne' as any) + .mockResolvedValueOnce(undefined); - jest - .spyOn(authRepository, 'findOne') - .mockResolvedValue(authEntity as AuthEntity); + const result = await authService.auth(userEntity as UserEntity); - await expect(authService.refresh(where, MOCK_IP)).rejects.toThrow( - ErrorAuth.UserNotActive, + expect(findAuthMock).toHaveBeenCalledWith({ userId: userEntity.id }); + expect(updateAuthMock).not.toHaveBeenCalled(); + expect(createAuthMock).toHaveBeenCalledWith({ + user: userEntity, + refreshToken: MOCK_REFRESH_TOKEN_HASHED, + accessToken: MOCK_ACCESS_TOKEN_HASHED, + }); + expect(jwtSignMock).toHaveBeenCalledWith({ + email: userEntity.email, + userId: userEntity.id, + }); + expect(jwtSignMock).toHaveBeenLastCalledWith( + { + email: userEntity.email, + userId: userEntity.id, + }, + { + expiresIn: undefined, + }, ); - - expect(authRepository.findOne).toHaveBeenCalledWith(where, { - relations: ['user'], + expect(logoutMock).not.toHaveBeenCalled(); + expect(hashTokenMock).toHaveBeenCalledWith(MOCK_ACCESS_TOKEN); + expect(hashTokenMock).toHaveBeenLastCalledWith(MOCK_REFRESH_TOKEN); + expect(result).toEqual({ + accessToken: MOCK_ACCESS_TOKEN, + refreshToken: MOCK_REFRESH_TOKEN, }); }); - }); - describe('auth', () => { - it('should create authentication tokens and return them', async () => { - const userEntity: Partial = { id: 1, email: 'user@example.com' }; - const refreshToken = v4(); - const accessToken = MOCK_ACCESS_TOKEN - - const logoutMock = jest.spyOn(authService, 'logout').mockResolvedValueOnce(undefined); - const createAuthMock = jest.spyOn(authRepository, 'create' as any).mockResolvedValueOnce(undefined); - const jwtSignMock = jest.spyOn(jwtService, 'sign').mockReturnValue(accessToken); + it('should logout, create authentication tokens and return them', async () => { + const findAuthMock = jest + .spyOn(authRepository, 'findOne' as any) + .mockResolvedValueOnce(authEntity); - const result = await authService.auth(userEntity as UserEntity, MOCK_IP); + const result = await authService.auth(userEntity as UserEntity); - expect(logoutMock).toHaveBeenCalledWith({ userId: userEntity.id }); + expect(findAuthMock).toHaveBeenCalledWith({ userId: userEntity.id }); + expect(updateAuthMock).not.toHaveBeenCalled(); expect(createAuthMock).toHaveBeenCalledWith({ user: userEntity, - refreshToken, - refreshTokenExpiresAt: expect.any(Number), - ip: MOCK_IP, - status: AuthStatus.ACTIVE, + refreshToken: MOCK_REFRESH_TOKEN_HASHED, + accessToken: MOCK_ACCESS_TOKEN_HASHED, + }); + expect(jwtSignMock).toHaveBeenCalledWith({ + email: userEntity.email, + userId: userEntity.id, }); - expect(jwtSignMock).toHaveBeenCalledWith({ email: userEntity.email }, { expiresIn: expect.any(Number) }); + expect(jwtSignMock).toHaveBeenLastCalledWith( + { + email: userEntity.email, + userId: userEntity.id, + }, + { + expiresIn: undefined, + }, + ); + expect(logoutMock).toHaveBeenCalled(); + expect(hashTokenMock).toHaveBeenCalledWith(MOCK_ACCESS_TOKEN); + expect(hashTokenMock).toHaveBeenLastCalledWith(MOCK_REFRESH_TOKEN); expect(result).toEqual({ - accessToken, - refreshToken, - accessTokenExpiresAt: expect.any(Number), - refreshTokenExpiresAt: expect.any(Number) + accessToken: MOCK_ACCESS_TOKEN, + refreshToken: MOCK_REFRESH_TOKEN, }); }); }); }); - 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 130d1acbf6..4e63acdd59 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 @@ -5,43 +5,48 @@ import { UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { ConfigService } from '@nestjs/config'; -import { FindOptionsWhere } from 'typeorm'; -import { v4 } from 'uuid'; -import { UserEntity } from '../user/user.entity'; -import { UserService } from '../user/user.service'; -import { AuthEntity } from './auth.entity'; -import { TokenType } from './token.entity'; +import { ErrorAuth } from '../../common/constants/errors'; import { UserStatus } from '../../common/enums/user'; import { UserCreateDto } from '../user/user.dto'; +import { UserEntity } from '../user/user.entity'; +import { UserService } from '../user/user.service'; import { + AuthDto, ForgotPasswordDto, ResendEmailVerificationDto, RestorePasswordDto, SignInDto, VerifyEmailDto, } from './auth.dto'; +import { TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; import { AuthRepository } from './auth.repository'; -import { ErrorAuth } from '../../common/constants/errors'; import { ConfigNames } from '../../common/config'; -import { AuthStatus } from '../../common/enums/auth'; -import { IJwt } from '../../common/interfaces/auth'; +import { ConfigService } from '@nestjs/config'; +import { createHash, randomBytes } from 'crypto'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); - + private readonly refreshTokenExpiresIn: string; + private readonly salt: string; constructor( private readonly jwtService: JwtService, private readonly userService: UserService, - private readonly configService: ConfigService, private readonly tokenRepository: TokenRepository, private readonly authRepository: AuthRepository, - ) {} + private readonly configService: ConfigService, + ) { + this.refreshTokenExpiresIn = configService.get( + ConfigNames.JWT_REFRESH_TOKEN_EXPIRES_IN, + '100000000', + ); - public async signin(data: SignInDto, ip: string): Promise { + this.salt = randomBytes(16).toString('hex'); + } + + public async signin(data: SignInDto): Promise { const userEntity = await this.userService.getByCredentials( data.email, data.password, @@ -51,7 +56,11 @@ export class AuthService { throw new NotFoundException(ErrorAuth.InvalidEmailOrPassword); } - return this.auth(userEntity, ip); + if (userEntity.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException(ErrorAuth.UserNotActive); + } + + return this.auth(userEntity); } public async signup(data: UserCreateDto): Promise { @@ -69,65 +78,42 @@ export class AuthService { return userEntity; } - public async logout( - where: FindOptionsWhere, - ): Promise { - await this.authRepository.update({ ...where, status: AuthStatus.ACTIVE }, { status: AuthStatus.EXPIRED }); - return; + public async logout(user: UserEntity): Promise { + await this.authRepository.delete({ userId: user.id }); } - public async refresh( - where: FindOptionsWhere, - ip: string, - ): Promise { - const authEntity = await this.authRepository.findOne(where, { - relations: ['user'], - }); - - if ( - !authEntity || - authEntity.refreshTokenExpiresAt < new Date().getTime() - ) { - throw new UnauthorizedException(ErrorAuth.RefreshTokenHasExpired); - } + public async auth(userEntity: UserEntity): Promise { + const auth = await this.authRepository.findOne({ userId: userEntity.id }); - if (authEntity.user.status !== UserStatus.ACTIVE) { - throw new UnauthorizedException(ErrorAuth.UserNotActive); - } + const accessToken = await this.jwtService.signAsync({ + email: userEntity.email, + userId: userEntity.id, + }); - return this.auth(authEntity.user, ip); - } + const refreshToken = await this.jwtService.signAsync( + { + email: userEntity.email, + userId: userEntity.id, + }, + { + expiresIn: this.refreshTokenExpiresIn, + }, + ); - public async auth(userEntity: UserEntity, ip: string): Promise { - const refreshToken = v4(); - const date = new Date(); + const accessTokenHashed = this.hashToken(accessToken); + const refreshTokenHashed = this.hashToken(refreshToken); - const accessTokenExpiresIn = ~~this.configService.get( - ConfigNames.JWT_ACCESS_TOKEN_EXPIRES_IN, - )!; - const refreshTokenExpiresIn = ~~this.configService.get( - ConfigNames.JWT_REFRESH_TOKEN_EXPIRES_IN, - )!; + if (auth) { + await this.logout(userEntity); + } - await this.logout({ userId: userEntity.id }); - await this.authRepository.create({ user: userEntity, - refreshToken, - refreshTokenExpiresAt: date.getTime() + refreshTokenExpiresIn * 1000, - ip, - status: AuthStatus.ACTIVE + refreshToken: refreshTokenHashed, + accessToken: accessTokenHashed, }); - return { - accessToken: this.jwtService.sign( - { email: userEntity.email }, - { expiresIn: accessTokenExpiresIn }, - ), - refreshToken: refreshToken, - accessTokenExpiresAt: date.getTime() + accessTokenExpiresIn * 1000, - refreshTokenExpiresAt: date.getTime() + refreshTokenExpiresIn * 1000, - }; + return { accessToken, refreshToken }; } public async forgotPassword(data: ForgotPasswordDto): Promise { @@ -169,10 +155,7 @@ export class AuthService { return true; } - public async emailVerification( - data: VerifyEmailDto, - ip: string, - ): Promise { + public async emailVerification(data: VerifyEmailDto): Promise { const tokenEntity = await this.tokenRepository.findOne({ uuid: data.token, tokenType: TokenType.EMAIL, @@ -182,11 +165,8 @@ export class AuthService { throw new NotFoundException('Token not found'); } - await this.userService.activate(tokenEntity.user); - + this.userService.activate(tokenEntity.user); await tokenEntity.remove(); - - return this.auth(tokenEntity.user, ip); } public async resendEmailVerification( @@ -205,4 +185,14 @@ export class AuthService { this.logger.debug('Verification token: ', tokenEntity.uuid); } + + public hashToken(token: string): string { + const hash = createHash('sha256'); + hash.update(token + this.salt); + return hash.digest('hex'); + } + + public compareToken(token: string, hashedToken: string): boolean { + return this.hashToken(token) === hashedToken; + } } 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 030c444550..d36d51000d 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,41 +1,69 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { - Injectable, - NotFoundException, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable, Req, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UserEntity } from '../../user/user.entity'; -import { UserService } from '../../user/user.service'; import { UserStatus } from '../../../common/enums/user'; +import { ConfigNames } from '../../../common/config'; +import { AuthRepository } from '../auth.repository'; +import { AuthService } from '../auth.service'; +import { JWT_PREFIX } from '../../../common/constants'; @Injectable() export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { constructor( - private readonly userService: UserService, + private readonly authRepository: AuthRepository, + private readonly authService: AuthService, private readonly configService: ConfigService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET', 'secretkey'), + secretOrKey: configService.get( + ConfigNames.JWT_SECRET, + 'secretkey', + ), + passReqToCallback: true, }); } - public async validate(payload: { email: string }): Promise { - const email = payload.email.toLowerCase(); - const userEntity = await this.userService.getByEmail(email); + public async validate( + @Req() request: any, + payload: { email: string; userId: number }, + ): Promise { + const auth = await this.authRepository.findOne( + { + userId: payload.userId, + }, + { + relations: ['user'], + }, + ); - if (!userEntity) { - throw new NotFoundException('User not found'); + if (!auth?.user) { + throw new UnauthorizedException('User not found'); } - if (userEntity.status !== UserStatus.ACTIVE) { + if (auth?.user.status !== UserStatus.ACTIVE) { throw new UnauthorizedException('User not active'); } - return userEntity; + //check that the jwt exists in the database + let jwt = request.headers['authorization'] as string; + if (jwt.toLowerCase().substring(0, JWT_PREFIX.length) === JWT_PREFIX) { + jwt = jwt.substring(JWT_PREFIX.length); + } + if (request.url === '/auth/refresh') { + if (!this.authService.compareToken(jwt, auth?.refreshToken)) { + throw new UnauthorizedException('Token expired'); + } + } else { + if (!this.authService.compareToken(jwt, auth?.accessToken)) { + throw new UnauthorizedException('Token expired'); + } + } + + return auth?.user; } } diff --git a/packages/apps/job-launcher/server/src/modules/auth/token.entity.ts b/packages/apps/job-launcher/server/src/modules/auth/token.entity.ts index 6a4cbd7f6c..de132308a4 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/token.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/token.entity.ts @@ -15,7 +15,7 @@ export interface IToken extends IBase { tokenType: TokenType; } -@Entity({ schema: NS, name: 'token' }) +@Entity({ schema: NS, name: 'tokens' }) export class TokenEntity extends BaseEntity implements IToken { @Column({ type: 'uuid', unique: true }) @Generated('uuid') @@ -28,7 +28,7 @@ export class TokenEntity extends BaseEntity implements IToken { public tokenType: TokenType; @JoinColumn() - @OneToOne((_type) => UserEntity) + @OneToOne(() => UserEntity) public user: UserEntity; @Column({ type: 'int' }) 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 d975307aa1..0c7dc7485e 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 @@ -7,35 +7,34 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { RolesGuard } from '../../common/guards'; +import { JwtAuthGuard } from 'src/common/guards'; +import { RequestWithUser } from 'src/common/types'; import { JobCvatDto, JobFortuneDto } from './job.dto'; import { JobService } from './job.service'; @ApiBearerAuth() +@UseGuards(JwtAuthGuard) @ApiTags('Job') @Controller('/job') export class JobController { constructor(private readonly jobService: JobService) {} - @UseGuards(RolesGuard) @Post('/fortune') public async createFortuneJob( - @Request() req: any, + @Request() req: RequestWithUser, @Body() data: JobFortuneDto, ): Promise { - return this.jobService.createFortuneJob(req.user?.id, data); + return this.jobService.createFortuneJob(req.user.id, data); } - @UseGuards(RolesGuard) @Post('/cvat') public async createCvatJob( - @Request() req: any, + @Request() req: RequestWithUser, @Body() data: JobCvatDto, ): Promise { - return this.jobService.createCvatJob(req.user?.id, data); + return this.jobService.createCvatJob(req.user.id, data); } - @UseGuards(RolesGuard) @Get('/result') public async getResult(@Request() req: any): Promise { return this.jobService.getResult(req.user?.id); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts index 7d5bd224fd..91d3655a70 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts @@ -49,20 +49,6 @@ export class JobFortuneDto { public fundAmount: number; } -export class JobFortuneCreateDto extends JobFortuneDto { - @IsNumber() - public userId: number; - - @IsString() - public manifestUrl: string; - - @IsEnum(JobStatus) - public status: JobStatus; - - @IsDate() - public waitUntil: Date; -} - export class JobCvatDto { @ApiProperty({ enum: ChainId, @@ -98,20 +84,6 @@ export class JobCvatDto { public fundAmount: number; } -export class JobCvatCreateDto extends JobCvatDto { - @IsNumber() - public userId: number; - - @IsString() - public manifestUrl: string; - - @IsEnum(JobStatus) - public status: JobStatus; - - @IsDate() - public waitUntil: Date; -} - export class JobUpdateDto { @ApiPropertyOptional({ enum: JobStatus, diff --git a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts index e557a4ce8e..7f9b5f26ac 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; import { NS } from '../../common/constants'; import { IJob } from '../../common/interfaces'; @@ -6,12 +6,13 @@ import { JobStatus } from '../../common/enums/job'; import { BaseEntity } from '../../database/base.entity'; import { UserEntity } from '../user/user.entity'; -@Entity({ schema: NS, name: 'job' }) +@Entity({ schema: NS, name: 'jobs' }) +@Index(['chainId', 'escrowAddress'], { unique: true }) export class JobEntity extends BaseEntity implements IJob { - @Column({ type: 'int' }) + @Column({ type: 'int', nullable: true }) public chainId: number; - @Column({ type: 'varchar' }) + @Column({ type: 'varchar', nullable: true }) public escrowAddress: string; @Column({ type: 'varchar' }) @@ -38,7 +39,7 @@ export class JobEntity extends BaseEntity implements IJob { @Column({ type: 'int' }) public userId: number; - @Column({ type: 'int' }) + @Column({ type: 'int', default: 0 }) public retriesCount: number; @Column({ type: 'timestamptz' }) diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index 1e73dbaa45..f4fb4cf899 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -1,19 +1,17 @@ import { createMock } from '@golevelup/ts-jest'; -import { - ChainId, - EscrowClient, - StorageClient, -} from '@human-protocol/sdk'; +import { ChainId, EscrowClient, StorageClient } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; import { BadGatewayException, BadRequestException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { BigNumber, FixedNumber, ethers } from 'ethers'; +import { ErrorBucket, ErrorJob } from '../../common/constants/errors'; import { - ErrorBucket, - ErrorJob, -} from '../../common/constants/errors'; -import { Currency, PaymentSource, PaymentType, TokenId } from '../../common/enums/payment'; + Currency, + PaymentSource, + PaymentType, + TokenId, +} from '../../common/enums/payment'; import { JobRequestType, JobStatus } from '../../common/enums/job'; import { MOCK_ADDRESS, @@ -32,7 +30,7 @@ import { MOCK_REPUTATION_ORACLE_FEE, MOCK_REQUESTER_DESCRIPTION, MOCK_REQUESTER_TITLE, -} from '../../common/test/constants'; +} from '../../../test/constants'; import { PaymentService } from '../payment/payment.service'; import { Web3Service } from '../web3/web3.service'; import { @@ -47,8 +45,8 @@ import { JobRepository } from './job.repository'; import { JobService } from './job.service'; import { HMToken__factory } from '@human-protocol/core/typechain-types'; -import { CurrencyService } from '../payment/currency.service'; import { RoutingProtocolService } from './routing-protocol.service'; +import { PaymentRepository } from '../payment/payment.repository'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -66,12 +64,17 @@ jest.mock('@human-protocol/sdk', () => ({ })), })); +jest.mock('../../common/utils', () => ({ + getRate: jest.fn().mockImplementation(() => 0.5) +})); + describe('JobService', () => { - let jobService: JobService; - let jobRepository: JobRepository; - let paymentService: PaymentService; - let currencyService: CurrencyService; - let routingProtocolService: RoutingProtocolService; + let jobService: JobService, + jobRepository: JobRepository, + paymentRepository: PaymentRepository, + paymentService: PaymentService, + createPaymentMock: any, + routingProtocolService: RoutingProtocolService; const signerMock = { address: MOCK_ADDRESS, @@ -115,8 +118,8 @@ describe('JobService', () => { getSigner: jest.fn().mockReturnValue(signerMock), }, }, - { provide: CurrencyService, useValue: createMock() }, { provide: JobRepository, useValue: createMock() }, + { provide: PaymentRepository, useValue: createMock() }, { provide: PaymentService, useValue: createMock() }, { provide: ConfigService, useValue: mockConfigService }, { provide: HttpService, useValue: createMock() }, @@ -127,15 +130,15 @@ describe('JobService', () => { ], }).compile(); - currencyService = moduleRef.get(CurrencyService); jobService = moduleRef.get(JobService); jobRepository = moduleRef.get(JobRepository); + paymentRepository = moduleRef.get(PaymentRepository); paymentService = moduleRef.get(PaymentService); routingProtocolService = moduleRef.get(RoutingProtocolService); + createPaymentMock = jest.spyOn(paymentRepository, 'create'); }); describe('createFortuneJob', () => { - let userBalance: ethers.BigNumber; const rate = 0.5; const userId = 1; const dto: JobFortuneDto = { @@ -150,9 +153,7 @@ describe('JobService', () => { beforeEach(() => { getUserBalanceMock = jest.spyOn(paymentService, 'getUserBalance'); - - jest.spyOn(currencyService, 'getRate').mockResolvedValue(rate); - jest.spyOn(paymentService, 'savePayment').mockResolvedValue(true); + createPaymentMock.mockResolvedValue(true); }); afterEach(() => { @@ -160,34 +161,37 @@ describe('JobService', () => { }); it('should create a fortune job successfully', async () => { - const userBalance = ethers.utils.parseUnits('15', 'ether') + const userBalance = ethers.utils.parseUnits('15', 'ether'); getUserBalanceMock.mockResolvedValue(userBalance); const fundAmountInWei = ethers.utils.parseUnits( dto.fundAmount.toString(), 'ether', ); - const jobLauncherFee = BigNumber.from( - MOCK_JOB_LAUNCHER_FEE, - ).div(100).mul(fundAmountInWei); + const jobLauncherFee = BigNumber.from(MOCK_JOB_LAUNCHER_FEE) + .div(100) + .mul(fundAmountInWei); const usdTotalAmount = BigNumber.from( FixedNumber.from( - ethers.utils.formatUnits(fundAmountInWei.add(jobLauncherFee), 'ether'), + ethers.utils.formatUnits( + fundAmountInWei.add(jobLauncherFee), + 'ether', + ), ).mulUnsafe(FixedNumber.from(rate.toString())), ); await jobService.createFortuneJob(userId, dto); expect(paymentService.getUserBalance).toHaveBeenCalledWith(userId); - expect(paymentService.savePayment).toHaveBeenCalledWith( + expect(paymentRepository.create).toHaveBeenCalledWith({ userId, - PaymentSource.BALANCE, - Currency.USD, - TokenId.HMT, - PaymentType.WITHDRAWAL, - usdTotalAmount, - ); + source: PaymentSource.BALANCE, + type: PaymentType.WITHDRAWAL, + currency: TokenId.HMT, + amount: usdTotalAmount.toString(), + rate: 0.5 + }); expect(jobRepository.create).toHaveBeenCalledWith({ chainId: dto.chainId, userId, @@ -212,15 +216,6 @@ describe('JobService', () => { .div(100) .mul(fundAmountInWei); - const usdTotalAmount = BigNumber.from( - FixedNumber.from( - ethers.utils.formatUnits( - fundAmountInWei.add(jobLauncherFee), - 'ether', - ), - ).mulUnsafe(FixedNumber.from(rate.toString())), - ); - jest .spyOn(routingProtocolService, 'selectNetwork') .mockReturnValue(ChainId.MOONBEAM); @@ -228,14 +223,6 @@ describe('JobService', () => { await jobService.createFortuneJob(userId, { ...dto, chainId: undefined }); expect(paymentService.getUserBalance).toHaveBeenCalledWith(userId); - expect(paymentService.savePayment).toHaveBeenCalledWith( - userId, - PaymentSource.BALANCE, - Currency.USD, - TokenId.HMT, - PaymentType.WITHDRAWAL, - usdTotalAmount, - ); expect(jobRepository.create).toHaveBeenCalledWith({ chainId: ChainId.MOONBEAM, userId, @@ -252,8 +239,9 @@ describe('JobService', () => { const fundAmount = 10; // ETH const userBalance = ethers.utils.parseUnits('1', 'ether'); // 1 ETH - jest.spyOn(paymentService, 'getUserBalance').mockResolvedValue(userBalance); - + jest + .spyOn(paymentService, 'getUserBalance') + .mockResolvedValue(userBalance); getUserBalanceMock.mockResolvedValue(userBalance); @@ -273,12 +261,14 @@ describe('JobService', () => { it('should throw an exception if job entity creation fails', async () => { const fundAmount = 1; // ETH - const userBalance = ethers.utils.parseUnits('10', 'ether') + const userBalance = ethers.utils.parseUnits('10', 'ether'); getUserBalanceMock.mockResolvedValue(userBalance); + jest.spyOn(jobRepository, 'create').mockResolvedValue(undefined!); + const dto: JobFortuneDto = { chainId: MOCK_CHAIN_ID, fortunesRequired: MOCK_FORTUNES_REQUIRED, @@ -302,8 +292,12 @@ describe('JobService', () => { }; beforeEach(() => { - jest.spyOn(HMToken__factory, 'connect').mockReturnValue(mockTokenContract); + jest + .spyOn(HMToken__factory, 'connect') + .mockReturnValue(mockTokenContract); getManifestMock = jest.spyOn(jobService, 'getManifest'); + + createPaymentMock.mockResolvedValue(true); }); afterEach(() => { @@ -342,11 +336,16 @@ describe('JobService', () => { await jobService.launchJob(mockJobEntity as JobEntity); - expect(mockTokenContract.transfer).toHaveBeenCalledWith(MOCK_ADDRESS, mockJobEntity.fundAmount); + expect(mockTokenContract.transfer).toHaveBeenCalledWith( + MOCK_ADDRESS, + mockJobEntity.fundAmount, + ); expect(mockJobEntity.escrowAddress).toBe(MOCK_ADDRESS); expect(mockJobEntity.status).toBe(JobStatus.LAUNCHED); expect(mockJobEntity.save).toHaveBeenCalled(); - expect(jobService.getManifest).toHaveBeenCalledWith(mockJobEntity.manifestUrl); + expect(jobService.getManifest).toHaveBeenCalledWith( + mockJobEntity.manifestUrl, + ); }); it('should throw an unpredictable gas limit error if transfer failed', async () => { @@ -361,7 +360,12 @@ describe('JobService', () => { }; getManifestMock.mockResolvedValue(manifest); - mockTokenContract.transfer.mockRejectedValue(Object.assign(new Error(ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT), { code: ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT })); + mockTokenContract.transfer.mockRejectedValue( + Object.assign( + new Error(ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT), + { code: ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT }, + ), + ); const mockJobEntity: Partial = { chainId, @@ -374,7 +378,9 @@ describe('JobService', () => { await expect( jobService.launchJob(mockJobEntity as JobEntity), - ).rejects.toThrow(new Error(ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT)); + ).rejects.toThrow( + new Error(ethers.utils.Logger.errors.UNPREDICTABLE_GAS_LIMIT), + ); }); it('should throw an error if the manifest does not exist', async () => { @@ -420,9 +426,7 @@ describe('JobService', () => { it('should handle error during job launch', async () => { (EscrowClient.build as any).mockImplementation(() => ({ - createAndSetupEscrow: jest - .fn() - .mockRejectedValue(new Error()), + createAndSetupEscrow: jest.fn().mockRejectedValue(new Error()), })); const mockJobEntity: Partial = { @@ -448,7 +452,9 @@ describe('JobService', () => { }; beforeEach(() => { - jest.spyOn(HMToken__factory, 'connect').mockReturnValue(mockTokenContract); + jest + .spyOn(HMToken__factory, 'connect') + .mockReturnValue(mockTokenContract); getManifestMock = jest.spyOn(jobService, 'getManifest'); }); @@ -458,12 +464,6 @@ describe('JobService', () => { it('should launch a job successfully', async () => { const fundAmountInWei = ethers.utils.parseUnits('10', 'ether'); - const totalFeePercentage = BigNumber.from(MOCK_JOB_LAUNCHER_FEE) - .add(MOCK_RECORDING_ORACLE_FEE) - .add(MOCK_REPUTATION_ORACLE_FEE); - const totalFee = BigNumber.from(fundAmountInWei) - .mul(totalFeePercentage) - .div(100); const manifest: ImageLabelBinaryManifestDto = { dataUrl: MOCK_FILE_URL, @@ -472,7 +472,7 @@ describe('JobService', () => { submissionsRequired: 10, requesterDescription: MOCK_REQUESTER_DESCRIPTION, fundAmount: fundAmountInWei.toString(), - requestType: JobRequestType.IMAGE_LABEL_BINARY + requestType: JobRequestType.IMAGE_LABEL_BINARY, }; jest.spyOn(jobService, 'getManifest').mockResolvedValue(manifest); @@ -488,7 +488,9 @@ describe('JobService', () => { requestType: JobRequestType.IMAGE_LABEL_BINARY, }; - getManifestMock.mockResolvedValue(invalidManifest as ImageLabelBinaryManifestDto); + getManifestMock.mockResolvedValue( + invalidManifest as ImageLabelBinaryManifestDto, + ); const mockJobEntity: Partial = { chainId: 1, @@ -549,7 +551,9 @@ describe('JobService', () => { await expect( jobService.saveManifest(encryptedManifest, MOCK_BUCKET_NAME), - ).rejects.toThrowError(new BadGatewayException(ErrorBucket.UnableSaveFile)); + ).rejects.toThrowError( + new BadGatewayException(ErrorBucket.UnableSaveFile), + ); expect(jobService.storageClient.uploadFiles).toHaveBeenCalledWith( [encryptedManifest], MOCK_BUCKET_NAME, @@ -608,7 +612,10 @@ describe('JobService', () => { let downloadFileFromUrlMock: any; beforeEach(() => { - downloadFileFromUrlMock = jest.spyOn(StorageClient, 'downloadFileFromUrl'); + downloadFileFromUrlMock = jest.spyOn( + StorageClient, + 'downloadFileFromUrl', + ); }); afterEach(() => { @@ -616,17 +623,17 @@ describe('JobService', () => { }); it('should download and return the manifest', async () => { - const fundAmountInWei = ethers.utils.parseUnits( - '10', - 'ether', - ); - const jobLauncherFee = BigNumber.from( - MOCK_JOB_LAUNCHER_FEE, - ).div(100).mul(fundAmountInWei); + const fundAmountInWei = ethers.utils.parseUnits('10', 'ether'); + const jobLauncherFee = BigNumber.from(MOCK_JOB_LAUNCHER_FEE) + .div(100) + .mul(fundAmountInWei); const usdTotalAmount = BigNumber.from( FixedNumber.from( - ethers.utils.formatUnits(fundAmountInWei.add(jobLauncherFee), 'ether'), + ethers.utils.formatUnits( + fundAmountInWei.add(jobLauncherFee), + 'ether', + ), ).mulUnsafe(FixedNumber.from('10'.toString())), ); 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 4609adbb9b..bb42693f64 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 @@ -54,8 +54,9 @@ import { HMToken, HMToken__factory, } from '@human-protocol/core/typechain-types'; -import { CurrencyService } from '../payment/currency.service'; import { RoutingProtocolService } from './routing-protocol.service'; +import { PaymentRepository } from '../payment/payment.repository'; +import { getRate } from '../../common/utils'; @Injectable() export class JobService { @@ -68,8 +69,8 @@ export class JobService { @Inject(Web3Service) private readonly web3Service: Web3Service, public readonly jobRepository: JobRepository, - public readonly paymentService: PaymentService, - private readonly currencyService: CurrencyService, + private readonly paymentRepository: PaymentRepository, + private readonly paymentService: PaymentService, public readonly httpService: HttpService, public readonly configService: ConfigService, private readonly routingProtocolService: RoutingProtocolService, @@ -87,7 +88,7 @@ export class JobService { useSSL, }; - this.bucket = this.configService.get(ConfigNames.S3_BACKET)!; + this.bucket = this.configService.get(ConfigNames.S3_BUCKET)!; this.storageClient = new StorageClient( storageCredentials, @@ -114,7 +115,7 @@ export class JobService { 'ether', ); - const rate = await this.currencyService.getRate(Currency.USD, TokenId.HMT); + const rate = await getRate(Currency.USD, TokenId.HMT); const jobLauncherFee = BigNumber.from( this.configService.get(ConfigNames.JOB_LAUNCHER_FEE)!, @@ -162,14 +163,14 @@ export class JobService { throw new NotFoundException(ErrorJob.NotCreated); } - await this.paymentService.savePayment( + await this.paymentRepository.create({ userId, - PaymentSource.BALANCE, - Currency.USD, - TokenId.HMT, - PaymentType.WITHDRAWAL, - usdTotalAmount, - ); + source: PaymentSource.BALANCE, + type: PaymentType.WITHDRAWAL, + amount: usdTotalAmount.toString(), + currency: TokenId.HMT, + rate + }) jobEntity.status = JobStatus.PAID; await jobEntity.save(); @@ -195,7 +196,7 @@ export class JobService { 'ether', ); - const rate = await this.currencyService.getRate(Currency.USD, TokenId.HMT); + const rate = await getRate(Currency.USD, TokenId.HMT); const jobLauncherFee = BigNumber.from( this.configService.get(ConfigNames.JOB_LAUNCHER_FEE)!, @@ -245,14 +246,14 @@ export class JobService { throw new NotFoundException(ErrorJob.NotCreated); } - await this.paymentService.savePayment( + await this.paymentRepository.create({ userId, - PaymentSource.BALANCE, - Currency.USD, - TokenId.HMT, - PaymentType.WITHDRAWAL, - usdTotalAmount, - ); + source: PaymentSource.BALANCE, + type: PaymentType.WITHDRAWAL, + amount: usdTotalAmount.toString(), + currency: TokenId.HMT, + rate + }) jobEntity.status = JobStatus.PAID; await jobEntity.save(); diff --git a/packages/apps/job-launcher/server/src/modules/job/routing-protocol.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/routing-protocol.service.spec.ts index a80d4cd4ef..a80bd0be67 100644 --- a/packages/apps/job-launcher/server/src/modules/job/routing-protocol.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/routing-protocol.service.spec.ts @@ -1,12 +1,12 @@ import { Test } from '@nestjs/testing'; -import { - MOCK_ADDRESS, - MOCK_FILE_HASH, - MOCK_FILE_KEY, - MOCK_FILE_URL, -} from '../../common/test/constants'; + import { RoutingProtocolService } from './routing-protocol.service'; import { NETWORKS } from '@human-protocol/sdk'; +import { MOCK_ADDRESS, + MOCK_FILE_HASH, + MOCK_FILE_KEY, + MOCK_FILE_URL +} from '../../../test/constants'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), diff --git a/packages/apps/job-launcher/server/src/modules/payment/currency.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/currency.service.spec.ts deleted file mode 100644 index e04701d1e8..0000000000 --- a/packages/apps/job-launcher/server/src/modules/payment/currency.service.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CurrencyService } from './currency.service'; -import { HttpService } from '@nestjs/axios'; -import { createMock } from '@golevelup/ts-jest'; -import { Currency, TokenId } from '../../common/enums/payment'; -import { COINGECKO_API_URL } from '../../common/constants'; -import { ErrorCurrency } from '../../common/constants/errors'; -import { of } from 'rxjs'; -import { CoingeckoTokenId } from '../../common/constants/payment'; - -describe('CurrencyService', () => { - let currencyService: CurrencyService; - let httpService: HttpService; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CurrencyService, - { - provide: HttpService, - useValue: createMock(), - }, - ], - }).compile(); - - currencyService = module.get(CurrencyService); - httpService = module.get(HttpService); - }); - - describe('getRate', () => { - it('should get the rate for a given usd to other fiat currency and reverse it', async () => { - const currency = Currency.USD; - const otherCurrency = Currency.EUR; - const rate = 0.9; - const reversedRate = 1.1111111111111112; - - const response = { - data: { - [currency]: { - [otherCurrency]: rate, - }, - }, - }; - - jest.spyOn(httpService, 'get').mockReturnValueOnce(of(response as any)); - const result = await currencyService.getRate(currency, otherCurrency); - - expect(httpService.get).toHaveBeenCalledWith( - `${COINGECKO_API_URL}?ids=${currency}&vs_currencies=${otherCurrency}`, - ); - expect(result).toBe(reversedRate); - }); - - it('should get the rate for a given token ID and currency', async () => { - const tokenId = TokenId.HMT; - const currency = Currency.USD; - const rate = 1.5; - - const response = { - data: { - [CoingeckoTokenId[tokenId]]: { - [currency]: rate, - }, - }, - }; - - jest.spyOn(httpService, 'get').mockReturnValueOnce(of(response as any)); - const result = await currencyService.getRate(currency, tokenId); - - expect(httpService.get).toHaveBeenCalledWith( - `${COINGECKO_API_URL}?ids=${CoingeckoTokenId[tokenId]}&vs_currencies=${currency}`, - ); - expect(result).toBe(rate); - }); - - it('should throw NotFoundException if the rate is not found', async () => { - const tokenId = TokenId.HMT; - const currency = Currency.USD; - - const response = { - data: {}, - }; - - jest.spyOn(httpService, 'get').mockReturnValueOnce(of(response as any)); - - await expect(currencyService.getRate(currency, tokenId)).rejects.toThrow( - ErrorCurrency.PairNotFound, - ); - }); - }); -}); diff --git a/packages/apps/job-launcher/server/src/modules/payment/currency.service.ts b/packages/apps/job-launcher/server/src/modules/payment/currency.service.ts deleted file mode 100644 index c00fa5a820..0000000000 --- a/packages/apps/job-launcher/server/src/modules/payment/currency.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { HttpService } from '@nestjs/axios'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { firstValueFrom } from 'rxjs'; -import { COINGECKO_API_URL } from '../../common/constants'; -import { ErrorCurrency } from '../../common/constants/errors'; -import { Currency, TokenId } from '../../common/enums/payment'; -import { CoingeckoTokenId } from '../../common/constants/payment'; - -@Injectable() -export class CurrencyService { - constructor(private readonly httpService: HttpService) {} - - public async getRate(from: string, to: string): Promise { - let reversed = false; - - if (Object.values(TokenId).includes(to as TokenId)) { - [from, to] = [CoingeckoTokenId[to], from] - } else { - reversed = true; - } - - const { data } = await firstValueFrom( - this.httpService.get( - `${COINGECKO_API_URL}?ids=${from}&vs_currencies=${to}`, - ), - ); - - if (!data[from] || !data[from][to]) { - throw new NotFoundException(ErrorCurrency.PairNotFound); - } - - const rate = data[from][to]; - - return reversed ? 1 / rate : rate; - } -} 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 32f880c63a..1376f51148 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 @@ -8,48 +8,43 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { RolesGuard } from '../../common/guards'; +import { JwtAuthGuard } from 'src/common/guards'; +import { RequestWithUser } from 'src/common/types'; -import { PaymentService } from './payment.service'; import { GetRateDto, PaymentCryptoCreateDto, PaymentFiatConfirmDto, PaymentFiatCreateDto, } from './payment.dto'; -import { CurrencyService } from './currency.service'; +import { PaymentService } from './payment.service'; +import { getRate } from '../../common/utils'; @ApiBearerAuth() +@UseGuards(JwtAuthGuard) @ApiTags('Payment') @Controller('/payment') export class PaymentController { constructor( private readonly paymentService: PaymentService, - private readonly currencyService: CurrencyService, ) {} - @UseGuards(RolesGuard) @Post('/fiat') public async createFiatPayment( - @Request() req: any, + @Request() req: RequestWithUser, @Body() data: PaymentFiatCreateDto, ): Promise { - return this.paymentService.createFiatPayment( - req.user?.stripeCustomerId, - data, - ); + return this.paymentService.createFiatPayment(req.user, data); } - @UseGuards(RolesGuard) @Post('/fiat/confirm-payment') public async confirmFiatPayment( - @Request() req: any, + @Request() req: RequestWithUser, @Body() data: PaymentFiatConfirmDto, ): Promise { - return this.paymentService.confirmFiatPayment(req.user?.id, data); + return this.paymentService.confirmFiatPayment(req.user.id, data); } - @UseGuards(RolesGuard) @Post('/crypto') public async createCryptoPayment( @Request() req: any, @@ -58,11 +53,10 @@ export class PaymentController { return this.paymentService.createCryptoPayment(req.user?.id, data); } - @UseGuards(RolesGuard) @Get('/rates') public async getRate(@Query() data: GetRateDto): Promise { try { - return this.currencyService.getRate(data.currency, data.token); + return getRate(data.currency, data.token); } catch (e) { throw new Error(e); } diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index d0bb0797af..716f2687ad 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -40,13 +40,14 @@ export class PaymentCryptoCreateDto { } export class PaymentCreateDto { - public transactionHash?: string; + public transaction?: string; public amount?: string; public currency?: string; public source?: PaymentSource; public userId?: number; public rate?: number; public type?: PaymentType; + public chainId?: number; } export class GetRateDto { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts index 9eab426f68..6074c221df 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts @@ -1,20 +1,24 @@ -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { NS } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; -import { - Currency, - PaymentSource, - PaymentType, -} from '../../common/enums/payment'; +import { PaymentSource, PaymentType } from '../../common/enums/payment'; import { UserEntity } from '../user/user.entity'; -@Entity({ schema: NS, name: 'payment' }) +@Entity({ schema: NS, name: 'payments' }) +@Index(['chainId', 'transaction'], { + unique: true, + where: '(chain_Id IS NOT NULL AND transaction IS NOT NULL)', +}) +@Index(['transaction'], { + unique: true, + where: '(chain_Id IS NULL AND transaction IS NOT NULL)', +}) export class PaymentEntity extends BaseEntity { - @Column({ type: 'varchar', default: null, nullable: true }) - public paymentId: string; + @Column({ type: 'varchar', nullable: true }) + public transaction: string; - @Column({ type: 'varchar', default: null, nullable: true }) - public transactionHash: string; + @Column({ type: 'int', nullable: true }) + public chainId: number; @Column({ type: 'varchar' }) public amount: string; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts index 0047ce925d..15eb85aaca 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { PaymentService } from './payment.service'; -import { CurrencyService } from './currency.service'; import { MinioModule } from 'nestjs-minio-client'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { PaymentEntity } from './payment.entity'; @@ -32,7 +31,7 @@ import { Web3Module } from '../web3/web3.module'; }), ], controllers: [PaymentController], - providers: [PaymentService, PaymentRepository, CurrencyService], - exports: [PaymentService, CurrencyService], + providers: [PaymentService, PaymentRepository], + exports: [PaymentService, PaymentRepository], }) export class PaymentModule {} 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 37fbb43f3e..e0ac562d21 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 @@ -6,9 +6,8 @@ import { PaymentRepository } from './payment.repository'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { BigNumber } from 'ethers'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { createMock } from '@golevelup/ts-jest'; import { ErrorPayment } from '../../common/constants/errors'; -import { CurrencyService } from './currency.service'; import { TransactionReceipt, Log } from '@ethersproject/abstract-provider'; import { Currency, @@ -24,18 +23,21 @@ import { MOCK_EMAIL, MOCK_PAYMENT_ID, MOCK_TRANSACTION_HASH, -} from '../../common/test/constants'; +} from '../../../test/constants'; import { Web3Service } from '../web3/web3.service'; import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { ChainId } from '@human-protocol/sdk'; jest.mock('@human-protocol/sdk'); +jest.mock('../../common/utils', () => ({ + getRate: jest.fn().mockImplementation(() => 0.5) +})); + describe('PaymentService', () => { let stripe: Stripe; let paymentService: PaymentService; let paymentRepository: PaymentRepository; - let currencyService: CurrencyService; const signerMock = { address: MOCK_ADDRESS, @@ -61,7 +63,7 @@ describe('PaymentService', () => { } }), }; - + const moduleRef = await Test.createTestingModule({ providers: [ PaymentService, @@ -75,33 +77,39 @@ describe('PaymentService', () => { getSigner: jest.fn().mockReturnValue(signerMock), }, }, - { provide: CurrencyService, useValue: createMock() }, { provide: ConfigService, useValue: mockConfigService }, { provide: HttpService, useValue: createMock() }, ], - exports: [CurrencyService], }).compile(); - + paymentService = moduleRef.get(PaymentService); paymentRepository = moduleRef.get(PaymentRepository); - currencyService = moduleRef.get(CurrencyService); - + const stripeCustomersCreateMock = jest.fn(); const stripePaymentIntentsCreateMock = jest.fn(); - + const stripePaymentIntentsRetrieveMock = jest.fn(); + stripe = { customers: { create: stripeCustomersCreateMock, }, paymentIntents: { create: stripePaymentIntentsCreateMock, + retrieve: stripePaymentIntentsRetrieveMock }, } as any; - + paymentService['stripe'] = stripe; - - jest.spyOn(stripe.customers, 'create').mockImplementation(stripeCustomersCreateMock); - jest.spyOn(stripe.paymentIntents, 'create').mockImplementation(stripePaymentIntentsCreateMock); + + jest + .spyOn(stripe.customers, 'create') + .mockImplementation(stripeCustomersCreateMock); + jest + .spyOn(stripe.paymentIntents, 'create') + .mockImplementation(stripePaymentIntentsCreateMock); + jest + .spyOn(stripe.paymentIntents, 'retrieve') + .mockImplementation(stripePaymentIntentsRetrieveMock); }); describe('createCustomer', () => { @@ -126,8 +134,8 @@ describe('PaymentService', () => { }; createCustomerMock.mockResolvedValue( - stripeApiResponse as Stripe.Response, - ); + stripeApiResponse as Stripe.Response, + ); const result = await paymentService.createCustomer(MOCK_EMAIL); @@ -143,59 +151,72 @@ describe('PaymentService', () => { describe('createFiatPayment', () => { let createPaymentIntentMock: any; - + beforeEach(() => { createPaymentIntentMock = jest.spyOn(stripe.paymentIntents, 'create'); }); - + afterEach(() => { expect(createPaymentIntentMock).toHaveBeenCalledTimes(1); createPaymentIntentMock.mockRestore(); }); - + it('should create a fiat payment successfully', async () => { - const customerId = MOCK_CUSTOMER_ID; const dto = { amount: 100, currency: Currency.USD, }; - + const paymentIntent = { client_secret: 'clientSecret123', }; - + createPaymentIntentMock.mockResolvedValue(paymentIntent); - - const result = await paymentService.createFiatPayment(customerId, dto); - + + // TODO: Remove this after resolve remove comments + const user = {} + + const result = await paymentService.createFiatPayment(user as any, dto); + expect(createPaymentIntentMock).toHaveBeenCalledWith({ - payment_method_types: [PaymentFiatMethodType.CARD], amount: dto.amount * 100, currency: dto.currency, - confirm: true, - customer: customerId, - payment_method_options: {}, }); expect(result).toEqual(paymentIntent.client_secret); }); - + it('should throw a bad request exception if the payment intent creation fails', async () => { const customerId = MOCK_CUSTOMER_ID; - + const dto = { amount: 100, currency: Currency.USD, }; - + createPaymentIntentMock.mockRejectedValue(new Error()); - + + // TODO: Remove this after resolve remove comments + const user = {} + await expect( - paymentService.createFiatPayment(customerId, dto), + paymentService.createFiatPayment(user as any, dto), ).rejects.toThrowError(); }); }); describe('confirmFiatPayment', () => { + let createPaymentMock: any, + retrievePaymentIntentMock: any; + + beforeEach(() => { + createPaymentMock = jest.spyOn(paymentRepository, 'create'); + retrievePaymentIntentMock = jest.spyOn(stripe.paymentIntents, 'retrieve'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should confirm a fiat payment successfully', async () => { const userId = 1; const dto = { @@ -203,32 +224,25 @@ describe('PaymentService', () => { }; const rate = 1.5; - const paymentData: Partial> = { + const paymentData = { status: 'succeeded', amount: 100, - currency: Currency.EUR + currency: Currency.EUR, }; - jest - .spyOn(paymentService, 'getPayment') - .mockResolvedValue( - paymentData as Stripe.Response, - ); - jest.spyOn(paymentService, 'savePayment').mockResolvedValue(true); - - jest.spyOn(currencyService, 'getRate').mockResolvedValue(rate); + retrievePaymentIntentMock.mockResolvedValue(paymentData); + createPaymentMock.mockResolvedValue(true); const result = await paymentService.confirmFiatPayment(userId, dto); - expect(paymentService.getPayment).toHaveBeenCalledWith(dto.paymentId); - expect(paymentService.savePayment).toHaveBeenCalledWith( + expect(paymentRepository.create).toHaveBeenCalledWith({ userId, - PaymentSource.FIAT, - Currency.USD, - Currency.EUR, - PaymentType.DEPOSIT, - BigNumber.from(paymentData.amount), - ); + source: PaymentSource.FIAT, + currency: Currency.EUR, + type: PaymentType.DEPOSIT, + amount: paymentData.amount.toString(), + rate: 0.5 + }); expect(result).toBe(true); }); @@ -238,16 +252,12 @@ describe('PaymentService', () => { paymentId: MOCK_PAYMENT_ID, }; - const paymentData: Partial> = { + const paymentData = { status: 'canceled', amount: 100, }; - jest - .spyOn(paymentService, 'getPayment') - .mockResolvedValue( - paymentData as Stripe.Response, - ); + retrievePaymentIntentMock.mockResolvedValue(paymentData); await expect( paymentService.confirmFiatPayment(userId, dto), @@ -260,86 +270,8 @@ describe('PaymentService', () => { paymentId: MOCK_PAYMENT_ID, }; - jest.spyOn(paymentService, 'getPayment').mockResolvedValue(null); - - await expect( - paymentService.confirmFiatPayment(userId, dto), - ).rejects.toThrowError(ErrorPayment.NotFound); - }); - }); + retrievePaymentIntentMock.mockResolvedValue(null); - describe('confirmFiatPayment', () => { - let getPaymentMock: any, - savePaymentMock: any, - getRateMock: any; - - beforeEach(() => { - getPaymentMock = jest.spyOn(paymentService, 'getPayment'); - savePaymentMock = jest.spyOn(paymentService, 'savePayment'); - getRateMock = jest.spyOn(currencyService, 'getRate'); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should confirm a fiat payment successfully', async () => { - const userId = 1; - const dto = { - paymentId: MOCK_PAYMENT_ID, - }; - const rate = 1.5; - - const paymentData = { - status: 'succeeded', - amount: 100, - currency: Currency.EUR - }; - - getPaymentMock.mockResolvedValue(paymentData); - savePaymentMock.mockResolvedValue(true); - getRateMock.mockResolvedValue(rate); - - const result = await paymentService.confirmFiatPayment(userId, dto); - - expect(paymentService.getPayment).toHaveBeenCalledWith(dto.paymentId); - expect(paymentService.savePayment).toHaveBeenCalledWith( - userId, - PaymentSource.FIAT, - Currency.USD, - Currency.EUR, - PaymentType.DEPOSIT, - BigNumber.from(paymentData.amount), - ); - expect(result).toBe(true); - }); - - it('should throw a bad request exception if the payment is not successful', async () => { - const userId = 1; - const dto = { - paymentId: MOCK_PAYMENT_ID, - }; - - const paymentData = { - status: 'canceled', - amount: 100, - }; - - getPaymentMock.mockResolvedValue(paymentData); - - await expect( - paymentService.confirmFiatPayment(userId, dto), - ).rejects.toThrowError(ErrorPayment.NotSuccess); - }); - - it('should return false if the payment is not found', async () => { - const userId = 1; - const dto = { - paymentId: MOCK_PAYMENT_ID, - }; - - getPaymentMock.mockResolvedValue(null); - await expect( paymentService.confirmFiatPayment(userId, dto), ).rejects.toThrowError(ErrorPayment.NotFound); @@ -347,42 +279,42 @@ describe('PaymentService', () => { }); describe('createCryptoPayment', () => { - let jsonRpcProviderMock: any, - findOneMock: any, - savePaymentMock: any; + let jsonRpcProviderMock: any, findOneMock: any, createPaymentMock: any; const mockTokenContract: any = { symbol: jest.fn(), }; - + beforeEach(() => { jsonRpcProviderMock = { getTransactionReceipt: jest.fn(), }; - + jest .spyOn(ethers.providers, 'JsonRpcProvider') .mockReturnValue(jsonRpcProviderMock as any); - - jest.spyOn(HMToken__factory, 'connect').mockReturnValue(mockTokenContract); + + jest + .spyOn(HMToken__factory, 'connect') + .mockReturnValue(mockTokenContract); jest.spyOn(mockTokenContract, 'symbol'); findOneMock = jest.spyOn(paymentRepository, 'findOne'); - savePaymentMock = jest.spyOn(paymentService, 'savePayment'); + createPaymentMock = jest.spyOn(paymentRepository, 'create'); }); - + afterEach(() => { jest.restoreAllMocks(); }); - + it('should create a crypto payment successfully', async () => { const userId = 1; const dto = { chainId: ChainId.LOCALHOST, transactionHash: MOCK_TRANSACTION_HASH, }; - + const token = 'hmt'; - + const transactionReceipt: Partial = { logs: [ { @@ -392,11 +324,7 @@ describe('PaymentService', () => { transactionIndex: 123, removed: false, address: MOCK_ADDRESS, - topics: [ - '0x123', - '0x0000000000000000000000000123', - MOCK_ADDRESS, - ], + topics: ['0x123', '0x0000000000000000000000000123', MOCK_ADDRESS], transactionHash: MOCK_TRANSACTION_HASH, logIndex: 123, }, @@ -404,39 +332,42 @@ describe('PaymentService', () => { transactionHash: MOCK_TRANSACTION_HASH, confirmations: TX_CONFIRMATION_TRESHOLD, }; - - jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue(transactionReceipt); - + + jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue( + transactionReceipt, + ); + mockTokenContract.symbol.mockResolvedValue(token); findOneMock.mockResolvedValue(null); - savePaymentMock.mockResolvedValue(true); - + createPaymentMock.mockResolvedValue(true); + const result = await paymentService.createCryptoPayment(userId, dto); - + expect(paymentRepository.findOne).toHaveBeenCalledWith({ - transactionHash: dto.transactionHash, + transaction: dto.transactionHash, }); - expect(paymentService.savePayment).toHaveBeenCalledWith( + expect(paymentRepository.create).toHaveBeenCalledWith({ userId, - PaymentSource.CRYPTO, - Currency.USD, - TokenId.HMT, - PaymentType.DEPOSIT, - BigNumber.from('100'), - MOCK_TRANSACTION_HASH - ); + source: PaymentSource.CRYPTO, + type: PaymentType.DEPOSIT, + currency: TokenId.HMT, + amount: '100', + rate: 0.5, + transaction: MOCK_TRANSACTION_HASH, + chainId: ChainId.LOCALHOST + }); expect(result).toBe(true); }); - + it('should throw a conflict exception if an unsupported token is used', async () => { const userId = 1; const dto = { chainId: ChainId.LOCALHOST, transactionHash: MOCK_TRANSACTION_HASH, }; - + const unsupportedToken = 'doge'; - + const transactionReceipt: Partial = { logs: [ { @@ -446,11 +377,7 @@ describe('PaymentService', () => { transactionIndex: 123, removed: false, address: MOCK_ADDRESS, - topics: [ - '0x123', - '0x0000000000000000000000000123', - MOCK_ADDRESS, - ], + topics: ['0x123', '0x0000000000000000000000000123', MOCK_ADDRESS], transactionHash: MOCK_TRANSACTION_HASH, logIndex: 123, }, @@ -458,56 +385,60 @@ describe('PaymentService', () => { transactionHash: MOCK_TRANSACTION_HASH, confirmations: TX_CONFIRMATION_TRESHOLD, }; - - jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue(transactionReceipt); - + + jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue( + transactionReceipt, + ); + mockTokenContract.symbol.mockResolvedValue(unsupportedToken); - + await expect( paymentService.createCryptoPayment(userId, dto), ).rejects.toThrowError(ErrorPayment.UnsupportedToken); }); - + it('should throw a not found exception if the transaction is not found by hash', async () => { const userId = 1; const dto = { chainId: ChainId.LOCALHOST, transactionHash: MOCK_TRANSACTION_HASH, }; - + jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue(null); - + await expect( paymentService.createCryptoPayment(userId, dto), ).rejects.toThrowError(ErrorPayment.TransactionNotFoundByHash); }); - + it('should throw a not found exception if the transaction data is invalid', async () => { const userId = 1; const dto = { chainId: ChainId.LOCALHOST, transactionHash: MOCK_TRANSACTION_HASH, }; - + const transactionReceipt: Partial = { logs: [], confirmations: TX_CONFIRMATION_TRESHOLD, }; - - jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue(transactionReceipt); - + + jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue( + transactionReceipt, + ); + await expect( paymentService.createCryptoPayment(userId, dto), ).rejects.toThrowError(ErrorPayment.InvalidTransactionData); }); - + it('should throw a not found exception if the transaction has insufficient confirmations', async () => { const userId = 1; const dto = { chainId: ChainId.LOCALHOST, transactionHash: MOCK_TRANSACTION_HASH, }; - + const transactionReceipt: Partial = { logs: [ { @@ -517,11 +448,7 @@ describe('PaymentService', () => { transactionIndex: 123, removed: false, address: MOCK_ADDRESS, - topics: [ - '0x123', - '0x0000000000000000000000000123', - MOCK_ADDRESS, - ], + topics: ['0x123', '0x0000000000000000000000000123', MOCK_ADDRESS], transactionHash: MOCK_TRANSACTION_HASH, logIndex: 123, }, @@ -529,25 +456,27 @@ describe('PaymentService', () => { transactionHash: MOCK_TRANSACTION_HASH, confirmations: TX_CONFIRMATION_TRESHOLD - 1, }; - - jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue(transactionReceipt); - + + jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue( + transactionReceipt, + ); + await expect( paymentService.createCryptoPayment(userId, dto), ).rejects.toThrowError( ErrorPayment.TransactionHasNotEnoughAmountOfConfirmations, ); }); - + it('should throw a bad request exception if the payment with the same transaction hash already exists', async () => { const userId = 1; const dto = { chainId: ChainId.LOCALHOST, transactionHash: MOCK_TRANSACTION_HASH, }; - + const token = 'hmt'; - + const transactionReceipt: Partial = { logs: [ { @@ -557,11 +486,7 @@ describe('PaymentService', () => { transactionIndex: 123, removed: false, address: MOCK_ADDRESS, - topics: [ - '0x123', - '0x0000000000000000000000000123', - MOCK_ADDRESS, - ], + topics: ['0x123', '0x0000000000000000000000000123', MOCK_ADDRESS], transactionHash: MOCK_TRANSACTION_HASH, logIndex: 123, }, @@ -569,42 +494,56 @@ describe('PaymentService', () => { transactionHash: MOCK_TRANSACTION_HASH, confirmations: TX_CONFIRMATION_TRESHOLD, }; - - jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue(transactionReceipt); - + + jsonRpcProviderMock.getTransactionReceipt.mockResolvedValue( + transactionReceipt, + ); + mockTokenContract.symbol.mockResolvedValue(token); - + findOneMock.mockResolvedValue({} as any); - + await expect( paymentService.createCryptoPayment(userId, dto), ).rejects.toThrowError(ErrorPayment.TransactionHashAlreadyExists); }); }); - describe('getUserBalance', () => { + describe('getUserBalance', () => { it('should return the correct balance for a user', async () => { const userId = 1; const expectedBalance = ethers.utils.parseUnits('20', 'ether'); paymentRepository.find = jest.fn().mockResolvedValue([ - { amount: ethers.utils.parseUnits('50', 'ether'), rate: 1, type: PaymentType.DEPOSIT }, - { amount: ethers.utils.parseUnits('150', 'ether'), rate: 1, type: PaymentType.DEPOSIT }, - { amount: ethers.utils.parseUnits('180', 'ether'), rate: 1, type: PaymentType.WITHDRAWAL }, + { + amount: ethers.utils.parseUnits('50', 'ether'), + rate: 1, + type: PaymentType.DEPOSIT, + }, + { + amount: ethers.utils.parseUnits('150', 'ether'), + rate: 1, + type: PaymentType.DEPOSIT, + }, + { + amount: ethers.utils.parseUnits('180', 'ether'), + rate: 1, + type: PaymentType.WITHDRAWAL, + }, ]); - + const balance = await paymentService.getUserBalance(userId); - + expect(balance).toEqual(expectedBalance); expect(paymentRepository.find).toHaveBeenCalledWith({ userId }); }); - + it('should return 0 balance for a user with no payment entities', async () => { const userId = 1; paymentRepository.find = jest.fn().mockResolvedValue([]); const balance = await paymentService.getUserBalance(userId); - + expect(balance).toEqual(BigNumber.from(0)); expect(paymentRepository.find).toHaveBeenCalledWith({ userId }); }); 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 ea89e261b5..ce8c9bed4b 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 @@ -10,7 +10,6 @@ import Stripe from 'stripe'; import { BigNumber, FixedNumber, ethers, providers } from 'ethers'; import { ErrorPayment } from '../../common/constants/errors'; import { PaymentRepository } from './payment.repository'; -import { CurrencyService } from './currency.service'; import { PaymentCryptoCreateDto, PaymentFiatConfirmDto, @@ -18,21 +17,21 @@ import { } from './payment.dto'; import { Currency, - PaymentFiatMethodType, PaymentSource, PaymentStatus, PaymentType, TokenId, } from '../../common/enums/payment'; import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; -import { networkMap } from '../../common/constants/network'; -import { ConfigNames } from '../../common/config'; +import { ConfigNames, networkMap } from '../../common/config'; import { HMToken, HMToken__factory, } from '@human-protocol/core/typechain-types'; import { Web3Service } from '../web3/web3.service'; import { CoingeckoTokenId } from '../../common/constants/payment'; +import { getRate } from '../../common/utils'; +import { UserEntity } from '../user/user.entity'; @Injectable() export class PaymentService { @@ -42,7 +41,6 @@ export class PaymentService { constructor( private readonly web3Service: Web3Service, private readonly paymentRepository: PaymentRepository, - private readonly currencyService: CurrencyService, private configService: ConfigService, ) { this.stripe = new Stripe( @@ -79,21 +77,16 @@ export class PaymentService { } public async createFiatPayment( - customerId: string, + user: UserEntity, dto: PaymentFiatCreateDto, ): Promise { const { amount, currency } = dto; const params: Stripe.PaymentIntentCreateParams = { - payment_method_types: [PaymentFiatMethodType.CARD], amount: amount * 100, currency: currency, }; - params.confirm = true; - params.customer = customerId; - params.payment_method_options = {}; - const paymentIntent = await this.stripe.paymentIntents.create(params); if (!paymentIntent.client_secret) { @@ -104,14 +97,18 @@ export class PaymentService { throw new NotFoundException(ErrorPayment.ClientSecretDoesNotExist); } + //TODO: save payment intent in database + return paymentIntent.client_secret; } public async confirmFiatPayment( userId: number, - dto: PaymentFiatConfirmDto, + data: PaymentFiatConfirmDto, ): Promise { - const paymentData = await this.getPayment(dto.paymentId); + const paymentData = await this.stripe.paymentIntents.retrieve( + data.paymentId, + ); if (!paymentData) { this.logger.log(ErrorPayment.NotFound, PaymentService.name); @@ -123,14 +120,16 @@ export class PaymentService { throw new BadRequestException(ErrorPayment.NotSuccess); } - await this.savePayment( + const rate = await getRate(Currency.USD, paymentData.currency.toLowerCase()); + + await this.paymentRepository.create({ userId, - PaymentSource.FIAT, - Currency.USD, - paymentData.currency.toLowerCase(), - PaymentType.DEPOSIT, - BigNumber.from(paymentData.amount) - ); + source: PaymentSource.FIAT, + type: PaymentType.DEPOSIT, + amount: BigNumber.from(paymentData.amount).toString(), + currency: paymentData.currency.toLowerCase(), + rate + }) return true; } @@ -141,7 +140,7 @@ export class PaymentService { ): Promise { const provider = new providers.JsonRpcProvider( Object.values(networkMap).find( - (item) => item.network.chainId === dto.chainId, + (item) => item.chainId === dto.chainId, )?.rpcUrl, ); @@ -194,7 +193,7 @@ export class PaymentService { } const paymentEntity = await this.paymentRepository.findOne({ - transactionHash: transaction.transactionHash, + transaction: transaction.transactionHash, }); if (paymentEntity) { @@ -205,62 +204,29 @@ export class PaymentService { throw new BadRequestException(ErrorPayment.TransactionHashAlreadyExists); } - await this.savePayment( - userId, - PaymentSource.CRYPTO, - Currency.USD, - TokenId.HMT, - PaymentType.DEPOSIT, - amount, - transaction.transactionHash - ); - - return true; - } - - public async getPayment( - paymentId: string, - ): Promise | null> { - return this.stripe.paymentIntents.retrieve(paymentId); - } - - public async savePayment( - userId: number, - source: PaymentSource, - currencyFrom: string, - currencyTo: string, - type: PaymentType, - amount: BigNumber, - transactionHash?: string - ): Promise { - const rate = await this.currencyService.getRate( - currencyFrom, - currencyTo, - ); - - const paymentEntity = await this.paymentRepository.create({ + const rate = await getRate(Currency.USD, TokenId.HMT); + + await this.paymentRepository.create({ userId, + source: PaymentSource.CRYPTO, + type: PaymentType.DEPOSIT, amount: amount.toString(), - currency: currencyTo, - source, + currency: TokenId.HMT, rate, - type, - transactionHash, - }); - - if (!paymentEntity) { - this.logger.log(ErrorPayment.NotFound, PaymentService.name); - throw new NotFoundException(ErrorPayment.NotFound); - } + chainId: dto.chainId, + transaction: dto.transactionHash + }) return true; } + + public async getUserBalance(userId: number): Promise { const paymentEntities = await this.paymentRepository.find({ userId }); let finalAmount = BigNumber.from(0); - + paymentEntities.forEach((payment) => { const fixedAmount = FixedNumber.from( ethers.utils.formatUnits(payment.amount, 18), @@ -278,4 +244,4 @@ export class PaymentService { return finalAmount; } -} +} \ No newline at end of file diff --git a/packages/apps/job-launcher/server/src/modules/user/user.controller.ts b/packages/apps/job-launcher/server/src/modules/user/user.controller.ts index da392f4a13..cec89366d9 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.controller.ts @@ -1,24 +1,41 @@ -import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Logger, + Request, + UnprocessableEntityException, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { RolesGuard } from '../../common/guards'; import { UserService } from './user.service'; import { IUserBalance } from '../../common/interfaces'; +import { JwtAuthGuard } from 'src/common/guards'; +import { RequestWithUser } from 'src/common/types'; +import { ErrorUser } from 'src/common/constants/errors'; @ApiBearerAuth() +@UseGuards(JwtAuthGuard) @ApiTags('User') @Controller('/user') export class UserController { - constructor( - private readonly userService: UserService, - ) {} + private readonly logger = new Logger(UserController.name); - @UseGuards(RolesGuard) - @Get('/balance') - public async getBalance(@Request() req: any): Promise { - try { - return this.userService.getBalance(req.user?.id); - } catch (e) { - throw new Error(e); - } + constructor(private readonly userService: UserService) {} + + @Get('/balance') + public async getBalance( + @Request() req: RequestWithUser, + ): Promise { + try { + return this.userService.getBalance(req.user.id); + } catch (e) { + this.logger.log( + e.message, + `${UserController.name} - ${ErrorUser.BalanceCouldNotBeRetreived}`, + ); + throw new UnprocessableEntityException( + ErrorUser.BalanceCouldNotBeRetreived, + ); } + } } diff --git a/packages/apps/job-launcher/server/src/modules/user/user.entity.ts b/packages/apps/job-launcher/server/src/modules/user/user.entity.ts index 669903bfb4..98a165b26a 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, OneToMany } from 'typeorm'; +import { Auth, Column, Entity, OneToMany, OneToOne } from 'typeorm'; import { Exclude } from 'class-transformer'; import { NS } from '../../common/constants'; @@ -7,19 +7,18 @@ import { IUser } from '../../common/interfaces'; import { UserStatus, UserType } from '../../common/enums/user'; import { PaymentEntity } from '../payment/payment.entity'; import { JobEntity } from '../job/job.entity'; +import { TokenEntity } from '../auth/token.entity'; +import { AuthEntity } from '../auth/auth.entity'; -@Entity({ schema: NS, name: 'user' }) +@Entity({ schema: NS, name: 'users' }) export class UserEntity extends BaseEntity implements IUser { @Exclude() - @Column({ type: 'varchar', select: false }) + @Column({ type: 'varchar' }) public password: string; @Column({ type: 'varchar', nullable: true, unique: true }) public email: string; - @Column({ type: 'varchar', nullable: true, unique: true }) - public stripeCustomerId: string; - @Column({ type: 'enum', enum: UserType }) public type: UserType; @@ -29,6 +28,12 @@ export class UserEntity extends BaseEntity implements IUser { }) public status: UserStatus; + @OneToOne(() => AuthEntity) + public auth: AuthEntity; + + @OneToOne(() => TokenEntity) + public token: TokenEntity; + @OneToMany(() => JobEntity, (job) => job.user) public jobs: JobEntity[]; diff --git a/packages/apps/job-launcher/server/src/modules/user/user.module.ts b/packages/apps/job-launcher/server/src/modules/user/user.module.ts index ea757acb77..c061b02b99 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.module.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.module.ts @@ -5,12 +5,15 @@ import { ConfigModule } from '@nestjs/config'; import { UserService } from './user.service'; import { UserEntity } from './user.entity'; import { UserController } from './user.controller'; -import { AuthEntity } from '../auth/auth.entity'; import { UserRepository } from './user.repository'; import { PaymentModule } from '../payment/payment.module'; @Module({ - imports: [TypeOrmModule.forFeature([UserEntity, AuthEntity]), ConfigModule, PaymentModule], + imports: [ + TypeOrmModule.forFeature([UserEntity]), + ConfigModule, + PaymentModule, + ], controllers: [UserController], providers: [Logger, UserService, UserRepository], exports: [UserService], diff --git a/packages/apps/job-launcher/server/src/modules/user/user.repository.ts b/packages/apps/job-launcher/server/src/modules/user/user.repository.ts index 28994be3bc..8c01f92468 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.repository.ts @@ -43,10 +43,6 @@ export class UserRepository { where, ...options, }); - console.log({ - where, - ...options, - }); return userEntity; } 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 ae6982f9e6..7f82f4e4de 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 @@ -13,6 +13,7 @@ import { UserStatus, UserType } from '../../common/enums/user'; import { ethers } from 'ethers'; import { IUserBalance } from '../../common/interfaces'; import { Currency } from '../../common/enums/payment'; +import * as bcrypt from 'bcrypt'; const PASSWORD_SECRET = '$2b$10$EICgM2wYixoJisgqckU9gu'; @@ -87,20 +88,19 @@ describe('UserService', () => { password: 'password123', confirm: 'password123', }; - + const hashedPassword = + '$2b$12$Z02o9/Ay7CT0n99icApZYORH8iJI9VGtl3mju7d0c4SdDDujhSzOa'; const createdUser: Partial = { id: 1, email: dto.email, - password: 'hashedPassword', + password: hashedPassword, }; jest.spyOn(userService, 'checkEmail').mockResolvedValue(undefined); jest .spyOn(userRepository, 'create') .mockResolvedValue(createdUser as UserEntity); - jest - .spyOn(userService, 'createPasswordHash') - .mockReturnValue('hashedPassword'); + jest.spyOn(bcrypt, 'hashSync').mockReturnValue(hashedPassword); const result = await userService.create(dto); @@ -108,9 +108,9 @@ describe('UserService', () => { expect(userRepository.create).toHaveBeenCalledWith({ ...dto, email: dto.email, - password: 'hashedPassword', + password: hashedPassword, type: UserType.REQUESTER, - status: UserStatus.ACTIVE, + status: UserStatus.PENDING, }); expect(result).toBe(createdUser); }); @@ -137,40 +137,32 @@ describe('UserService', () => { }); describe('getByCredentials', () => { - it('should return the user entity if credentials are valid', async () => { - const email = 'test@example.com'; - const password = 'password123'; - - const userEntity: Partial = { - id: 1, - email, - password: 'hashedPassword', - }; + const email = 'test@example.com'; + const password = 'password123'; + const hashedPassword = + '$2b$12$Z02o9/Ay7CT0n99icApZYORH8iJI9VGtl3mju7d0c4SdDDujhSzOa'; + + const userEntity: Partial = { + id: 1, + email, + password: hashedPassword, + }; + it('should return the user entity if credentials are valid', async () => { jest .spyOn(userRepository, 'findOne') .mockResolvedValue(userEntity as UserEntity); - jest - .spyOn(userService, 'createPasswordHash') - .mockReturnValue('hashedPassword'); const result = await userService.getByCredentials(email, password); expect(userRepository.findOne).toHaveBeenCalledWith({ email, - password: 'hashedPassword', }); expect(result).toBe(userEntity); }); it('should throw NotFoundException if credentials are invalid', async () => { - const email = 'test@example.com'; - const password = 'password123'; - jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); - jest - .spyOn(userService, 'createPasswordHash') - .mockReturnValue('hashedPassword'); await expect( userService.getByCredentials(email, password), @@ -178,23 +170,24 @@ describe('UserService', () => { expect(userRepository.findOne).toHaveBeenCalledWith({ email, - password: 'hashedPassword', }); }); }); - describe('getBalance', () => { + describe('getBalance', () => { it('should return the correct balance with currency for a user', async () => { const userId = 1; const expectedBalance: IUserBalance = { amount: 10, // ETH - currency: Currency.USD - } + currency: Currency.USD, + }; + + jest + .spyOn(paymentService, 'getUserBalance') + .mockResolvedValue(ethers.utils.parseUnits('10', 'ether')); - jest.spyOn(paymentService, 'getUserBalance').mockResolvedValue(ethers.utils.parseUnits('10', 'ether')); - const balance = await userService.getBalance(userId); - + expect(balance).toEqual(expectedBalance); expect(paymentService.getUserBalance).toHaveBeenCalledWith(userId); }); diff --git a/packages/apps/job-launcher/server/src/modules/user/user.service.ts b/packages/apps/job-launcher/server/src/modules/user/user.service.ts index 99f16871ee..0ced23aacd 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.service.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.service.ts @@ -14,7 +14,6 @@ import { UserCreateDto, UserUpdateDto } from './user.dto'; import { UserRepository } from './user.repository'; import { ValidatePasswordDto } from '../auth/auth.dto'; import { ErrorUser } from '../../common/constants/errors'; -import { ConfigNames } from '../../common/config'; import { PaymentService } from '../payment/payment.service'; import { ethers } from 'ethers'; import { IUserBalance } from '../../common/interfaces'; @@ -23,7 +22,7 @@ import { Currency } from '../../common/enums/payment'; @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); - + private HASH_ROUNDS = 12; constructor( private userRepository: UserRepository, private readonly configService: ConfigService, @@ -39,12 +38,12 @@ export class UserService { await this.checkEmail(email, 0); - return this.userRepository.create({ + return await this.userRepository.create({ ...rest, email, - password: this.createPasswordHash(password), + password: bcrypt.hashSync(password, this.HASH_ROUNDS), type: UserType.REQUESTER, - status: UserStatus.ACTIVE, + status: UserStatus.PENDING, }); } @@ -54,11 +53,14 @@ export class UserService { ): Promise { const userEntity = await this.userRepository.findOne({ email, - password: this.createPasswordHash(password), }); if (!userEntity) { - throw new NotFoundException(ErrorUser.NotFound); + throw new NotFoundException(ErrorUser.InvalidCredentials); + } + + if (!bcrypt.compareSync(password, userEntity.password)) { + throw new NotFoundException(ErrorUser.InvalidCredentials); } return userEntity; @@ -72,17 +74,10 @@ export class UserService { userEntity: UserEntity, data: ValidatePasswordDto, ): Promise { - userEntity.password = this.createPasswordHash(data.password); + userEntity.password = bcrypt.hashSync(data.password, this.HASH_ROUNDS); return userEntity.save(); } - public createPasswordHash(password: string): string { - const passwordSecret = this.configService.get( - ConfigNames.PASSWORD_SECRET, - )!; - return bcrypt.hashSync(password, passwordSecret); - } - public activate(userEntity: UserEntity): Promise { userEntity.status = UserStatus.ACTIVE; return userEntity.save(); @@ -105,7 +100,7 @@ export class UserService { return { amount: Number(ethers.utils.formatUnits(balance, 'ether')), - currency: Currency.USD - } + currency: Currency.USD, + }; } } diff --git a/packages/apps/job-launcher/server/src/common/test/constants.ts b/packages/apps/job-launcher/server/test/constants.ts similarity index 88% rename from packages/apps/job-launcher/server/src/common/test/constants.ts rename to packages/apps/job-launcher/server/test/constants.ts index d467ccbdc1..347d4f9816 100644 --- a/packages/apps/job-launcher/server/src/common/test/constants.ts +++ b/packages/apps/job-launcher/server/test/constants.ts @@ -22,10 +22,10 @@ export const MOCK_TRANSACTION_HASH = export const MOCK_EMAIL = 'test@example.com'; export const MOCK_PASSWORD = 'password123'; export const MOCK_HASHED_PASSWORD = 'hashedPassword'; -export const MOCK_IP = '127.0.0.1'; export const MOCK_CUSTOMER_ID = 'customer123'; export const MOCK_PAYMENT_ID = 'payment123'; export const MOCK_ACCESS_TOKEN = 'access_token'; export const MOCK_REFRESH_TOKEN = 'refresh_token'; -export const MOCK_EXPIRES_IN = 1000000000000000; -export const MOCK_UID = 'mocked-uuid'; +export const MOCK_ACCESS_TOKEN_HASHED = 'access_token_hashed'; +export const MOCK_REFRESH_TOKEN_HASHED = 'refresh_token_hashed'; +export const MOCK_EXPIRES_IN = 1000000000000000; diff --git a/packages/apps/job-launcher/server/typeorm.config.ts b/packages/apps/job-launcher/server/typeorm.config.ts index b6996f4ce6..af5432de18 100644 --- a/packages/apps/job-launcher/server/typeorm.config.ts +++ b/packages/apps/job-launcher/server/typeorm.config.ts @@ -1,16 +1,17 @@ import { DataSource } from 'typeorm'; -import * as dotenv from "dotenv"; +import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; +import * as dotenv from 'dotenv'; -dotenv.config({ +dotenv.config({ path: process.env.NODE_ENV ? `.env.${process.env.NODE_ENV as string}` - : '.env' + : '.env', }); export default new DataSource({ type: 'postgres', host: process.env.POSTGRES_HOST, - port: Number(process.env.POSTGRES_PORT!), + port: Number(process.env.POSTGRES_PORT), username: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, database: process.env.POSTGRES_DB, @@ -18,5 +19,6 @@ export default new DataSource({ synchronize: false, migrations: ['dist/src/database/migrations/*{.ts,.js}'], migrationsTableName: 'migrations_typeorm', - migrationsRun: true + migrationsRun: true, + namingStrategy: new SnakeNamingStrategy(), }); diff --git a/packages/apps/reputation-oracle/server/src/common/config/env.ts b/packages/apps/reputation-oracle/server/src/common/config/env.ts index 935770230b..305f32e089 100644 --- a/packages/apps/reputation-oracle/server/src/common/config/env.ts +++ b/packages/apps/reputation-oracle/server/src/common/config/env.ts @@ -49,7 +49,7 @@ export const envValidator = Joi.object({ S3_PORT: Joi.string().default(9000), S3_ACCESS_KEY: Joi.string().required(), S3_SECRET_KEY: Joi.string().required(), - S3_BACKET: Joi.string().default('launcher'), + S3_BUCKET: Joi.string().default('launcher'), S3_USE_SSL: Joi.string().default(false), // Reputation Level REPUTATION_LEVEL_LOW: Joi.number().default(300), diff --git a/yarn.lock b/yarn.lock index 9c5247dfb8..3f74b40de4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19183,7 +19183,7 @@ typeorm-naming-strategies@^4.1.0: resolved "https://registry.yarnpkg.com/typeorm-naming-strategies/-/typeorm-naming-strategies-4.1.0.tgz#1ec6eb296c8d7b69bb06764d5b9083ff80e814a9" integrity sha512-vPekJXzZOTZrdDvTl1YoM+w+sUIfQHG4kZTpbFYoTsufyv9NIBRe4Q+PdzhEAFA2std3D9LZHEb1EjE9zhRpiQ== -typeorm@^0.3.16: +typeorm@^0.3.16, typeorm@^0.3.17: version "0.3.17" resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.17.tgz#a73c121a52e4fbe419b596b244777be4e4b57949" integrity sha512-UDjUEwIQalO9tWw9O2A4GU+sT3oyoUXheHJy4ft+RFdnRdQctdQ34L9SqE2p7LdwzafHx1maxT+bqXON+Qnmig==