Repository: anilahir/nestjs-authentication-and-authorization Branch: main Commit: d67cb13bbbe6 Files: 69 Total size: 67.1 KB Directory structure: gitextract_hglp_yas/ ├── .dockerignore ├── .eslintrc.js ├── .github/ │ ├── actions/ │ │ └── setvars/ │ │ └── action.yml │ ├── dependabot.yml │ ├── variables/ │ │ └── myvars.env │ └── workflows/ │ ├── ci.yml │ └── dependabot-auto-merge.yml ├── .gitignore ├── .husky/ │ ├── pre-commit │ └── pre-push ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc ├── .swcrc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose-test.yml ├── docker-compose.yml ├── nest-cli.json ├── package.json ├── src/ │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth/ │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ ├── bcrypt.service.ts │ │ ├── dto/ │ │ │ ├── sign-in.dto.ts │ │ │ └── sign-up.dto.ts │ │ └── guards/ │ │ └── jwt-auth.guard.ts │ ├── common/ │ │ ├── config/ │ │ │ ├── app.config.ts │ │ │ ├── database.config.ts │ │ │ ├── jwt.config.ts │ │ │ ├── redis.config.ts │ │ │ └── swagger.config.ts │ │ ├── constants/ │ │ │ └── index.ts │ │ ├── decorators/ │ │ │ ├── active-user.decorator.ts │ │ │ ├── match.decorator.ts │ │ │ └── public.decorator.ts │ │ ├── enums/ │ │ │ ├── environment.enum.ts │ │ │ └── error-codes.enum.ts │ │ ├── interceptors/ │ │ │ └── transform.interceptor.ts │ │ ├── interfaces/ │ │ │ └── active-user-data.interface.ts │ │ └── validation/ │ │ └── env.validation.ts │ ├── database/ │ │ └── database.module.ts │ ├── main.ts │ ├── metadata.ts │ ├── redis/ │ │ ├── redis.constants.ts │ │ ├── redis.module.ts │ │ └── redis.service.ts │ ├── swagger.ts │ └── users/ │ ├── entities/ │ │ └── user.entity.ts │ ├── users.controller.ts │ ├── users.module.ts │ └── users.service.ts ├── test/ │ ├── e2e/ │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ ├── factories/ │ │ ├── app.factory.ts │ │ └── user.factory.ts │ └── unit/ │ ├── app.service.unit-spec.ts │ ├── auth/ │ │ ├── auth.service.unit-spec.ts │ │ ├── bcrypt.service.unit-spec.ts │ │ └── guards/ │ │ └── jwt-auth.guard.unit-spec.ts │ ├── jest-unit.json │ ├── redis/ │ │ └── redis.service.unit-spec.ts │ └── users/ │ └── users.service.unit-spec.ts ├── tsconfig.build.json └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Versioning and metadata .vscode .git .gitignore .dockerignore # Build dependencies dist node_modules coverage # Environment (contains sensitive data) .env # Files not required for production Dockerfile README.md ================================================ FILE: .eslintrc.js ================================================ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { project: 'tsconfig.json', tsconfigRootDir: __dirname, sourceType: 'module', }, plugins: ['@typescript-eslint/eslint-plugin'], extends: [ 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', ], root: true, env: { node: true, jest: true, }, ignorePatterns: ['.eslintrc.js'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': 'off', }, }; ================================================ FILE: .github/actions/setvars/action.yml ================================================ name: 'Set environment variables' description: 'Configures environment variables for a workflow' inputs: varFilePath: description: 'File path to variable file or directory. Defaults to ./.github/variables/* if none specified and runs against each file in that directory.' required: false default: ./.github/variables/* runs: using: "composite" steps: - run: | sed "" ${{ inputs.varFilePath }} >> $GITHUB_ENV shell: bash ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: npm directory: '/' schedule: interval: 'daily' open-pull-requests-limit: 10 labels: - 'dependencies' - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'daily' ================================================ FILE: .github/variables/myvars.env ================================================ NODE_ENV=test PORT=3000 DB_HOST=localhost DB_PORT=3306 DB_USER=admin DB_PASSWORD=test123! DB_NAME=nestjs-auth REDIS_HOST=localhost REDIS_PORT=6379 REDIS_USERNAME= REDIS_PASSWORD= REDIS_DATABASE=1 REDIS_KEY_PREFIX=nestjs-auth JWT_SECRET=your-secret-key JWT_ACCESS_TOKEN_TTL=86400 SWAGGER_SITE_TITLE=The NestJs Authentication API SWAGGER_DOC_TITLE=NestJs Authentication SWAGGER_DOC_DESCRIPTION=The NestJs Authentication API SWAGGER_DOC_VERSION=1.0 ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI Testing on: push: branches: [main] pull_request: types: [opened, synchronize, reopened] jobs: unit-tests: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] test: [nestjs-authentication-and-authorization] steps: - uses: actions/checkout@v6 - name: Use NodeJS ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - name: Setup npm run: npm install -g npm - name: Setup Nodejs with npm caching uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: npm - name: Install dependencies run: npm i - name: Run unit test run: npm run test:unit e2e-test: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] needs: [unit-tests] steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - name: Setup npm run: npm install -g npm - name: Setup Nodejs with npm caching uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: npm - name: Set Environment Variables uses: ./.github/actions/setvars with: varFilePath: ./.github/variables/myvars.env - name: Start Docker-Compose run: docker-compose -f docker-compose-test.yml up -d - name: Install dependencies run: npm i - name: Run tests run: npm run test:e2e - name: Stop Docker-Compose run: docker-compose -f docker-compose-test.yml down ================================================ FILE: .github/workflows/dependabot-auto-merge.yml ================================================ name: Dependabot auto-merge PRs if CI Testing succeeds on: pull_request_target: workflow_run: workflows: ['CI Testing'] types: - completed permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} && ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v3.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge for Dependabot PRs # if: contains(steps.metadata.outputs.dependency-names, 'my-dependency') && steps.metadata.outputs.update-type == 'version-update:semver-patch' run: gh pr merge --auto --rebase --delete-branch "${{ github.event.pull_request.html_url }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # compiled output /dist /node_modules # Logs logs *.log npm-debug.log* pnpm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # OS .DS_Store # Tests /coverage /.nyc_output # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # Environment .env ================================================ FILE: .husky/pre-commit ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx lint-staged ================================================ FILE: .husky/pre-push ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npm run test:unit && npm run test:e2e ================================================ FILE: .lintstagedrc.json ================================================ { "**/*.{js,jsx,ts,tsx}": [ "eslint --fix", "prettier --config ./.prettierrc --write" ], "**/*.{css,scss,md,html,json}": ["prettier --config ./.prettierrc --write"] } ================================================ FILE: .prettierignore ================================================ dist/ node_modules/ ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "trailingComma": "all" } ================================================ FILE: .swcrc ================================================ { "$schema": "https://json.schemastore.org/swcrc", "sourceMaps": true, "jsc": { "parser": { "syntax": "typescript", "decorators": true, "dynamicImport": true }, "transform": { "legacyDecorator": true, "decoratorMetadata": true }, "target": "es2017", "keepClassNames": true, "baseUrl": "./" }, "module": { "type": "commonjs", "strictMode": true } } ================================================ FILE: Dockerfile ================================================ ################### # BUILD FOR LOCAL DEVELOPMENT ################### FROM node:18-alpine As development # Create app directory WORKDIR /usr/src/app # Copy application dependency manifests to the container image. # A wildcard is used to ensure copying both package.json AND package-lock.json (when available). # Copying this first prevents re-running npm install on every code change. COPY --chown=node:node package*.json ./ # Install app dependencies using the `npm ci` command instead of `npm install` RUN npm ci # Bundle app source COPY --chown=node:node . . # Use the node user from the image (instead of the root user) USER node ################### # BUILD FOR PRODUCTION ################### FROM node:18-alpine As build WORKDIR /usr/src/app COPY --chown=node:node package*.json ./ # In order to run `npm run build` we need access to the Nest CLI. # The Nest CLI is a dev dependency, # In the previous development stage we ran `npm ci` which installed all dependencies. # So we can copy over the node_modules directory from the development image into this build image. COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules COPY --chown=node:node . . # Run the build command which creates the production bundle RUN npm run build # Set NODE_ENV environment variable ENV NODE_ENV production # Running `npm ci` removes the existing node_modules directory. # Passing in --only=production ensures that only the production dependencies are installed. # This ensures that the node_modules directory is as optimized as possible. RUN npm ci --only=production && npm cache clean --force USER node ################### # PRODUCTION ################### FROM node:18-alpine As production # Copy the bundled code from the build stage to the production image COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules COPY --chown=node:node --from=build /usr/src/app/dist ./dist # Start the server using the production build CMD [ "node", "dist/main.js" ] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Anil Ahir Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # NestJS Authentication ![Workflow Test](https://github.com/anilahir/nestjs-authentication-and-authorization/actions/workflows/ci.yml/badge.svg) ![Prettier](https://img.shields.io/badge/Code%20style-prettier-informational?logo=prettier&logoColor=white) [![GPL v3 License](https://img.shields.io/badge/License-GPLv3-green.svg)](./LICENSE) [![HitCount](https://hits.dwyl.com/anilahir/nestjs-authentication-and-authorization.svg)](https://hits.dwyl.com/anilahir/nestjs-authentication-and-authorization) ## Description NestJS Authentication without Passport using Bcrypt, JWT and Redis ## Features 1. Register 2. Login 3. Show profile 4. Logout ## Technologies stack: - JWT - Bcrypt - TypeORM + MySQL - Redis - Docker ## Setup ### 1. Install the required dependencies ```bash $ npm install ``` ### 2. Rename the .env.example filename to .env and set your local variables ```bash $ mv .env.example .env ``` ### 3. Start the application ```bash # development $ npm run start # watch mode $ npm run start:dev # production mode $ npm run start:prod ``` ## Docker for development ```bash # start the application $ npm run docker:up # stop the application $ npm run docker:down ``` ## Swagger documentation - [localhost:3000/docs](http://localhost:3000/docs) ## References - [NestJS Authentication without Passport](https://trilon.io/blog/nestjs-authentication-without-passport) - [NestJS, Redis and Postgres local development with Docker Compose](https://www.tomray.dev/nestjs-docker-compose-postgres) ## Author 👤 **Anil Ahir** - Twitter: [@anilahir220](https://twitter.com/anilahir220) - Github: [@anilahir](https://github.com/anilahir) - LinkedIn: [@anilahir](https://www.linkedin.com/in/anilahir) ## Show your support Give a ⭐️ if this project helped you! ## Related projects Explore more NestJS example projects: [![GraphQL example](https://github-readme-stats.vercel.app/api/pin/?username=anilahir&repo=nestjs-graphql-demo)](https://github.com/anilahir/nestjs-graphql-demo) ## License Release under the terms of [MIT](./LICENSE) ================================================ FILE: docker-compose-test.yml ================================================ services: mysql: image: mysql:8.0 ports: - 3306:3306 environment: MYSQL_RANDOM_ROOT_PASSWORD: 'true' MYSQL_USER: admin MYSQL_PASSWORD: test123! MYSQL_DATABASE: nestjs-auth TZ: 'utc' command: --default-authentication-plugin=mysql_native_password redis: image: redis:alpine ports: - 6379:6379 ================================================ FILE: docker-compose.yml ================================================ services: nestjs-auth-api: container_name: nestjs-auth-api image: nestjs-auth-api restart: unless-stopped build: context: . dockerfile: Dockerfile target: development # Only will build development stage from our dockerfile volumes: - ./:/usr/src/app ports: - ${PORT}:${PORT} networks: - nestjs-auth-intranet env_file: - .env # Available inside container not in compose file environment: - DB_HOST=nestjs-auth-mysql - REDIS_HOST=nestjs-auth-redis depends_on: nestjs-auth-mysql: condition: service_healthy nestjs-auth-redis: condition: service_healthy command: npm run start:dev # Run in development mode nestjs-auth-mysql: container_name: nestjs-auth-mysql image: mysql:8.0 restart: unless-stopped volumes: - mysql:/var/lib/mysql ports: - 3307:${DB_PORT} networks: - nestjs-auth-intranet environment: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} MYSQL_DATABASE: ${DB_NAME} MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} TZ: 'utc' command: --default-authentication-plugin=mysql_native_password healthcheck: test: ['CMD', 'mysqladmin', '-u${DB_USER}', '-p${DB_PASSWORD}', 'ping'] interval: 5s retries: 3 timeout: 3s nestjs-auth-redis: container_name: nestjs-auth-redis image: redis:alpine restart: unless-stopped volumes: - redis:/data ports: - 6380:${REDIS_PORT} networks: - nestjs-auth-intranet healthcheck: test: ['CMD', 'redis-cli', 'ping'] interval: 5s retries: 3 timeout: 3s volumes: mysql: name: nestjs-auth-mysql redis: name: nestjs-auth-redis networks: nestjs-auth-intranet: name: nestjs-auth-intranet driver: bridge ================================================ FILE: nest-cli.json ================================================ { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { "plugins": [ { "name": "@nestjs/swagger", "options": { "dtoFileNameSuffix": [".entity.ts", ".dto.ts"], "controllerFileNameSuffix": [".controller.ts"], "classValidatorShim": true, "dtoKeyOfComment": "description", "controllerKeyOfComment": "description", "introspectComments": true } } ], "builder": "swc", "typeCheck": true } } ================================================ FILE: package.json ================================================ { "name": "nestjs-authentication-and-authorization", "version": "0.0.1", "description": "NestJS authentication and authorization using Bcrypt, JWT & Redis", "author": "Anil Ahir", "private": false, "license": "MIT", "scripts": { "prebuild": "rimraf dist", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test:watch": "jest --config ./test/unit/jest-unit.json --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "NODE_ENV=test jest --config ./test/e2e/jest-e2e.json --runInBand --detectOpenHandles", "test:unit": "NODE_ENV=test jest --config ./test/unit/jest-unit.json --runInBand", "prepare": "husky install", "docker:up": "docker-compose up -d -V --build", "docker:down": "docker-compose down" }, "dependencies": { "@nestjs/common": "^11.1.19", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.19", "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.1.19", "@nestjs/swagger": "^11.4.1", "@nestjs/typeorm": "^11.0.1", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "ioredis": "^5.10.1", "mysql2": "^3.22.2", "reflect-metadata": "^0.2.2", "rimraf": "^6.1.3", "rxjs": "^7.8.2", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.28" }, "devDependencies": { "@golevelup/ts-jest": "^3.0.0", "@nestjs/cli": "^11.0.21", "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", "@swc/cli": "^0.8.1", "@swc/core": "^1.15.30", "@swc/jest": "^0.2.39", "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.6", "@types/jest": "30.0.0", "@types/node": "^25.6.0", "@types/supertest": "^7.2.0", "@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/parser": "^8.59.0", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "husky": "^9.1.7", "jest": "^30.3.0", "lint-staged": "^16.4.0", "prettier": "^3.8.3", "source-map-support": "^0.5.21", "supertest": "^7.2.2", "ts-jest": "29.4.9", "ts-loader": "^9.5.7", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^6.0.3" }, "engines": { "node": ">= 18" }, "jest": { "moduleFileExtensions": [ "js", "json", "ts" ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s?$": [ "@swc/jest" ] }, "collectCoverageFrom": [ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", "testEnvironment": "node" } } ================================================ FILE: src/app.controller.ts ================================================ import { Controller, Get } from '@nestjs/common'; import { ApiOkResponse } from '@nestjs/swagger'; import { AppService } from './app.service'; import { Public } from './common/decorators/public.decorator'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @ApiOkResponse({ description: "Returns 'Hello World'" }) @Public() @Get() getHello(): string { return this.appService.getHello(); } } ================================================ FILE: src/app.module.ts ================================================ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import appConfig from './common/config/app.config'; import databaseConfig from './common/config/database.config'; import jwtConfig from './common/config/jwt.config'; import { validate } from './common/validation/env.validation'; import { DatabaseModule } from './database/database.module'; import { UsersModule } from './users/users.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import redisConfig from './common/config/redis.config'; import { RedisModule } from './redis/redis.module'; import swaggerConfig from './common/config/swagger.config'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [appConfig, jwtConfig, databaseConfig, redisConfig, swaggerConfig], validate, }), DatabaseModule, RedisModule, AuthModule, UsersModule, ], controllers: [AppController], providers: [ AppService, { provide: APP_GUARD, useClass: JwtAuthGuard, }, ], }) export class AppModule {} ================================================ FILE: src/app.service.ts ================================================ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHello(): string { return 'Hello World!'; } } ================================================ FILE: src/auth/auth.controller.ts ================================================ import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBearerAuth, ApiConflictResponse, ApiCreatedResponse, ApiOkResponse, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { ActiveUser } from '../common/decorators/active-user.decorator'; import { Public } from '../common/decorators/public.decorator'; import { AuthService } from './auth.service'; import { SignInDto } from './dto/sign-in.dto'; import { SignUpDto } from './dto/sign-up.dto'; @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @ApiConflictResponse({ description: 'User already exists', }) @ApiBadRequestResponse({ description: 'Return errors for invalid sign up fields', }) @ApiCreatedResponse({ description: 'User has been successfully signed up', }) @Public() @Post('sign-up') signUp(@Body() signUpDto: SignUpDto): Promise { return this.authService.signUp(signUpDto); } @ApiBadRequestResponse({ description: 'Return errors for invalid sign in fields', }) @ApiOkResponse({ description: 'User has been successfully signed in' }) @HttpCode(HttpStatus.OK) @Public() @Post('sign-in') signIn(@Body() signInDto: SignInDto): Promise<{ accessToken: string }> { return this.authService.signIn(signInDto); } @ApiUnauthorizedResponse({ description: 'Unauthorized' }) @ApiOkResponse({ description: 'User has been successfully signed out' }) @ApiBearerAuth() @HttpCode(HttpStatus.OK) @Post('sign-out') signOut(@ActiveUser('id') userId: string): Promise { return this.authService.signOut(userId); } } ================================================ FILE: src/auth/auth.module.ts ================================================ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { BcryptService } from './bcrypt.service'; import { User } from '../users/entities/user.entity'; import jwtConfig from '../common/config/jwt.config'; @Module({ imports: [ TypeOrmModule.forFeature([User]), JwtModule.registerAsync(jwtConfig.asProvider()), ], controllers: [AuthController], providers: [AuthService, BcryptService], exports: [JwtModule], }) export class AuthModule {} ================================================ FILE: src/auth/auth.service.ts ================================================ import { BadRequestException, ConflictException, Inject, Injectable, } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; import jwtConfig from '../common/config/jwt.config'; import { MysqlErrorCode } from '../common/enums/error-codes.enum'; import { ActiveUserData } from '../common/interfaces/active-user-data.interface'; import { RedisService } from '../redis/redis.service'; import { User } from '../users/entities/user.entity'; import { BcryptService } from './bcrypt.service'; import { SignInDto } from './dto/sign-in.dto'; import { SignUpDto } from './dto/sign-up.dto'; @Injectable() export class AuthService { constructor( @Inject(jwtConfig.KEY) private readonly jwtConfiguration: ConfigType, private readonly bcryptService: BcryptService, private readonly jwtService: JwtService, @InjectRepository(User) private readonly userRepository: Repository, private readonly redisService: RedisService, ) {} async signUp(signUpDto: SignUpDto): Promise { const { email, password } = signUpDto; try { const user = new User(); user.email = email; user.password = await this.bcryptService.hash(password); await this.userRepository.save(user); } catch (error) { if (error.code === MysqlErrorCode.UniqueViolation) { throw new ConflictException(`User [${email}] already exist`); } throw error; } } async signIn(signInDto: SignInDto): Promise<{ accessToken: string }> { const { email, password } = signInDto; const user = await this.userRepository.findOne({ where: { email, }, }); if (!user) { throw new BadRequestException('Invalid email'); } const isPasswordMatch = await this.bcryptService.compare( password, user.password, ); if (!isPasswordMatch) { throw new BadRequestException('Invalid password'); } return await this.generateAccessToken(user); } async signOut(userId: string): Promise { this.redisService.delete(`user-${userId}`); } async generateAccessToken( user: Partial, ): Promise<{ accessToken: string }> { const tokenId = randomUUID(); await this.redisService.insert(`user-${user.id}`, tokenId); const accessToken = await this.jwtService.signAsync( { id: user.id, email: user.email, tokenId, } as ActiveUserData, { secret: this.jwtConfiguration.secret, expiresIn: this.jwtConfiguration.accessTokenTtl, }, ); return { accessToken }; } } ================================================ FILE: src/auth/bcrypt.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { compare, genSalt, hash } from 'bcrypt'; @Injectable() export class BcryptService { async hash(data: string): Promise { const salt = await genSalt(); return hash(data, salt); } async compare(data: string, encrypted: string): Promise { return compare(data, encrypted); } } ================================================ FILE: src/auth/dto/sign-in.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsNotEmpty, Matches, MaxLength, MinLength, } from 'class-validator'; export class SignInDto { @ApiProperty({ description: 'Email of user', example: 'atest@email.com', }) @IsEmail() @MaxLength(255) @IsNotEmpty() readonly email: string; @ApiProperty({ description: 'Password of user', example: 'Pass#123', }) @MinLength(8, { message: 'password too short', }) @MaxLength(20, { message: 'password too long', }) @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, { message: 'password too weak', }) @IsNotEmpty() readonly password: string; } ================================================ FILE: src/auth/dto/sign-up.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsNotEmpty, Matches, MaxLength, MinLength, } from 'class-validator'; import { Match } from '../../common/decorators/match.decorator'; export class SignUpDto { @ApiProperty({ example: 'atest@email.com', description: 'Email of user', }) @IsEmail() @MaxLength(255) @IsNotEmpty() readonly email: string; @ApiProperty({ description: 'Password of user', example: 'Pass#123', }) @MinLength(8, { message: 'password too short', }) @MaxLength(20, { message: 'password too long', }) @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, { message: 'password too weak', }) @IsNotEmpty() readonly password: string; @ApiProperty({ description: 'Repeat same value as in password field', example: 'Pass#123', }) @Match('password') @IsNotEmpty() readonly passwordConfirm: string; } ================================================ FILE: src/auth/guards/jwt-auth.guard.ts ================================================ import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException, } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; import jwtConfig from '../../common/config/jwt.config'; import { REQUEST_USER_KEY } from '../../common/constants'; import { ActiveUserData } from '../../common/interfaces/active-user-data.interface'; import { RedisService } from '../../redis/redis.service'; @Injectable() export class JwtAuthGuard implements CanActivate { constructor( @Inject(jwtConfig.KEY) private readonly jwtConfiguration: ConfigType, private readonly jwtService: JwtService, private readonly redisService: RedisService, private reflector: Reflector, ) {} async canActivate(context: ExecutionContext): Promise { const isPublic = this.reflector.getAllAndOverride('isPublic', [ context.getHandler(), context.getClass(), ]); if (isPublic) { return true; } const request = context.switchToHttp().getRequest(); const token = this.getToken(request); if (!token) { throw new UnauthorizedException('Authorization token is required'); } try { const payload = await this.jwtService.verifyAsync( token, this.jwtConfiguration, ); const isValidToken = await this.redisService.validate( `user-${payload.id}`, payload.tokenId, ); if (!isValidToken) { throw new UnauthorizedException('Authorization token is not valid'); } request[REQUEST_USER_KEY] = payload; } catch (error) { throw new UnauthorizedException(error.message); } return true; } private getToken(request: Request) { const [_, token] = request.headers.authorization?.split(' ') ?? []; return token; } } ================================================ FILE: src/common/config/app.config.ts ================================================ import { registerAs } from '@nestjs/config'; export default registerAs('app', () => { return { nodeEnv: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT, 10) || 3000, }; }); ================================================ FILE: src/common/config/database.config.ts ================================================ import { registerAs } from '@nestjs/config'; export default registerAs('database', () => { return { type: 'mysql', host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT, 10) || 3306, username: process.env.DB_USER, password: process.env.DB_PASSWORD, name: process.env.DB_NAME, }; }); ================================================ FILE: src/common/config/jwt.config.ts ================================================ import { registerAs } from '@nestjs/config'; export default registerAs('jwt', () => { return { secret: process.env.JWT_SECRET, accessTokenTtl: process.env.JWT_ACCESS_TOKEN_TTL, }; }); ================================================ FILE: src/common/config/redis.config.ts ================================================ import { registerAs } from '@nestjs/config'; export default registerAs('redis', () => { return { host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT, 10) || 6379, db: parseInt(process.env.REDIS_DATABASE, 10), keyPrefix: process.env.REDIS_KEY_PREFIX + ':', ...(process.env.REDIS_USERNAME && { username: process.env.REDIS_USERNAME, }), ...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD, }), }; }); ================================================ FILE: src/common/config/swagger.config.ts ================================================ import { registerAs } from '@nestjs/config'; export default registerAs('swagger', () => { return { siteTitle: process.env.SWAGGER_SITE_TITLE, docTitle: process.env.SWAGGER_DOC_TITLE, docDescription: process.env.SWAGGER_DOC_DESCRIPTION, docVersion: process.env.SWAGGER_DOC_VERSION, }; }); ================================================ FILE: src/common/constants/index.ts ================================================ export const REQUEST_USER_KEY = 'user'; ================================================ FILE: src/common/decorators/active-user.decorator.ts ================================================ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { REQUEST_USER_KEY } from '../constants'; import { ActiveUserData } from '../interfaces/active-user-data.interface'; export const ActiveUser = createParamDecorator( (field: keyof ActiveUserData | undefined, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user: ActiveUserData | undefined = request[REQUEST_USER_KEY]; return field ? user?.[field] : user; }, ); ================================================ FILE: src/common/decorators/match.decorator.ts ================================================ import { registerDecorator, ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; export function Match(property: string, validationOptions?: ValidationOptions) { return (object: any, propertyName: string) => { registerDecorator({ target: object.constructor, propertyName, options: validationOptions, constraints: [property], validator: MatchConstraint, }); }; } @ValidatorConstraint({ name: 'Match' }) export class MatchConstraint implements ValidatorConstraintInterface { validate(value: any, args: ValidationArguments) { const [relatedPropertyName] = args.constraints; const relatedValue = (args.object as any)[relatedPropertyName]; return value === relatedValue; } defaultMessage(args: ValidationArguments) { return args.property + ' must match ' + args.constraints[0]; } } ================================================ FILE: src/common/decorators/public.decorator.ts ================================================ import { SetMetadata, CustomDecorator } from '@nestjs/common'; export const Public = (): CustomDecorator => SetMetadata('isPublic', true); ================================================ FILE: src/common/enums/environment.enum.ts ================================================ export enum Environment { Development = 'development', Production = 'production', Test = 'test', } ================================================ FILE: src/common/enums/error-codes.enum.ts ================================================ export enum MysqlErrorCode { UniqueViolation = 'ER_DUP_ENTRY', } ================================================ FILE: src/common/interceptors/transform.interceptor.ts ================================================ import { CallHandler, ExecutionContext, Injectable, NestInterceptor, } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import { map, Observable } from 'rxjs'; @Injectable() export class TransformInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe(map((data) => instanceToPlain(data))); } } ================================================ FILE: src/common/interfaces/active-user-data.interface.ts ================================================ export interface ActiveUserData { id: string; email: string; tokenId: string; } ================================================ FILE: src/common/validation/env.validation.ts ================================================ import { plainToInstance } from 'class-transformer'; import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, validateSync, } from 'class-validator'; import { Environment } from '../enums/environment.enum'; class EnvironmentVariables { @IsEnum(Environment) NODE_ENV: Environment; @IsNumber() @IsNotEmpty() PORT: number; @IsString() @IsNotEmpty() DB_HOST: string; @IsNumber() @IsNotEmpty() DB_PORT: number; @IsString() @IsNotEmpty() DB_USER: string; @IsString() @IsNotEmpty() DB_PASSWORD: string; @IsString() @IsNotEmpty() DB_NAME: string; @IsString() @IsNotEmpty() REDIS_HOST: string; @IsNumber() @IsNotEmpty() REDIS_PORT: number; @IsString() @IsOptional() REDIS_USERNAME: string; @IsString() @IsOptional() REDIS_PASSWORD: string; @IsNumber() @IsNotEmpty() REDIS_DATABASE: number; @IsString() @IsNotEmpty() REDIS_KEY_PREFIX: string; @IsString() @IsNotEmpty() JWT_SECRET: string; @IsNotEmpty() @IsNumber() JWT_ACCESS_TOKEN_TTL: number; @IsString() @IsNotEmpty() SWAGGER_SITE_TITLE: string; @IsString() @IsNotEmpty() SWAGGER_DOC_TITLE: string; @IsString() @IsNotEmpty() SWAGGER_DOC_DESCRIPTION: string; @IsString() @IsNotEmpty() SWAGGER_DOC_VERSION: string; } export function validate(config: Record) { const validatedConfig = plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true, }); const errors = validateSync(validatedConfig, { skipMissingProperties: false, }); let errorMessage = errors .map((message) => message.constraints[Object.keys(message.constraints)[0]]) .join('\n'); const COLOR = { reset: '\x1b[0m', bright: '\x1b[1m', fgRed: '\x1b[31m', }; errorMessage = `${COLOR.fgRed}${COLOR.bright}${errorMessage}${COLOR.reset}`; if (errors.length > 0) { throw new Error(errorMessage); } return validatedConfig; } ================================================ FILE: src/database/database.module.ts ================================================ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ type: 'mysql', host: configService.get('database.host'), port: configService.get('database.port'), username: configService.get('database.username'), password: configService.get('database.password'), database: configService.get('database.name'), autoLoadEntities: true, synchronize: true, logging: false, }), }), ], }) export class DatabaseModule {} ================================================ FILE: src/main.ts ================================================ import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { setupSwagger } from './swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); setupSwagger(app); app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidUnknownValues: true, stopAtFirstError: true, validateCustomDecorators: true, }), ); const configService = app.get(ConfigService); const port = configService.get('PORT'); await app.listen(port, () => { console.log(`Application running at ${port}`); }); } bootstrap(); ================================================ FILE: src/metadata.ts ================================================ /* eslint-disable */ export default async () => { const t = { ['./users/entities/user.entity']: await import( './users/entities/user.entity' ), }; return { '@nestjs/swagger': { models: [ [ import('./users/entities/user.entity'), { User: { id: { required: true, type: () => String }, email: { required: true, type: () => String }, createdAt: { required: true, type: () => Date }, updatedAt: { required: true, type: () => Date }, }, }, ], [ import('./auth/dto/sign-in.dto'), { SignInDto: { email: { required: true, type: () => String, maxLength: 255 }, password: { required: true, type: () => String, minLength: 8, maxLength: 20, pattern: '/((?=.*\\d)|(?=.*\\W+))(?![.\\n])(?=.*[A-Z])(?=.*[a-z]).*$/', }, }, }, ], [ import('./auth/dto/sign-up.dto'), { SignUpDto: { email: { required: true, type: () => String, maxLength: 255 }, password: { required: true, type: () => String, minLength: 8, maxLength: 20, pattern: '/((?=.*\\d)|(?=.*\\W+))(?![.\\n])(?=.*[A-Z])(?=.*[a-z]).*$/', }, passwordConfirm: { required: true, type: () => String }, }, }, ], ], controllers: [ [ import('./app.controller'), { AppController: { getHello: { type: String } } }, ], [ import('./auth/auth.controller'), { AuthController: { signUp: {}, signIn: {}, signOut: {} } }, ], [ import('./users/users.controller'), { UsersController: { getMe: { type: t['./users/entities/user.entity'].User }, }, }, ], ], }, }; }; ================================================ FILE: src/redis/redis.constants.ts ================================================ export const IORedisKey = 'IORedis'; ================================================ FILE: src/redis/redis.module.ts ================================================ import { Global, Module, OnApplicationShutdown, Scope } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ModuleRef } from '@nestjs/core'; import { Redis } from 'ioredis'; import { IORedisKey } from './redis.constants'; import { RedisService } from './redis.service'; @Global() @Module({ imports: [ConfigModule], providers: [ { provide: IORedisKey, useFactory: async (configService: ConfigService) => { return new Redis(configService.get('redis')); }, inject: [ConfigService], }, RedisService, ], exports: [RedisService], }) export class RedisModule implements OnApplicationShutdown { constructor(private readonly moduleRef: ModuleRef) {} async onApplicationShutdown(signal?: string): Promise { return new Promise((resolve) => { const redis = this.moduleRef.get(IORedisKey); redis.quit(); redis.on('end', () => { resolve(); }); }); } } ================================================ FILE: src/redis/redis.service.ts ================================================ import { Inject, Injectable } from '@nestjs/common'; import { Redis } from 'ioredis'; import { IORedisKey } from './redis.constants'; @Injectable() export class RedisService { constructor( @Inject(IORedisKey) private readonly redisClient: Redis, ) {} async getKeys(pattern?: string): Promise { return await this.redisClient.keys(pattern); } async insert(key: string, value: string | number): Promise { await this.redisClient.set(key, value); } async get(key: string): Promise { return this.redisClient.get(key); } async delete(key: string): Promise { await this.redisClient.del(key); } async validate(key: string, value: string): Promise { const storedValue = await this.redisClient.get(key); return storedValue === value; } } ================================================ FILE: src/swagger.ts ================================================ import { INestApplication } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerCustomOptions, SwaggerDocumentOptions, SwaggerModule, } from '@nestjs/swagger'; import metadata from './metadata'; export const setupSwagger = async (app: INestApplication) => { const configService = app.get(ConfigService); const swaggerConfig = configService.get('swagger'); const config = new DocumentBuilder() .setTitle(swaggerConfig.docTitle) .setDescription(swaggerConfig.docDescription) .setVersion(swaggerConfig.docVersion) .addBearerAuth() .build(); const options: SwaggerDocumentOptions = { operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, }; await SwaggerModule.loadPluginMetadata(metadata); const document = SwaggerModule.createDocument(app, config, options); const customOptions: SwaggerCustomOptions = { swaggerOptions: { persistAuthorization: true, // defaultModelsExpandDepth: -1, }, customSiteTitle: swaggerConfig.siteTitle, }; SwaggerModule.setup('docs', app, document, customOptions); }; ================================================ FILE: src/users/entities/user.entity.ts ================================================ import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'; import { Exclude } from 'class-transformer'; import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @Entity({ name: 'users', }) export class User { @ApiProperty({ description: 'ID of user', example: '89c018cc-8a77-4dbd-94e1-dbaa710a2a9c', }) @PrimaryGeneratedColumn('uuid') id: string; @ApiProperty({ description: 'Email of user', example: 'atest@email.com' }) @Column({ unique: true }) email: string; @ApiHideProperty() @Column() @Exclude({ toPlainOnly: true }) password: string; @ApiProperty({ description: 'Created date of user' }) @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @ApiProperty({ description: 'Updated date of user' }) @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ================================================ FILE: src/users/users.controller.ts ================================================ import { Controller, Get } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { ActiveUser } from '../common/decorators/active-user.decorator'; import { User } from './entities/user.entity'; import { UsersService } from './users.service'; @ApiTags('users') @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @ApiUnauthorizedResponse({ description: 'Unauthorized' }) @ApiOkResponse({ description: "Get logged in user's details", type: User }) @ApiBearerAuth() @Get('me') async getMe(@ActiveUser('id') userId: string): Promise { return this.usersService.getMe(userId); } } ================================================ FILE: src/users/users.module.ts ================================================ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService], }) export class UsersModule {} ================================================ FILE: src/users/users.service.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly userRepository: Repository, ) {} async getMe(userId: string): Promise { const user = await this.userRepository.findOne({ where: { id: userId, }, }); if (!user) { throw new BadRequestException('User not found'); } return user; } } ================================================ FILE: test/e2e/app.e2e-spec.ts ================================================ import { HttpStatus } from '@nestjs/common'; import * as request from 'supertest'; import { DataSource } from 'typeorm'; import { Server } from 'http'; import { AppFactory } from '../factories/app.factory'; import { AuthService } from '../../src/auth/auth.service'; import { SignUpDto } from '../../src/auth/dto/sign-up.dto'; import { UserFactory } from '../factories/user.factory'; import { SignInDto } from '../../src/auth/dto/sign-in.dto'; describe('App (e2e)', () => { let app: AppFactory; let server: Server; let dataSource: DataSource; let authService: AuthService; beforeAll(async () => { app = await AppFactory.new(); server = app.instance.getHttpServer(); dataSource = app.dbSource; authService = app.instance.get(AuthService); }); afterAll(async () => { await app.close(); }); beforeEach(async () => { await app.cleanupDB(); }); describe('AppModule', () => { describe('GET /', () => { it("should return 'Hello World'", () => { return request(app.instance.getHttpServer()) .get('/') .expect(HttpStatus.OK) .expect('Hello World!'); }); }); }); describe('AuthModule', () => { describe('POST /auth/sign-up', () => { it('should create a new user', async () => { await new Promise((resolve) => setTimeout(resolve, 1)); const signUpDto: SignUpDto = { email: 'atest@email.com', password: 'Pass#123', passwordConfirm: 'Pass#123', }; return request(server) .post('/auth/sign-up') .send(signUpDto) .expect(HttpStatus.CREATED); }); it('should return 400 for invalid sign up fields', async () => { await new Promise((resolve) => setTimeout(resolve, 1)); const signUpDto: SignUpDto = { email: 'invalid-email', password: 'Pass#123', passwordConfirm: 'Pass#123', }; return request(server) .post('/auth/sign-up') .send(signUpDto) .expect(HttpStatus.BAD_REQUEST); }); it('should return 409 if user already exists', async () => { await UserFactory.new(dataSource).create({ email: 'atest@email.com', password: 'Pass#123', }); const signUpDto: SignUpDto = { email: 'atest@email.com', password: 'Pass#123', passwordConfirm: 'Pass#123', }; return request(server) .post('/auth/sign-up') .send(signUpDto) .expect(HttpStatus.CONFLICT); }); }); describe('POST /auth/sign-in', () => { it('should sign in the user and return access token', async () => { const email = 'atest@email.com'; const password = 'Pass#123'; await UserFactory.new(dataSource).create({ email, password, }); const signInDto: SignInDto = { email, password, }; return request(server) .post('/auth/sign-in') .send(signInDto) .expect(HttpStatus.OK) .expect((res) => { expect(res.body).toEqual({ accessToken: expect.any(String) }); }); }); it('should return 400 for invalid sign in fields', async () => { const signInDto: SignInDto = { email: 'atest@email.com', password: '', }; return request(server) .post('/auth/sign-in') .send(signInDto) .expect(HttpStatus.BAD_REQUEST); }); }); describe('POST /auth/sign-out', () => { it('should sign out the user', async () => { const user = await UserFactory.new(dataSource).create({ email: 'atest@email.com', password: 'Pass#123', }); const { accessToken } = await authService.generateAccessToken(user); return request(server) .post('/auth/sign-out') .set('Authorization', `Bearer ${accessToken}`) .expect(HttpStatus.OK); }); it('should return 401 if not authorized', async () => { return request(server) .post('/auth/sign-out') .expect(HttpStatus.UNAUTHORIZED); }); }); }); describe('UsersModule', () => { describe('GET /users/me', () => { it('should return 401 unauthorized when no access token provided', () => { return request(server).get('/users/me').expect(HttpStatus.UNAUTHORIZED); }); it('should return user details when access token provided', async () => { const user = await UserFactory.new(dataSource).create({ email: 'atest@email.com', password: 'Pass#123', }); const { accessToken } = await authService.generateAccessToken(user); return request(server) .get('/users/me') .set('Authorization', `Bearer ${accessToken}`) .expect(HttpStatus.OK) .expect((res) => { expect(res.body).toEqual( expect.objectContaining({ id: user.id, email: user.email, }), ); }); }); }); }); }); ================================================ FILE: test/e2e/jest-e2e.json ================================================ { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "../../", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "modulePaths": ["."] } ================================================ FILE: test/factories/app.factory.ts ================================================ import { INestApplication, Type, ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { DataSource } from 'typeorm'; import { Redis } from 'ioredis'; import { getDataSourceToken } from '@nestjs/typeorm'; import { AppModule } from 'src/app.module'; import { TransformInterceptor } from '../../src/common/interceptors/transform.interceptor'; import { IORedisKey } from '../../src/redis/redis.constants'; export class AppFactory { private constructor( private readonly appInstance: INestApplication, private readonly dataSource: DataSource, private readonly redis: Redis, ) {} get instance() { return this.appInstance; } get dbSource() { return this.dataSource; } static async new() { const module = await Test.createTestingModule({ imports: [AppModule], }).compile(); const app = module.createNestApplication(); app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, forbidUnknownValues: true, stopAtFirstError: true, validateCustomDecorators: true, }), ); await app.init(); const dataSource = module.get( getDataSourceToken() as Type, ); const redis = module.get(IORedisKey); return new AppFactory(app, dataSource, redis); } async close() { await this.appInstance.close(); } async cleanupDB() { await this.redis.flushall(); const tables = this.dataSource.manager.connection.entityMetadatas.map( (entity) => `${entity.tableName}`, ); for (const table of tables) { await this.dataSource.manager.connection.query(`DELETE FROM ${table};`); } } } ================================================ FILE: test/factories/user.factory.ts ================================================ import { DataSource, EntityManager } from 'typeorm'; import * as bcrypt from 'bcrypt'; import { User } from '../../src/users/entities/user.entity'; export class UserFactory { private dataSource: DataSource; static new(dataSource: DataSource) { const factory = new UserFactory(); factory.dataSource = dataSource; return factory; } async create(user: Partial = {}) { const userRepository = this.dataSource.getRepository(User); const salt = await bcrypt.genSalt(); const password = await this.hashPassword(user.password, salt); const payload = { ...user, password, }; return userRepository.save(payload); } private hashPassword(password: string, salt: string) { return bcrypt.hash(password, salt); } } ================================================ FILE: test/unit/app.service.unit-spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { AppService } from '../../src/app.service'; describe('AppService', () => { let service: AppService; beforeEach(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ providers: [AppService], }).compile(); service = moduleRef.get(AppService); }); describe('getHello', () => { it('should return "Hello World!"', () => { const result = service.getHello(); expect(result).toEqual('Hello World!'); }); }); }); ================================================ FILE: test/unit/auth/auth.service.unit-spec.ts ================================================ import { Test } from '@nestjs/testing'; import { ConfigType } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { Repository } from 'typeorm'; import { BadRequestException, ConflictException } from '@nestjs/common'; import { createMock } from '@golevelup/ts-jest'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AuthService } from '../../../src/auth/auth.service'; import { BcryptService } from '../../../src/auth/bcrypt.service'; import { RedisService } from '../../../src/redis/redis.service'; import jwtConfig from '../../../src/common/config/jwt.config'; import { User } from '../../../src/users/entities/user.entity'; import { SignUpDto } from '../../../src/auth/dto/sign-up.dto'; import { MysqlErrorCode } from '../../../src/common/enums/error-codes.enum'; import { ActiveUserData } from '../../../src/common/interfaces/active-user-data.interface'; describe('AuthService', () => { let authService: AuthService; let bcryptService: BcryptService; let jwtService: JwtService; let userRepository: Repository; let redisService: RedisService; let jwtConfiguration: ConfigType; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [ AuthService, { provide: BcryptService, useValue: createMock() }, { provide: JwtService, useValue: createMock() }, { provide: RedisService, useValue: createMock() }, { provide: getRepositoryToken(User), useClass: Repository, }, { provide: jwtConfig.KEY, useValue: jwtConfig.asProvider(), }, ], }).compile(); authService = moduleRef.get(AuthService); bcryptService = moduleRef.get(BcryptService); jwtService = moduleRef.get(JwtService); userRepository = moduleRef.get>(getRepositoryToken(User)); redisService = moduleRef.get(RedisService); jwtConfiguration = moduleRef.get>( jwtConfig.KEY, ); }); describe('signUp', () => { const signUpDto: SignUpDto = { email: 'test@example.com', password: 'password', passwordConfirm: 'password', }; let user: User; beforeEach(() => { user = new User(); user.email = signUpDto.email; user.password = 'hashed_password'; }); it('should create a new user', async () => { const saveSpy = jest .spyOn(userRepository, 'save') .mockResolvedValueOnce(user); const hashSpy = jest .spyOn(bcryptService, 'hash') .mockResolvedValueOnce('hashed_password'); await authService.signUp(signUpDto); expect(hashSpy).toHaveBeenCalledWith(signUpDto.password); expect(saveSpy).toHaveBeenCalledWith(user); }); it('should throw a ConflictException if a user with the same email already exists', async () => { const saveSpy = jest .spyOn(userRepository, 'save') .mockRejectedValueOnce({ code: MysqlErrorCode.UniqueViolation }); await expect(authService.signUp(signUpDto)).rejects.toThrowError( new ConflictException(`User [${signUpDto.email}] already exist`), ); expect(saveSpy).toHaveBeenCalledWith(user); }); it('should rethrow any other error that occurs during signup', async () => { const saveSpy = jest .spyOn(userRepository, 'save') .mockRejectedValueOnce(new Error('Unexpected error')); await expect(authService.signUp(signUpDto)).rejects.toThrowError( new Error('Unexpected error'), ); expect(saveSpy).toHaveBeenCalledWith(user); }); }); describe('signIn', () => { it('should sign in a user and return an access token', async () => { const signInDto = { email: 'johndoe@example.com', password: 'password', }; const user = new User(); user.id = '123'; user.email = signInDto.email; user.password = 'encryptedPassword'; const encryptedPassword = 'encryptedPassword'; const comparedPassword = true; const tokenId = expect.any(String); jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); jest.spyOn(bcryptService, 'compare').mockResolvedValue(comparedPassword); jest .spyOn(authService, 'generateAccessToken') .mockResolvedValue({ accessToken: 'accessToken' }); const result = await authService.signIn(signInDto); expect(result).toEqual({ accessToken: 'accessToken' }); expect(userRepository.findOne).toHaveBeenCalledWith({ where: { email: signInDto.email }, }); expect(bcryptService.compare).toHaveBeenCalledWith( signInDto.password, encryptedPassword, ); }); it('should throw an error when email is invalid', async () => { const signInDto = { email: 'invalid-email', password: 'Pass#123', }; jest.spyOn(userRepository, 'findOne').mockResolvedValue(undefined); await expect(authService.signIn(signInDto)).rejects.toThrow( BadRequestException, ); expect(userRepository.findOne).toHaveBeenCalledWith({ where: { email: signInDto.email }, }); }); it('should throw an error when password is invalid', async () => { const signInDto = { email: 'johndoe@example.com', password: 'password', }; const user = new User(); user.id = '123'; user.email = signInDto.email; user.password = 'encryptedPassword'; jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); jest.spyOn(bcryptService, 'compare').mockResolvedValue(false); await expect(authService.signIn(signInDto)).rejects.toThrow( BadRequestException, ); expect(userRepository.findOne).toHaveBeenCalledWith({ where: { email: signInDto.email }, }); expect(bcryptService.compare).toHaveBeenCalledWith( signInDto.password, user.password, ); }); }); describe('signOut', () => { it('should delete user token from Redis', async () => { const userId = 'test-user-id'; const deleteSpy = jest .spyOn(redisService, 'delete') .mockResolvedValue(undefined); await authService.signOut(userId); expect(deleteSpy).toHaveBeenCalledWith(`user-${userId}`); }); }); describe('generateAccessToken', () => { it('should insert a token into Redis and return an access token', async () => { const user = { id: '123', email: 'test@example.com' }; const tokenId = expect.any(String); const accessToken = 'test-access-token'; (redisService.insert as any).mockResolvedValueOnce(undefined); (jwtService.signAsync as any).mockResolvedValueOnce(accessToken); const result = await authService.generateAccessToken(user); expect(redisService.insert).toHaveBeenCalledWith( `user-${user.id}`, tokenId, ); expect(jwtService.signAsync).toHaveBeenCalledWith( { id: user.id, email: user.email, tokenId } as ActiveUserData, { secret: jwtConfiguration.secret, expiresIn: jwtConfiguration.accessTokenTtl, }, ); expect(result).toEqual({ accessToken }); }); }); }); ================================================ FILE: test/unit/auth/bcrypt.service.unit-spec.ts ================================================ import { BcryptService } from '../../../src/auth/bcrypt.service'; describe('BcryptService', () => { let bcryptService: BcryptService; beforeEach(() => { bcryptService = new BcryptService(); }); describe('hash', () => { it('should return a hashed string', async () => { const data = 'password'; const result = await bcryptService.hash(data); expect(result).not.toBe(data); expect(result).toBeDefined(); expect(result).not.toBeNull(); expect(typeof result).toBe('string'); }); }); describe('compare', () => { it('should return true if the data matches the encrypted string', async () => { const data = 'password'; const encrypted = '$2b$10$iUp/PtR8IlnyKFD5ZjP0X.DUg4.zFec3jr/XoMm9/rIXC0dzaRUmS'; const result = await bcryptService.compare(data, encrypted); expect(result).toBe(true); }); it('should return false if the data does not match the encrypted string', async () => { const data = 'password'; const encrypted = '$2b$10$iUp/PtR8IlnyKFD5ZjP0X.DUg4.zFec3jr/XoMm9/rIXC0dzaRUmS'; const result = await bcryptService.compare('wrong-password', encrypted); expect(result).toBe(false); }); }); }); ================================================ FILE: test/unit/auth/guards/jwt-auth.guard.unit-spec.ts ================================================ import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { JwtService } from '@nestjs/jwt'; import { Reflector } from '@nestjs/core'; import { createMock } from '@golevelup/ts-jest'; import { ConfigModule } from '@nestjs/config'; import { JwtAuthGuard } from '../../../../src/auth/guards/jwt-auth.guard'; import { RedisService } from '../../../../src/redis/redis.service'; import jwtConfig from '../../../../src/common/config/jwt.config'; describe('JwtAuthGuard', () => { let guard: JwtAuthGuard; let redisService: RedisService; let jwtService: JwtService; let reflector: Reflector; let mockExecutionContext: ExecutionContext; beforeEach(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ load: [jwtConfig], }), ], providers: [ JwtAuthGuard, { provide: RedisService, useValue: createMock(), }, { provide: JwtService, useValue: createMock(), }, { provide: Reflector, useValue: createMock(), }, ], }).compile(); guard = moduleRef.get(JwtAuthGuard); redisService = moduleRef.get(RedisService); jwtService = moduleRef.get(JwtService); reflector = moduleRef.get(Reflector); mockExecutionContext = createMock(); }); afterEach(() => { jest.resetAllMocks(); }); it('should be defined', () => { expect(guard).toBeDefined(); }); it('should allow access to public routes', async () => { jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); const result = await guard.canActivate(mockExecutionContext); expect(result).toBe(true); }); it('should not allow access without a token', async () => { jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); jest.spyOn(guard as any, 'getToken').mockReturnValue(undefined); await expect(guard.canActivate(mockExecutionContext)).rejects.toThrowError( new UnauthorizedException('Authorization token is required'), ); }); it('should not allow access with an invalid token', async () => { jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); jest.spyOn(guard as any, 'getToken').mockReturnValue('invalid-token'); jest.spyOn(redisService, 'validate').mockResolvedValue(false); await expect(guard.canActivate(mockExecutionContext)).rejects.toThrowError( new UnauthorizedException('Authorization token is not valid'), ); }); it('should allow access with a valid token', async () => { const validToken = 'valid-token'; jest.spyOn(guard as any, 'getToken').mockReturnValue(validToken); jest.spyOn(redisService, 'validate').mockResolvedValue(true); const result = await guard.canActivate(mockExecutionContext); expect(result).toBe(true); }); }); ================================================ FILE: test/unit/jest-unit.json ================================================ { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "../../", "testEnvironment": "node", "testRegex": ".unit-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "modulePaths": ["."] } ================================================ FILE: test/unit/redis/redis.service.unit-spec.ts ================================================ import { createMock } from '@golevelup/ts-jest'; import { Test } from '@nestjs/testing'; import { Redis } from 'ioredis'; import { IORedisKey } from '../../../src/redis/redis.constants'; import { RedisService } from '../../../src/redis/redis.service'; describe('RedisService', () => { let redisService: RedisService; let redisClient: Redis; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [ RedisService, { provide: IORedisKey, useValue: createMock(), }, ], }).compile(); redisService = moduleRef.get(RedisService); redisClient = moduleRef.get(IORedisKey); }); afterEach(() => { jest.resetAllMocks(); }); it('should call redisClient.keys with the provided pattern when getKeys is called', async () => { const pattern = 'test*'; const expectedKeys = ['test1', 'test2']; (redisClient.keys as any).mockResolvedValue(expectedKeys); const result = await redisService.getKeys(pattern); expect(redisClient.keys).toHaveBeenCalledWith(pattern); expect(result).toEqual(expectedKeys); }); it('should call redisClient.set with the provided key and value when insert is called', async () => { const key = 'test-key'; const value = 'test-value'; await redisService.insert(key, value); expect(redisClient.set).toHaveBeenCalledWith(key, value); }); it('should call redisClient.get with the provided key when get is called', async () => { const key = 'test-key'; const expectedValue = 'test-value'; (redisClient.get as any).mockResolvedValue(expectedValue); const result = await redisService.get(key); expect(redisClient.get).toHaveBeenCalledWith(key); expect(result).toEqual(expectedValue); }); it('should call redisClient.del with the provided key when delete is called', async () => { const key = 'test-key'; await redisService.delete(key); expect(redisClient.del).toHaveBeenCalledWith(key); }); it('should return true if the stored value matches the provided value when validate is called', async () => { const key = 'test-key'; const value = 'test-value'; (redisClient.get as any).mockResolvedValue(value); const result = await redisService.validate(key, value); expect(redisClient.get).toHaveBeenCalledWith(key); expect(result).toBe(true); }); it('should return false if the stored value does not match the provided value when validate is called', async () => { const key = 'test-key'; const value = 'test-value'; (redisClient.get as any).mockResolvedValue('other-value'); const result = await redisService.validate(key, value); expect(redisClient.get).toHaveBeenCalledWith(key); expect(result).toBe(false); }); }); ================================================ FILE: test/unit/users/users.service.unit-spec.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../../../src/users/entities/user.entity'; import { UsersService } from '../../../src/users/users.service'; describe('UsersService', () => { let usersService: UsersService; let userRepository: Repository; beforeEach(async () => { const moduleRef: TestingModule = await Test.createTestingModule({ providers: [ UsersService, { provide: getRepositoryToken(User), useClass: Repository, }, ], }).compile(); usersService = moduleRef.get(UsersService); userRepository = moduleRef.get>(getRepositoryToken(User)); }); describe('getMe', () => { it('should return a user with the specified ID', async () => { const userId = '123'; const user = new User(); user.id = userId; jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); const result = await usersService.getMe(userId); expect(result).toEqual(user); }); it('should throw a BadRequestException if user is not found', async () => { const userId = '123'; jest.spyOn(userRepository, 'findOne').mockResolvedValue(undefined); await expect(usersService.getMe(userId)).rejects.toThrow( BadRequestException, ); }); }); }); ================================================ FILE: tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "module": "commonjs", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "es2017", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": false, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false } }