diff --git a/.env.example b/.env.example index 0c28b07..c428d44 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ DB_PASSWORD=secret DB_DATABASE=cophr DB_TIMEZONE="+08:00" +JWT_ACCESS_SECRET= +JWT_REFRESH_SECRET= + APP_SWAGGER_Title="NestJS-Board API" APP_SWAGGER_Description="This is NestJS-Board API documentation." APP_SWAGGER_Version="0.0.1" diff --git a/package.json b/package.json index a48b886..7521017 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test": "jest --verbose", "test:watch": "jest --watch", "test:cov": "jest --coverage", + "test:one": "jest --testPathPattern", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "typeorm": "npm run build && npx typeorm -d ./dist/config/data-source.js", @@ -36,7 +37,9 @@ "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", + "@nestjs/jwt": "^10.1.0", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^9.0.0", "@nestjs/swagger": "^6.1.4", "@nestjs/typeorm": "^9.0.1", @@ -45,6 +48,9 @@ "class-validator": "^0.14.0", "dotenv": "^16.0.3", "mysql2": "^2.3.3", + "passport": "^0.6.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -61,6 +67,9 @@ "@types/express": "^4.17.13", "@types/jest": "28.1.8", "@types/node": "^16.0.0", + "@types/passport": "^1.0.12", + "@types/passport-jwt": "^3.0.8", + "@types/passport-local": "^1.0.35", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", @@ -95,7 +104,11 @@ "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": [ - "**/*.(t|j)s" + "**/*.(t|j)s", + "!**/swagger/**", + "!**/database/**", + "!**/main.ts", + "!**/config/**" ], "coverageDirectory": "../coverage", "testEnvironment": "node", diff --git a/src/app.module.ts b/src/app.module.ts index 3319375..e4cb624 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; import { AuthModule } from "./auth/auth.module"; +import appConfig from "./config/app.config"; import { dataSourceOptions } from "./config/data-source"; import { validate } from "./config/env.validation"; import { UserModule } from "./user/user.module"; @@ -13,6 +14,8 @@ import { UserModule } from "./user/user.module"; controllers: [AppController], imports: [ ConfigModule.forRoot({ + isGlobal: true, + load: [appConfig], validate, }), TypeOrmModule.forRoot(dataSourceOptions), diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts index 4c43238..81f446f 100644 --- a/src/auth/auth.controller.spec.ts +++ b/src/auth/auth.controller.spec.ts @@ -1,15 +1,24 @@ -import { type HttpException, ConflictException } from "@nestjs/common"; +import { ConflictException } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; import { type TestingModule, Test } from "@nestjs/testing"; import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm"; +import { type Request } from "express"; import { dataSourceJest } from "src/config/data-source"; +import jestConfig from "src/config/jest.config"; import { UserEntity } from "src/user/entities/user.entity"; -import type { CreateUserRespose } from "src/user/resposes/create-user-respose"; +import type { CreateUserResponse } from "src/user/responses/create-user-response"; import { UserService } from "src/user/user.service"; import type { Repository } from "typeorm"; import type { CreateUserDto } from "../user/dto/create-user.dto"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { type JwtUser } from "./jwt/jwt.interface"; +import { JwtAccessStrategy } from "./jwt/jwt-access.strategy"; +import { LocalStrategy } from "./local/local.strategy"; +import { type GenerateTokenResponse } from "./responses/generate-token.response"; describe("AuthController", () => { let authController: AuthController; @@ -19,7 +28,14 @@ describe("AuthController", () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], - imports: [TypeOrmModule.forRoot(dataSourceJest)], + imports: [ + ConfigModule.forRoot({ + load: [jestConfig], + }), + PassportModule, + TypeOrmModule.forRoot(dataSourceJest), + JwtModule.register({}), + ], providers: [ AuthService, UserService, @@ -28,6 +44,8 @@ describe("AuthController", () => { // 使用測試資料庫的 Repository useValue: UserEntity, }, + LocalStrategy, + JwtAccessStrategy, ], }).compile(); @@ -39,43 +57,90 @@ describe("AuthController", () => { }); describe("create", () => { + const createUserDto: CreateUserDto = { + account: "account", + email: "jhon@gmail.com", + name: "displayname", + password: "Password@123", + }; + let mockedAuthService: jest.SpyInstance; + + beforeEach(async () => { + mockedAuthService = jest.spyOn(authService, "register"); + }); + it("應該會創建一個使用者,並返回 201 狀態碼", async () => { - const createUserDto: CreateUserDto = { - account: "account", - email: "jhon@gmail.com", - name: "displayname", - password: "Password@123", - }; - const expectedResponse: CreateUserRespose = { + const expectedResponse: CreateUserResponse = { message: "創建成功", statusCode: 201, }; - jest.spyOn(authService, "register").mockResolvedValue(expectedResponse); + mockedAuthService.mockResolvedValue(expectedResponse); + const result = await authController.register(createUserDto); expect(result).toEqual(expectedResponse); }); it("應該會發生資料使用者重覆,並返回 409 狀態碼", async () => { - const createUserDto1: CreateUserDto = { - account: "account1", - email: "jhon1@gmail.com", - name: "displayname", - password: "Password@123", + await authService.register(createUserDto); + await authService.register(createUserDto).catch(error => { + expect(error).toBeInstanceOf(ConflictException); + expect((error as ConflictException).getResponse()).toEqual({ + error: "Conflict", + message: ["email 已被註冊。", "account 已被註冊。"], + statusCode: 409, + }); + }); + }); + }); + + describe("login and refresh", () => { + const request: Request = { + user: { + id: 1, + } as JwtUser, + } as unknown as Request; + const fakeAccessToken = "mocked_access_token"; + const fakeRefreshToken = "mocked_refresh_token"; + let mockedAuthService: jest.SpyInstance; + + beforeEach(async () => { + jest + .spyOn(authService, "generateAccessToken") + .mockReturnValue(Promise.resolve(fakeAccessToken)); + + jest + .spyOn(authService, "generateRefreshToken") + .mockReturnValue(Promise.resolve(fakeRefreshToken)); + + mockedAuthService = jest.spyOn(authService, "sign"); + }); + + it("should return access, refresh token and 201 http code when account information is correct.", async () => { + const result = await authController.login(request); + + expect(mockedAuthService).toHaveBeenCalledWith(request.user); + const expectedResponse: GenerateTokenResponse = { + accessToken: fakeAccessToken, + refreshToken: fakeRefreshToken, + statusCode: 201, }; - await authService.register(createUserDto1); - await authService - .register(createUserDto1) - .catch((error: HttpException) => { - expect(error).toBeInstanceOf(ConflictException); - expect(error.getResponse()).toEqual({ - error: "Conflict", - message: ["email 已被註冊。", "account 已被註冊。"], - statusCode: 409, - }); - }); + expect(result).toEqual(expectedResponse); + }); + + it("should return access, refresh token and 201 http code when refresh token is correct.", async () => { + const result = await authController.refresh(request); + + expect(mockedAuthService).toHaveBeenCalledWith(request.user); + const expectedResponse: GenerateTokenResponse = { + accessToken: fakeAccessToken, + refreshToken: fakeRefreshToken, + statusCode: 201, + }; + + expect(result).toEqual(expectedResponse); }); }); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d0198fa..1f31684 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,17 +1,29 @@ -import { Body, Controller, Post } from "@nestjs/common"; +import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common"; import { ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, ApiConflictResponse, ApiCreatedResponse, + ApiForbiddenResponse, ApiOperation, ApiTags, + ApiUnauthorizedResponse, } from "@nestjs/swagger"; +import { Request } from "express"; +import { BadRequestError } from "src/error/bad-request-error"; +import { ConflictError } from "src/error/conflict-error"; +import { ForbiddenError } from "src/error/forbidden-error"; +import { UnauthorizedError } from "src/error/unauthorized-error"; import { CreateUserDto } from "src/user/dto/create-user.dto"; -import { CreateUserBadrequestError } from "src/user/exceptions/create-user-badrequest-error.exception"; -import { CreateUserConflictError } from "src/user/exceptions/create-user-conflict-error.exception"; -import { CreateUserRespose } from "src/user/resposes/create-user-respose"; +import { LoginUserDto } from "src/user/dto/login-user.dto"; +import { CreateUserResponse } from "src/user/responses/create-user-response"; import { AuthService } from "./auth.service"; +import { type JwtUser } from "./jwt/jwt.interface"; +import { JwtRefreshGuard } from "./jwt/jwt-refresh.guard"; +import { LocalAuthGuard } from "./local/local-auth.guard"; +import { GenerateTokenResponse } from "./responses/generate-token.response"; @ApiTags("Auth") @Controller("auth") @@ -25,17 +37,61 @@ export class AuthController { }) @ApiCreatedResponse({ description: "使用者創建成功", - type: CreateUserRespose, + type: CreateUserResponse, }) @ApiConflictResponse({ description: "使用者資料重覆", - type: CreateUserConflictError, + type: ConflictError, }) @ApiBadRequestResponse({ description: "使用者格式不符", - type: CreateUserBadrequestError, + type: BadRequestError, }) async register(@Body() userDto: CreateUserDto) { return this.authService.register(userDto); } + + @Post("login") + @UseGuards(LocalAuthGuard) + @ApiOperation({ + description: + "Will return access and refresh token when the account information is correct.", + summary: "login with our account", + }) + @ApiCreatedResponse({ + description: "Success with generated token", + type: GenerateTokenResponse, + }) + @ApiForbiddenResponse({ + description: "Account information error", + type: ForbiddenError, + }) + @ApiBody({ type: LoginUserDto }) + async login(@Req() request: Request) { + return this.authService.sign(request.user as JwtUser); + } + + @Get("refresh") + @ApiBearerAuth() + @UseGuards(JwtRefreshGuard) + @ApiOperation({ + description: + "Will return access and refresh token when the refresh token carried in the request is valid.", + summary: "refresh token", + }) + @ApiCreatedResponse({ + description: "Success with generated token", + type: GenerateTokenResponse, + }) + @ApiUnauthorizedResponse({ + description: String( + "Token error \n " + + "When the token is not carried in the request. \n" + + "When the token has expired or become invalid.", + ), + type: UnauthorizedError, + }) + async refresh(@Req() request: Request) { + return this.login(request); + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 1130e0f..d95d9fe 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,14 +1,29 @@ import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; import { TypeOrmModule } from "@nestjs/typeorm"; import { UserEntity } from "src/user/entities/user.entity"; import { UserModule } from "src/user/user.module"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { JwtAccessStrategy } from "./jwt/jwt-access.strategy"; +import { JwtRefreshStrategy } from "./jwt/jwt-refresh.strategy"; +import { LocalStrategy } from "./local/local.strategy"; @Module({ controllers: [AuthController], - imports: [UserModule, TypeOrmModule.forFeature([UserEntity])], - providers: [AuthService], + imports: [ + UserModule, + PassportModule, + TypeOrmModule.forFeature([UserEntity]), + JwtModule.register({}), + ], + providers: [ + AuthService, + LocalStrategy, + JwtAccessStrategy, + JwtRefreshStrategy, + ], }) export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 947a278..04b1062 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,26 +1,37 @@ -import { - type HttpException, - ConflictException, - HttpStatus, -} from "@nestjs/common"; +import { ConflictException, HttpStatus } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; import { type TestingModule, Test } from "@nestjs/testing"; import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm"; import { validate } from "class-validator"; import { dataSourceJest } from "src/config/data-source"; +import jestConfig from "src/config/jest.config"; import type { CreateUserDto } from "src/user/dto/create-user.dto"; import { UserEntity } from "src/user/entities/user.entity"; import { UserService } from "src/user/user.service"; import type { Repository } from "typeorm"; import { AuthService } from "./auth.service"; +import { type JwtUser } from "./jwt/jwt.interface"; +import { JwtAccessStrategy } from "./jwt/jwt-access.strategy"; +import { LocalStrategy } from "./local/local.strategy"; describe("AuthService", () => { let authService: AuthService; - let userRepository: Repository | undefined; + let userService: UserService; + let userRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [TypeOrmModule.forRoot(dataSourceJest)], + imports: [ + ConfigModule.forRoot({ + load: [jestConfig], + }), + PassportModule, + TypeOrmModule.forRoot(dataSourceJest), + JwtModule.register({}), + ], providers: [ AuthService, UserService, @@ -29,71 +40,66 @@ describe("AuthService", () => { // 使用測試資料庫的 Repository useValue: UserEntity, }, + LocalStrategy, + JwtAccessStrategy, ], }).compile(); authService = module.get(AuthService); + userService = module.get(UserService); userRepository = module.get>( getRepositoryToken(UserEntity), ); }); describe("createUser - Data", () => { + const rawUser: CreateUserDto = { + account: "account1", + email: "jhon@gmail.com", + name: "displayname", + password: "Password@123", + }; + const rawUserConflictEmail: CreateUserDto = { + account: "account2", + email: "jhon@gmail.com", + name: "displayname", + password: "Password@123", + }; + + const rawUserConflictAccount: CreateUserDto = { + account: "account", + email: "jhon2@gmail.com", + name: "displayname", + password: "Password@123", + }; + it("應該會創建 一個使用者", async () => { - const rawUser: CreateUserDto = { - account: "account1", - email: "jhon@gmail.com", - name: "displayname", - password: "Password@123", - }; const user = await authService.register(rawUser); - expect(user).toBeDefined(); expect(user.statusCode).toEqual(HttpStatus.CREATED); expect(user.message).toEqual("創建成功"); }); it("應該會發生 email、account 已被註冊衝突", async () => { - const createUserDto1: CreateUserDto = { - account: "account", - email: "jhon@gmail.com", - name: "displayname", - password: "Password@123", - }; - - await authService.register(createUserDto1); - await authService - .register(createUserDto1) - .catch((error: HttpException) => { - expect(error).toBeInstanceOf(ConflictException); - expect(error.getResponse()).toEqual({ - error: "Conflict", - message: ["email 已被註冊。", "account 已被註冊。"], - statusCode: 409, - }); + await authService.register(rawUser); + await authService.register(rawUser).catch(error => { + expect(error).toBeInstanceOf(ConflictException); + expect((error as ConflictException).getResponse()).toEqual({ + error: "Conflict", + message: ["email 已被註冊。", "account 已被註冊。"], + statusCode: 409, }); + }); }); it("應該會發生 email 已被註冊衝突", async () => { - const rawUser1: CreateUserDto = { - account: "account1", - email: "jhon@gmail.com", - name: "displayname", - password: "Password@123", - }; - const rawUser2: CreateUserDto = { - account: "account2", - email: "jhon@gmail.com", - name: "displayname", - password: "Password@123", - }; - const errors = await validate(rawUser1); + const errors = await validate(rawUser); expect(errors.length).toBe(0); - await authService.register(rawUser1); - await authService.register(rawUser2).catch((error: HttpException) => { + await authService.register(rawUser); + await authService.register(rawUserConflictEmail).catch(error => { expect(error).toBeInstanceOf(ConflictException); - expect(error.getResponse()).toEqual({ + expect((error as ConflictException).getResponse()).toEqual({ error: "Conflict", message: ["email 已被註冊。"], statusCode: 409, @@ -102,25 +108,13 @@ describe("AuthService", () => { }); it("應該會發生 account 已被註冊衝突", async () => { - const rawUser1: CreateUserDto = { - account: "account", - email: "jhon@gmail.com", - name: "displayname", - password: "Password@123", - }; - const rawUser2: CreateUserDto = { - account: "account", - email: "jhon2@gmail.com", - name: "displayname", - password: "Password@123", - }; - const errors = await validate(rawUser1); + const errors = await validate(rawUser); expect(errors.length).toBe(0); - await authService.register(rawUser1); - await authService.register(rawUser2).catch((error: HttpException) => { + await authService.register(rawUser); + await authService.register(rawUserConflictAccount).catch(error => { expect(error).toBeInstanceOf(ConflictException); - expect(error.getResponse()).toEqual({ + expect((error as ConflictException).getResponse()).toEqual({ error: "Conflict", message: ["account 已被註冊。"], statusCode: 409, @@ -129,7 +123,102 @@ describe("AuthService", () => { }); }); + describe("user local login", () => { + const fakeAccessToken = "mocked_access_token"; + const fakeRefreshToken = "mocked_refresh_token"; + const mockUser: Partial = { + account: "test", + email: "test@example.com", + id: 1, + name: "test", + password: "$2b$05$zc4SaUDmE68OgrabgSoLX.CDMHZ8SD/aDeuJc7rxKmtqjP5WpH.Me", + }; + const mockJwtUser: JwtUser = { + id: 1, + }; + + beforeEach(async () => { + jest + .spyOn(authService, "generateAccessToken") + .mockReturnValue(Promise.resolve(fakeAccessToken)); + + jest + .spyOn(authService, "generateRefreshToken") + .mockReturnValue(Promise.resolve(fakeRefreshToken)); + }); + + it("should be login successfully.", async () => { + const expectedStatusCode = HttpStatus.CREATED; + + const result = await authService.sign(mockJwtUser); + + expect(result).toEqual({ + accessToken: fakeAccessToken, + refreshToken: fakeRefreshToken, + statusCode: expectedStatusCode, + }); + }); + + it("should be validate successfully.", async () => { + const mockAccount = "test"; + const mockPassword = "Password@123"; + + jest + .spyOn(userService, "findOne") + .mockImplementation(async () => mockUser as UserEntity); + + const result = await authService.validateUser(mockAccount, mockPassword); + + expect(result).toEqual({ + id: mockUser.id, + }); + }); + + it("when the account does not exist should be validate failure.", async () => { + const mockAccount = "test"; + const mockPassword = "Password@123"; + + jest.spyOn(userService, "findOne").mockImplementation(async () => null); + + const result = await authService.validateUser(mockAccount, mockPassword); + + expect(result).toEqual(null); + }); + + it("when the account exist but password not correct should be validate failure.", async () => { + const mockAccount = "test"; + const mockPassword = "Password@1234"; + + jest + .spyOn(userService, "findOne") + .mockImplementation(async () => mockUser as UserEntity); + + const result = await authService.validateUser(mockAccount, mockPassword); + + expect(result).toEqual(null); + }); + }); + + describe("generate Token", () => { + const userId = 1; + const payload: JwtUser = { + id: userId, + }; + + it("should generate access token", async () => { + const result = await authService.generateAccessToken(payload); + + expect(result).toBeDefined(); + }); + + it("should generate refresh token", async () => { + const result = await authService.generateRefreshToken(payload); + + expect(result).toBeDefined(); + }); + }); + afterEach(async () => { - await userRepository?.clear(); + await userRepository.clear(); }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c718625..199e129 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,14 +1,21 @@ -import { ConflictException, Injectable } from "@nestjs/common"; +import { ConflictException, HttpStatus, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; import { InjectRepository } from "@nestjs/typeorm"; +import * as bcrypt from "bcrypt"; import type { CreateUserDto } from "src/user/dto/create-user.dto"; import { UserEntity } from "src/user/entities/user.entity"; import { UserService } from "src/user/user.service"; import { Repository } from "typeorm"; +import { type JwtUser } from "./jwt/jwt.interface"; + @Injectable() export class AuthService { constructor( + private readonly configService: ConfigService, private readonly userService: UserService, + private readonly jwtService: JwtService, @InjectRepository(UserEntity) private readonly userRepository: Repository, ) {} @@ -33,4 +40,58 @@ export class AuthService { return this.userService.create(userDto); } + + async sign(user: JwtUser) { + const accessToken = await this.generateAccessToken(user); + const refreshToken = await this.generateRefreshToken(user); + + return { + accessToken, + refreshToken, + statusCode: HttpStatus.CREATED, + }; + } + + async validateUser(username: string, password: string) { + const user: UserEntity | null = await this.userService.findOne(username); + + if (!user) return null; + + const passwordCorrect = bcrypt.compareSync(password, user.password); + const userData = { id: user.id }; + + if (!passwordCorrect) { + return null; + } + + return userData; + } + + async generateAccessToken(user: JwtUser): Promise { + const payload: JwtUser = { + id: user.id, + }; + const secret: string | undefined = + this.configService.get("jwtSecret.access"); + const token = this.jwtService.sign(payload, { + expiresIn: "1h", + secret, + }); + + return token; + } + + async generateRefreshToken(user: JwtUser): Promise { + const payload: JwtUser = { + id: user.id, + }; + const secret: string | undefined = + this.configService.get("jwtSecret.refresh"); + const token = this.jwtService.sign(payload, { + expiresIn: "7d", + secret, + }); + + return token; + } } diff --git a/src/auth/jwt/jwt-access.guard.spec.ts b/src/auth/jwt/jwt-access.guard.spec.ts new file mode 100644 index 0000000..e38e3e5 --- /dev/null +++ b/src/auth/jwt/jwt-access.guard.spec.ts @@ -0,0 +1,82 @@ +import { type ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { JwtModule, JwtService } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { Test } from "@nestjs/testing"; +import jestConfig from "src/config/jest.config"; + +import { JwtAccessGuard } from "./jwt-access.guard"; +import { JwtAccessStrategy } from "./jwt-access.strategy"; + +describe("JwtAccessGuard", () => { + let jwtAccessGuard: JwtAccessGuard; + let jwtService: JwtService; + let configService: ConfigService; + let secret: string | undefined; + let token: string; + + beforeEach(async () => { + jest.useFakeTimers(); + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [jestConfig], + }), + PassportModule, + JwtModule.register({}), + ], + providers: [JwtAccessGuard, JwtAccessStrategy, JwtService], + }).compile(); + + jwtAccessGuard = moduleRef.get(JwtAccessGuard); + jwtService = moduleRef.get(JwtService); + configService = moduleRef.get(ConfigService); + secret = configService.get("jwtSecret.access"); + token = jwtService.sign( + { + id: 1, + }, + { + expiresIn: "1h", + secret, + }, + ); + }); + + it("should be defined", () => { + expect(jwtAccessGuard).toBeDefined(); + }); + + describe("valid JWT", () => { + const response = {}; + const context: ExecutionContext = { + getRequest: () => ({ + headers: { + authorization: `bearer ${token}`, + }, + }), + getResponse: () => response, + switchToHttp: () => context, + } as unknown as ExecutionContext; + + it("should return true for a valid JWT", async () => { + const canActivate = await jwtAccessGuard.canActivate(context); + + expect(canActivate).toBe(true); + }); + + it("should throw an error for an expired JWT", async () => { + jest.advanceTimersByTime(60 * 60 * 1000); + + try { + await jwtAccessGuard.canActivate(context); + } catch (error) { + expect(error).toBeInstanceOf(UnauthorizedException); + } + }); + + afterEach(async () => { + jest.clearAllTimers(); + }); + }); +}); diff --git a/src/auth/jwt/jwt-access.guard.ts b/src/auth/jwt/jwt-access.guard.ts new file mode 100644 index 0000000..692a234 --- /dev/null +++ b/src/auth/jwt/jwt-access.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class JwtAccessGuard extends AuthGuard("jwt-access") {} diff --git a/src/auth/jwt/jwt-access.strategy.ts b/src/auth/jwt/jwt-access.strategy.ts new file mode 100644 index 0000000..d1feae3 --- /dev/null +++ b/src/auth/jwt/jwt-access.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; + +import { type JwtUser } from "./jwt.interface"; + +@Injectable() +export class JwtAccessStrategy extends PassportStrategy( + Strategy, + "jwt-access", +) { + constructor(configService: ConfigService) { + const secret: string | undefined = configService.get("jwtSecret.access"); + + super({ + ignoreExpiration: false, + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: secret, + }); + } + + async validate(payload: JwtUser) { + return { + id: payload.id, + }; + } +} diff --git a/src/auth/jwt/jwt-refresh.guard.spec.ts b/src/auth/jwt/jwt-refresh.guard.spec.ts new file mode 100644 index 0000000..ca22aac --- /dev/null +++ b/src/auth/jwt/jwt-refresh.guard.spec.ts @@ -0,0 +1,82 @@ +import { type ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { JwtModule, JwtService } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { Test } from "@nestjs/testing"; +import jestConfig from "src/config/jest.config"; + +import { JwtRefreshGuard } from "./jwt-refresh.guard"; +import { JwtRefreshStrategy } from "./jwt-refresh.strategy"; + +describe("JwtRefreshGuard", () => { + let jwtRefreshGuard: JwtRefreshGuard; + let jwtService: JwtService; + let configService: ConfigService; + let secret: string | undefined; + let token: string; + + beforeEach(async () => { + jest.useFakeTimers(); + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [jestConfig], + }), + PassportModule, + JwtModule.register({}), + ], + providers: [JwtRefreshGuard, JwtRefreshStrategy, JwtService], + }).compile(); + + jwtRefreshGuard = moduleRef.get(JwtRefreshGuard); + jwtService = moduleRef.get(JwtService); + configService = moduleRef.get(ConfigService); + secret = configService.get("jwtSecret.refresh"); + token = jwtService.sign( + { + id: 1, + }, + { + expiresIn: "7d", + secret, + }, + ); + }); + + it("should be defined", () => { + expect(jwtRefreshGuard).toBeDefined(); + }); + + describe("valid JWT", () => { + const response = {}; + const context: ExecutionContext = { + getRequest: () => ({ + headers: { + authorization: `bearer ${token}`, + }, + }), + getResponse: () => response, + switchToHttp: () => context, + } as unknown as ExecutionContext; + + it("should return true for a valid JWT", async () => { + const canActivate = await jwtRefreshGuard.canActivate(context); + + expect(canActivate).toBe(true); + }); + + it("should throw an error for an expired JWT", async () => { + jest.advanceTimersByTime(8 * 24 * 60 * 60 * 1000); + + try { + await jwtRefreshGuard.canActivate(context); + } catch (error) { + expect(error).toBeInstanceOf(UnauthorizedException); + } + }); + + afterEach(async () => { + jest.clearAllTimers(); + }); + }); +}); diff --git a/src/auth/jwt/jwt-refresh.guard.ts b/src/auth/jwt/jwt-refresh.guard.ts new file mode 100644 index 0000000..c64035f --- /dev/null +++ b/src/auth/jwt/jwt-refresh.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class JwtRefreshGuard extends AuthGuard("jwt-refresh") {} diff --git a/src/auth/jwt/jwt-refresh.strategy.ts b/src/auth/jwt/jwt-refresh.strategy.ts new file mode 100644 index 0000000..bb92e23 --- /dev/null +++ b/src/auth/jwt/jwt-refresh.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; + +import { type JwtUser } from "./jwt.interface"; + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy( + Strategy, + "jwt-refresh", +) { + constructor(configService: ConfigService) { + const secret: string | undefined = configService.get("jwtSecret.refresh"); + + super({ + ignoreExpiration: false, + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: secret, + }); + } + + async validate(payload: JwtUser) { + return { + payload, + }; + } +} diff --git a/src/auth/jwt/jwt.interface.ts b/src/auth/jwt/jwt.interface.ts new file mode 100644 index 0000000..42624e5 --- /dev/null +++ b/src/auth/jwt/jwt.interface.ts @@ -0,0 +1,3 @@ +export interface JwtUser { + id: number; +} diff --git a/src/auth/local/local-auth.guard.spec.ts b/src/auth/local/local-auth.guard.spec.ts new file mode 100644 index 0000000..5e94280 --- /dev/null +++ b/src/auth/local/local-auth.guard.spec.ts @@ -0,0 +1,110 @@ +import { type ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { BadRequestException } from "@nestjs/common/exceptions"; +import { PassportModule } from "@nestjs/passport"; +import { Test } from "@nestjs/testing"; +import { type LoginUserDto } from "src/user/dto/login-user.dto"; + +import { AuthService } from "../auth.service"; +import { LocalStrategy } from "./local.strategy"; +import { LocalAuthGuard } from "./local-auth.guard"; + +describe("LocalAuthGuard", () => { + let localAuthGuard: LocalAuthGuard; + let authService: AuthService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [PassportModule], + providers: [ + LocalAuthGuard, + { + provide: AuthService, + useValue: { + validateUser: jest.fn(), + }, + }, + LocalStrategy, + ], + }).compile(); + + localAuthGuard = moduleRef.get(LocalAuthGuard); + authService = moduleRef.get(AuthService); + }); + + function createMockExecutionContext(requestBody: LoginUserDto) { + const mockResponse = {}; + + return { + switchToHttp: () => ({ + getRequest: () => ({ + body: requestBody, + }), + getResponse: () => mockResponse, + }), + } as ExecutionContext; + } + + it("should be defined", () => { + expect(localAuthGuard).toBeDefined(); + }); + + it("should return true if user is valid", async () => { + const mockUser = { + email: "test@example.com", + id: 1, + }; + + const mockRequest = { + account: "test", + password: "password", + } as LoginUserDto; + + jest + .spyOn(authService, "validateUser") + .mockImplementation(async () => mockUser); + + const result = await localAuthGuard.canActivate( + createMockExecutionContext(mockRequest), + ); + + expect(result).toBe(true); + }); + + it("should return Forbidden and 403 http code when account information is wrong.", async () => { + const mockRequest = { + account: "test", + password: "password", + } as LoginUserDto; + + jest + .spyOn(authService, "validateUser") + .mockImplementation(async () => null); + + try { + await localAuthGuard.canActivate(createMockExecutionContext(mockRequest)); + } catch (error) { + expect(error).toBeInstanceOf(ForbiddenException); + expect((error as BadRequestException).getResponse()).toEqual({ + message: ["Account or password is wrong."], + statusCode: 403, + }); + } + }); + + it("should return BadRequest and 400 http code when field format validation failed.", async () => { + const mockRequest = { + account: "test", + } as LoginUserDto; + + try { + await localAuthGuard.canActivate(createMockExecutionContext(mockRequest)); + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException); + expect((error as BadRequestException).getResponse()).toEqual({ + error: "Bad Request", + message: ["password 必須長度大於等於8個字。", "password 為必填欄位。"], + statusCode: 400, + }); + } + }); +}); diff --git a/src/auth/local/local-auth.guard.ts b/src/auth/local/local-auth.guard.ts new file mode 100644 index 0000000..494febd --- /dev/null +++ b/src/auth/local/local-auth.guard.ts @@ -0,0 +1,32 @@ +import { + type ArgumentMetadata, + type BadRequestException, + type ExecutionContext, + Injectable, +} from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { plainToClass } from "class-transformer"; +import { validationPipe } from "src/pipes/validation-pipe"; +import { LoginUserDto } from "src/user/dto/login-user.dto"; + +@Injectable() +export class LocalAuthGuard extends AuthGuard("local") { + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const loginDto = plainToClass(LoginUserDto, request.body); + + const metadata: ArgumentMetadata = { + data: "@Body()", + metatype: LoginUserDto, + type: "body", + }; + + await validationPipe + .transform(loginDto, metadata) + .catch((error: BadRequestException) => { + throw error; + }); + + return super.canActivate(context) as Promise | boolean; + } +} diff --git a/src/auth/local/local.strategy.spec.ts b/src/auth/local/local.strategy.spec.ts new file mode 100644 index 0000000..be5efe3 --- /dev/null +++ b/src/auth/local/local.strategy.spec.ts @@ -0,0 +1,64 @@ +import { ForbiddenException } from "@nestjs/common"; +import { type TestingModule, Test } from "@nestjs/testing"; + +import { AuthService } from "../auth.service"; +import { LocalStrategy } from "./local.strategy"; + +describe("LocalStrategy", () => { + let localStrategy: LocalStrategy; + let authService: AuthService; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AuthService, + useValue: { + validateUser: jest.fn(), + }, + }, + LocalStrategy, + ], + }).compile(); + + localStrategy = moduleRef.get(LocalStrategy); + authService = moduleRef.get(AuthService); + }); + + it("should be defined", () => { + expect(localStrategy).toBeDefined(); + }); + + describe("validate user", () => { + const mockAccount = "test"; + const mockPassword = "password"; + + it("should return user payload if user is valid", async () => { + const mockUser = { + id: 1, + }; + + jest + .spyOn(authService, "validateUser") + .mockImplementation(async () => mockUser); + + const result = await localStrategy.validate(mockAccount, mockPassword); + + expect(result).toEqual({ + id: mockUser.id, + }); + }); + + it("should throw ForbiddenException if user is invalid", async () => { + jest + .spyOn(authService, "validateUser") + .mockImplementation(async () => null); + + try { + await localStrategy.validate(mockAccount, mockPassword); + } catch (error) { + expect(error).toBeInstanceOf(ForbiddenException); + } + }); + }); +}); diff --git a/src/auth/local/local.strategy.ts b/src/auth/local/local.strategy.ts new file mode 100644 index 0000000..2e9f5e8 --- /dev/null +++ b/src/auth/local/local.strategy.ts @@ -0,0 +1,29 @@ +import { ForbiddenException, HttpStatus, Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { Strategy } from "passport-local"; + +import { AuthService } from "../auth.service"; +import { type JwtUser } from "../jwt/jwt.interface"; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor(private readonly authService: AuthService) { + super({ usernameField: "account" }); + } + + async validate(account: string, password: string) { + const user = await this.authService.validateUser(account, password); + + if (!user) { + throw new ForbiddenException({ + message: ["Account or password is wrong."], + statusCode: HttpStatus.FORBIDDEN, + }); + } + const payload: JwtUser = { + id: user.id, + }; + + return payload; + } +} diff --git a/src/auth/responses/generate-token.response.ts b/src/auth/responses/generate-token.response.ts new file mode 100644 index 0000000..c20bc64 --- /dev/null +++ b/src/auth/responses/generate-token.response.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class GenerateTokenResponse { + @ApiProperty({ + description: "Generate accessToken", + example: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Impob25AZ21haWwuY29tIiwiaWQiOjIsImlhdCI6MTY4ODQ2Mzc4OCwiZXhwIjoxNjg4NTUwMTg4fQ.F3YqRedhg62eXpJ946OOTE52Y5-GIYHC8GTtT8JNMc8", + type: "string", + }) + public readonly accessToken: string; + + @ApiProperty({ + description: "Generate refreshToken", + example: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Impob25AZ21haWwuY29tIiwiaWQiOjIsImlhdCI6MTY4ODQ2Mzc4OCwiZXhwIjoxNjg4NTUwMTg4fQ.F3YqRedhg62eXpJ946OOTE52Y5-GIYHC8GTtT8JNMc8", + type: "string", + }) + public readonly refreshToken: string; + + @ApiProperty({ + description: "HTTP Code", + example: 201, + type: "number", + }) + public readonly statusCode: number; +} diff --git a/src/config/app.config.ts b/src/config/app.config.ts new file mode 100644 index 0000000..d7a85c2 --- /dev/null +++ b/src/config/app.config.ts @@ -0,0 +1,6 @@ +export default () => ({ + jwtSecret: { + access: process.env.JWT_ACCESS_SECRET, + refresh: process.env.JWT_REFRESH_SECRET, + }, +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index dd41775..ef7e7f0 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -42,6 +42,14 @@ class EnvironmentVariables { @IsString() @IsNotEmpty() DB_TIMEZONE: string; + + @IsString() + @IsNotEmpty() + JWT_ACCESS_SECRET: string; + + @IsString() + @IsNotEmpty() + JWT_REFRESH_SECRET: string; } export function validate(config: Record) { diff --git a/src/config/jest.config.ts b/src/config/jest.config.ts new file mode 100644 index 0000000..9d39b75 --- /dev/null +++ b/src/config/jest.config.ts @@ -0,0 +1,6 @@ +export default () => ({ + jwtSecret: { + access: "Cophr_jwtSecret_access", + refresh: "Cophr_jwtSecret_refresh", + }, +}); diff --git a/src/error/bad-request-error.ts b/src/error/bad-request-error.ts new file mode 100644 index 0000000..4129cb5 --- /dev/null +++ b/src/error/bad-request-error.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class BadRequestError { + @ApiProperty({ + description: "HTTP Code", + example: 400, + type: "number", + }) + public readonly statusCode: number; + + @ApiProperty({ + description: "Error Message", + example: ["Error Message"], + type: "array", + }) + public readonly error: string[]; +} diff --git a/src/error/conflict-error.ts b/src/error/conflict-error.ts new file mode 100644 index 0000000..556ba4f --- /dev/null +++ b/src/error/conflict-error.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class ConflictError { + @ApiProperty({ + description: "HTTP Code", + example: 409, + type: "number", + }) + public readonly statusCode: number; + + @ApiProperty({ + description: "Error Message", + example: ["Error Message"], + type: "array", + }) + public readonly error: string[]; +} diff --git a/src/error/forbidden-error.ts b/src/error/forbidden-error.ts new file mode 100644 index 0000000..0b452ab --- /dev/null +++ b/src/error/forbidden-error.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class ForbiddenError { + @ApiProperty({ + description: "HTTP Code", + example: 403, + type: "number", + }) + public readonly statusCode: number; + + @ApiProperty({ + description: "Error Message", + example: "Error Message", + type: "string", + }) + public readonly error: string; +} diff --git a/src/error/unauthorized-error.ts b/src/error/unauthorized-error.ts new file mode 100644 index 0000000..578d856 --- /dev/null +++ b/src/error/unauthorized-error.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class UnauthorizedError { + @ApiProperty({ + description: "HTTP Code", + example: 401, + type: "number", + }) + public readonly statusCode: number; + + @ApiProperty({ + description: "Error Message", + example: "Unauthorized", + type: "string", + }) + public readonly error: string; +} diff --git a/src/main.ts b/src/main.ts index 8743bcc..75bb3f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,24 @@ import type { INestApplication } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { type SecuritySchemeObject } from "@nestjs/swagger/dist/interfaces/open-api-spec.interface"; import { AppModule } from "./app.module"; import { validationPipe } from "./pipes/validation-pipe"; +const securitySchemes: SecuritySchemeObject = { + bearerFormat: "JWT", + scheme: "bearer", + type: "http", +}; + function setupSwagger(app: INestApplication) { const builder = new DocumentBuilder(); const config = builder .setTitle(process.env.APP_SWAGGER_Title ?? "Cophr") .setDescription(process.env.APP_SWAGGER_Description ?? "") .setVersion(process.env.APP_SWAGGER_Version ?? "N/A") + .addBearerAuth(securitySchemes) .build(); const document = SwaggerModule.createDocument(app, config); diff --git a/src/swagger/generate-swagger-file.ts b/src/swagger/generate-swagger-file.ts index 93f3ce6..f01998e 100644 --- a/src/swagger/generate-swagger-file.ts +++ b/src/swagger/generate-swagger-file.ts @@ -1,9 +1,16 @@ import { NestFactory } from "@nestjs/core"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { type SecuritySchemeObject } from "@nestjs/swagger/dist/interfaces/open-api-spec.interface"; import * as fs from "fs"; import { SwaggerGenerateModule } from "./swagger.module"; +const securitySchemes: SecuritySchemeObject = { + bearerFormat: "JWT", + scheme: "bearer", + type: "http", +}; + async function generateSwaggerJson() { const app = await NestFactory.create(SwaggerGenerateModule); @@ -11,6 +18,7 @@ async function generateSwaggerJson() { .setTitle(process.env.APP_SWAGGER_Title ?? "Cophr") .setDescription(process.env.APP_SWAGGER_Description ?? "") .setVersion(process.env.APP_SWAGGER_Version ?? "N/A") + .addBearerAuth(securitySchemes) .build(); const document = SwaggerModule.createDocument(app, config); diff --git a/src/swagger/swagger.module.ts b/src/swagger/swagger.module.ts index 9ce9d20..64678de 100644 --- a/src/swagger/swagger.module.ts +++ b/src/swagger/swagger.module.ts @@ -1,14 +1,27 @@ import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { JwtModule } from "@nestjs/jwt"; import { TypeOrmModule } from "@nestjs/typeorm"; import { AppController } from "src/app.controller"; import { AppService } from "src/app.service"; import { AuthModule } from "src/auth/auth.module"; +import { JwtAccessStrategy } from "src/auth/jwt/jwt-access.strategy"; import { dataSourceJest } from "src/config/data-source"; +import jestConfig from "src/config/jest.config"; import { UserModule } from "src/user/user.module"; @Module({ controllers: [AppController], - imports: [TypeOrmModule.forRoot(dataSourceJest), UserModule, AuthModule], - providers: [AppService], + imports: [ + TypeOrmModule.forRoot(dataSourceJest), + ConfigModule.forRoot({ + isGlobal: true, + load: [jestConfig], + }), + UserModule, + AuthModule, + JwtModule.register({}), + ], + providers: [AppService, JwtAccessStrategy], }) export class SwaggerGenerateModule {} diff --git a/src/user/dto/login-user.dto.ts b/src/user/dto/login-user.dto.ts new file mode 100644 index 0000000..9dd8a84 --- /dev/null +++ b/src/user/dto/login-user.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, MinLength } from "class-validator"; + +export class LoginUserDto { + @ApiProperty({ + description: "email or account as login account.", + example: "account", + }) + @IsNotEmpty({ + message: "account field should not be empty.", + }) + public readonly account: string; + + @ApiProperty({ + description: "使用者密碼", + example: "Password@123", + }) + @IsNotEmpty({ + message: "password 為必填欄位。", + }) + @MinLength(8, { message: "password 必須長度大於等於8個字。" }) + public readonly password: string; +} diff --git a/src/user/exceptions/create-user-badrequest-error.exception.ts b/src/user/exceptions/create-user-badrequest-error.exception.ts deleted file mode 100644 index b07d88b..0000000 --- a/src/user/exceptions/create-user-badrequest-error.exception.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; - -export class CreateUserBadrequestError { - @ApiProperty({ - description: "HTTP 回應代碼", - example: "400", - type: "number", - }) - public readonly statusCode: number; - - @ApiProperty({ - description: "錯誤訊息", - example: [ - "email 為必填欄位。", - "email 必須是信箱格式。", - "name 為必填欄位。", - "account 為必填欄位。", - "password 為必填欄位。", - "password 必須長度大於等於8個字。", - ], - items: { - properties: { - account: { - description: "account 為必填欄位。 \n", - type: "string", - }, - email: { - // eslint-disable-next-line no-useless-concat - description: "email 為必填欄位。 \n" + "email 必須是信箱格式。 \n", - type: "string", - }, - name: { - description: "name 為必填欄位。 \n", - type: "string", - }, - password: { - // eslint-disable-next-line no-useless-concat - description: - "password 為必填欄位。 \n" + - "password 必須長度大於等於8個字。 \n", - type: "string", - }, - }, - }, - type: "array", - }) - public readonly message: string[]; - - @ApiProperty({ - description: "錯誤狀態碼敘述", - example: "Bad Request", - type: "string", - }) - public readonly error: string; -} diff --git a/src/user/exceptions/create-user-conflict-error.exception.ts b/src/user/exceptions/create-user-conflict-error.exception.ts deleted file mode 100644 index 4302ab4..0000000 --- a/src/user/exceptions/create-user-conflict-error.exception.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; - -export class CreateUserConflictError { - @ApiProperty({ - description: "HTTP 回應代碼", - example: "409", - type: "number", - }) - public readonly statusCode: number; - - @ApiProperty({ - description: "錯誤訊息", - example: ["email 已被註冊。", "account 已被註冊。"], - items: { - properties: { - account: { - description: "account 已被註冊。 \n", - type: "string", - }, - email: { - description: "email 已被註冊。 \n", - type: "string", - }, - }, - }, - type: "array", - }) - public readonly message: string[]; - - @ApiProperty({ - description: "錯誤狀態碼敘述", - example: "Conflict", - type: "string", - }) - public readonly error: string; -} diff --git a/src/user/resposes/create-user-respose.ts b/src/user/responses/create-user-response.ts similarity index 80% rename from src/user/resposes/create-user-respose.ts rename to src/user/responses/create-user-response.ts index 56ad3be..dbf1516 100644 --- a/src/user/resposes/create-user-respose.ts +++ b/src/user/responses/create-user-response.ts @@ -1,9 +1,9 @@ import { ApiProperty } from "@nestjs/swagger"; -export class CreateUserRespose { +export class CreateUserResponse { @ApiProperty({ description: "HTTP 回應代碼", - example: "201", + example: 201, type: "number", }) public readonly statusCode: number; @@ -11,7 +11,7 @@ export class CreateUserRespose { @ApiProperty({ description: "創建成功回應", example: "創建成功", - type: "number", + type: "string", }) public readonly message: string; } diff --git a/src/user/user.module.ts b/src/user/user.module.ts index bdb9b06..b5e4d5f 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,9 +1,12 @@ import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { UserEntity } from "./entities/user.entity"; import { UserService } from "./user.service"; @Module({ exports: [UserService], + imports: [TypeOrmModule.forFeature([UserEntity])], providers: [UserService], }) export class UserModule {} diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 7ffc8c7..15e57d9 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -2,6 +2,7 @@ import { HttpStatus } from "@nestjs/common"; import { type TestingModule, Test } from "@nestjs/testing"; import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm"; import { dataSourceJest } from "src/config/data-source"; +import { type Repository } from "typeorm"; import type { CreateUserDto } from "./dto/create-user.dto"; import { UserEntity } from "./entities/user.entity"; @@ -9,6 +10,7 @@ import { UserService } from "./user.service"; describe("UserService", () => { let userService: UserService; + let userRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -24,6 +26,9 @@ describe("UserService", () => { }).compile(); userService = module.get(UserService); + userRepository = module.get>( + getRepositoryToken(UserEntity), + ); }); it("應該會創建 一個使用者", async () => { @@ -35,8 +40,30 @@ describe("UserService", () => { }; const user = await userService.create(rawUser); - expect(user).toBeDefined(); expect(user.statusCode).toEqual(HttpStatus.CREATED); expect(user.message).toEqual("創建成功"); }); + + it("should be found a user", async () => { + const account = "test"; + const mockUser: Partial = { + account: "test", + email: "test@example.com", + id: 1, + name: "test", + password: "$2b$05$zc4SaUDmE68OgrabgSoLX.CDMHZ8SD/aDeuJc7rxKmtqjP5WpH.Me", + }; + + jest + .spyOn(userRepository, "findOne") + .mockResolvedValue(mockUser as UserEntity); + + const user = await userService.findOne(account); + + expect(user).toEqual(mockUser); + }); + + afterEach(async () => { + await userRepository.clear(); + }); }); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 2b01b90..7f0b15c 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,11 +1,18 @@ import { HttpStatus, Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; import * as bcrypt from "bcrypt"; +import { Repository } from "typeorm"; import type { CreateUserDto } from "./dto/create-user.dto"; import { UserEntity } from "./entities/user.entity"; @Injectable() export class UserService { + constructor( + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + ) {} + async create(userDto: CreateUserDto) { const hash = bcrypt.hashSync(userDto.password, 5); const user = new UserEntity(); @@ -21,4 +28,11 @@ export class UserService { statusCode: HttpStatus.CREATED, }; } + + async findOne(account: string): Promise { + return this.userRepository.findOne({ + select: ["id", "email", "name", "account", "password"], + where: [{ email: account }, { account }], + }); + } } diff --git a/yarn.lock b/yarn.lock index 32ce74d..1628b38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -906,11 +906,24 @@ tslib "2.4.1" uuid "9.0.0" +"@nestjs/jwt@^10.1.0": + version "10.1.0" + resolved "https://registry.npmmirror.com/@nestjs/jwt/-/jwt-10.1.0.tgz#7899b68d68b998cc140bc0af392c63fc00755236" + integrity sha512-iLwCGS25ybUxGS7i5j/Mwuyzvp/WxJftHlm8aLEBv5GV92apz6L1QVjxLdZrqXbzo++C8gdJauhzil8qitY+6w== + dependencies: + "@types/jsonwebtoken" "9.0.2" + jsonwebtoken "9.0.0" + "@nestjs/mapped-types@*", "@nestjs/mapped-types@1.2.0": version "1.2.0" resolved "https://registry.npmmirror.com/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz#1bbdbb5c956f0adb3fd76add929137bc6ad3183f" integrity sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg== +"@nestjs/passport@^10.0.0": + version "10.0.0" + resolved "https://registry.npmmirror.com/@nestjs/passport/-/passport-10.0.0.tgz#92d36d4b8796b373da3f4d1a055db03cb246b127" + integrity sha512-IlKKc6M7JOe+4dBbW6gZsXBSD05ZYgwfGf3GJhgCmUGYVqffpDdALQSS6JftnExrE+12rACoEmHkzYwKAGVK0Q== + "@nestjs/platform-express@^9.0.0": version "9.2.1" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-9.2.1.tgz#74b88a531239eaee3fe23af2f2912ebef313866f" @@ -1148,6 +1161,26 @@ "@types/qs" "*" "@types/range-parser" "*" +"@types/express-serve-static-core@^4.17.33": + version "4.17.35" + resolved "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz#c95dd4424f0d32e525d23812aa8ab8e4d3906c4f" + integrity sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@*": + version "4.17.17" + resolved "https://registry.npmmirror.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" + integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/express@^4.17.13": version "4.17.15" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.15.tgz#9290e983ec8b054b65a5abccb610411953d417ff" @@ -1202,11 +1235,23 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonwebtoken@*", "@types/jsonwebtoken@9.0.2": + version "9.0.2" + resolved "https://registry.npmmirror.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#9eeb56c76dd555039be2a3972218de5bd3b8d83e" + integrity sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q== + dependencies: + "@types/node" "*" + "@types/mime@*": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== +"@types/mime@^1": + version "1.3.2" + resolved "https://registry.npmmirror.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" + integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== + "@types/minimist@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" @@ -1237,6 +1282,39 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/passport-jwt@^3.0.8": + version "3.0.8" + resolved "https://registry.npmmirror.com/@types/passport-jwt/-/passport-jwt-3.0.8.tgz#c8a95bf7d8f330f2560f1b3d07605e23ac01469a" + integrity sha512-VKJZDJUAHFhPHHYvxdqFcc5vlDht8Q2pL1/ePvKAgqRThDaCc84lSYOTQmnx3+JIkDlN+2KfhFhXIzlcVT+Pcw== + dependencies: + "@types/express" "*" + "@types/jsonwebtoken" "*" + "@types/passport-strategy" "*" + +"@types/passport-local@^1.0.35": + version "1.0.35" + resolved "https://registry.npmmirror.com/@types/passport-local/-/passport-local-1.0.35.tgz#233d370431b3f93bb43cf59154fb7519314156d9" + integrity sha512-K4eLTJ8R0yYW8TvCqkjB0pTKoqfUSdl5PfZdidTjV2ETV3604fQxtY6BHKjQWAx50WUS0lqzBvKv3LoI1ZBPeA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-strategy" "*" + +"@types/passport-strategy@*": + version "0.2.35" + resolved "https://registry.npmmirror.com/@types/passport-strategy/-/passport-strategy-0.2.35.tgz#e52f5212279ea73f02d9b06af67efe9cefce2d0c" + integrity sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g== + dependencies: + "@types/express" "*" + "@types/passport" "*" + +"@types/passport@*", "@types/passport@^1.0.12": + version "1.0.12" + resolved "https://registry.npmmirror.com/@types/passport/-/passport-1.0.12.tgz#7dc8ab96a5e895ec13688d9e3a96920a7f42e73e" + integrity sha512-QFdJ2TiAEoXfEQSNDISJR1Tm51I78CymqcBa8imbjo6dNNu+l2huDxxbDEIoFIwOSKMkOfHEikyDuZ38WwWsmw== + dependencies: + "@types/express" "*" + "@types/prettier@^2.1.5": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" @@ -1257,6 +1335,14 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== +"@types/send@*": + version "0.17.1" + resolved "https://registry.npmmirror.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301" + integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + "@types/serve-static@*": version "1.15.0" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155" @@ -1990,6 +2076,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -2633,6 +2724,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -4431,6 +4529,33 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== +jsonwebtoken@9.0.0, jsonwebtoken@^9.0.0: + version "9.0.0" + resolved "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d" + integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw== + dependencies: + jws "^3.2.2" + lodash "^4.17.21" + ms "^2.1.1" + semver "^7.3.8" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.npmmirror.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.npmmirror.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + kind-of@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -5269,6 +5394,35 @@ parseurl@~1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +passport-jwt@^4.0.1: + version "4.0.1" + resolved "https://registry.npmmirror.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" + integrity sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ== + dependencies: + jsonwebtoken "^9.0.0" + passport-strategy "^1.0.0" + +passport-local@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee" + integrity sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow== + dependencies: + passport-strategy "1.x.x" + +passport-strategy@1.x.x, passport-strategy@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== + +passport@^0.6.0: + version "0.6.0" + resolved "https://registry.npmmirror.com/passport/-/passport-0.6.0.tgz#e869579fab465b5c0b291e841e6cc95c005fac9d" + integrity sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug== + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + utils-merge "^1.0.1" + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -5309,6 +5463,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pause@0.0.1: + version "0.0.1" + resolved "https://registry.npmmirror.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6540,7 +6699,7 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@1.0.1: +utils-merge@1.0.1, utils-merge@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==