diff --git a/.eslintrc.js b/.eslintrc.ts similarity index 94% rename from .eslintrc.js rename to .eslintrc.ts index 28d7eb2d..6e28eb68 100644 --- a/.eslintrc.js +++ b/.eslintrc.ts @@ -12,7 +12,7 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: ['.eslintrc.js'], + ignorePatterns: ['.eslintrc.ts'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml new file mode 100644 index 00000000..061820cf --- /dev/null +++ b/.github/workflows/auto-label.yml @@ -0,0 +1,14 @@ +name: 'Auto Label' + +on: + pull_request: + types: [labeled, unlabeled, opened, synchronize, reopened] + +permissions: + pull-requests: write + +jobs: + auto-label: + runs-on: ubuntu-latest + steps: + - uses: Yaminyam/auto-label-in-issue@1.1.0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7607f702..3181ed22 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -9,20 +9,27 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ develop ] + branches: [develop] pull_request: # The branches below must be a subset of the branches above - branches: [ develop ] + branches: [develop] paths-ignore: - - '**/*.md' - - '**/*.txt' + - '**/*.md' + - '**/*.txt' schedule: - cron: '17 16 * * 5' +# Cancel previous workflows if they are the same workflow on same ref (branch/tags) +# with the same event (push/pull_request) even they are in progress. +# This setting will help reduce the number of duplicated workflows. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + jobs: analyze: name: Analyze @@ -35,39 +42,39 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ['javascript'] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://git.io/codeql-language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # โ„น๏ธ Command-line programs to run using the OS shell. - # ๐Ÿ“š https://git.io/JvXDl + # โ„น๏ธ Command-line programs to run using the OS shell. + # ๐Ÿ“š https://git.io/JvXDl - # โœ๏ธ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # โœ๏ธ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language - #- run: | - # make bootstrap - # make release + #- run: | + # make bootstrap + # make release - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e59f9087..2891fa89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,9 +3,18 @@ name: Test CI on: pull_request: branches: ['**'] + types: [labeled, unlabeled, opened, synchronize, reopened] + +# Cancel previous workflows if they are the same workflow on same ref (branch/tags) +# with the same event (push/pull_request) even they are in progress. +# This setting will help reduce the number of duplicated workflows. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true jobs: test: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} runs-on: ubuntu-latest env: @@ -25,17 +34,12 @@ jobs: ACCESS_TOKEN_KEY: ${{secrets.ACCESS_TOKEN_KEY}} FRONT_URL: ${{secrets.FRONT_URL}} - strategy: - matrix: - node-version: [16.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - steps: - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js 16.x uses: actions/setup-node@v2 with: - node-version: ${{ matrix.node-version }} + node-version: 16.x cache: 'yarn' - run: yarn - run: make test diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 69a4ac88..f690c174 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,7 +1,7 @@ import { AppController } from '@api/app.controller'; import { ArticleModule } from '@api/article/article.module'; import { AuthModule } from '@api/auth/auth.module'; -import { JwtAuthGuard } from '@api/auth/jwt-auth.guard'; +import { JwtAuthGuard } from '@api/auth/jwt-auth/jwt-auth.guard'; import { BestModule } from '@api/best/best.module'; import { CategoryModule } from '@api/category/category.module'; import { CommentModule } from '@api/comment/comment.module'; diff --git a/apps/api/src/article/repositories/article.repository.ts b/apps/api/src/article/repositories/article.repository.ts index 4ad48b3d..2711e137 100644 --- a/apps/api/src/article/repositories/article.repository.ts +++ b/apps/api/src/article/repositories/article.repository.ts @@ -41,9 +41,12 @@ export class ArticleRepository extends Repository
{ }) .andWhere( new Brackets((qb) => { - qb.where('article.title like :q', { q: `%${options.q}%` }).orWhere('article.content like :q', { - q: `%${options.q}%`, - }); + qb.where('article.title like :q', { q: `%${options.q}%` }).orWhere( + 'regexp_replace(`article`.`content`, "!\\[[[:print:]]+\\]\\([[:print:]]+\\)", "") like :q', + { + q: `%${options.q}%`, + }, + ); }), ) .skip(getPaginationSkip(options)) @@ -52,7 +55,6 @@ export class ArticleRepository extends Repository
{ const totalCount = await query.getCount(); const articles = await query.getMany(); - return { articles, totalCount }; } diff --git a/apps/api/src/auth/__test__/auth.controller.test.ts b/apps/api/src/auth/__test__/auth.controller.test.ts new file mode 100644 index 00000000..5a198cae --- /dev/null +++ b/apps/api/src/auth/__test__/auth.controller.test.ts @@ -0,0 +1,57 @@ +import { ConfigService } from '@nestjs/config'; +import { Response } from 'express'; +import { mock, mockFn } from 'jest-mock-extended'; +import { AuthController } from '../auth.controller'; +import { AuthService } from '../auth.service'; +import { GithubProfile } from '../types'; + +describe('AuthController', () => { + const mockAuthService = mock({ + login: mockFn().mockResolvedValue({ id: 1 }), + getJwt: mockFn().mockReturnValue('jwt'), + getCookieOption: mockFn().mockReturnValue({ cookie: 'test' }), + }); + const mockConfigService = mock({ + get: mockFn().mockReturnValue('access-token-key'), + }); + const authController = new AuthController(mockAuthService, mockConfigService); + + beforeEach(() => { + jest.clearAllTimers(); + }); + + describe('githubLogin', () => { + test('์ •์ƒ ํ˜ธ์ถœ', async () => { + const actual = () => authController.githubLogin(); + + expect(actual).not.toThrow(); + }); + }); + + describe('githubCallback', () => { + test('๋กœ๊ทธ์ธํ•˜๋ฉด ์ฟ ํ‚ค๋ฅผ ์„ธํŒ…ํ•œ๋‹ค', async () => { + const githubProfile: GithubProfile = { id: '1', username: 'test' }; + const mockResponse = mock({ + cookie: mockFn().mockReturnThis(), + }); + + await authController.githubCallback(githubProfile, mockResponse); + + expect(mockResponse.cookie).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledWith('access-token-key', 'jwt', { cookie: 'test' }); + }); + }); + + describe('signout', () => { + test('๋กœ๊ทธ์•„์›ƒํ•˜๋ฉด ์ฟ ํ‚ค๋ฅผ ๋น„์šด๋‹ค', async () => { + const mockResponse = mock({ + clearCookie: mockFn().mockReturnThis(), + }); + + authController.signout(mockResponse); + + expect(mockResponse.clearCookie).toBeCalledTimes(1); + expect(mockResponse.clearCookie).toBeCalledWith('access-token-key', { cookie: 'test' }); + }); + }); +}); diff --git a/apps/api/src/auth/__test__/auth.decorator.test.ts b/apps/api/src/auth/__test__/auth.decorator.test.ts new file mode 100644 index 00000000..4b65a76d --- /dev/null +++ b/apps/api/src/auth/__test__/auth.decorator.test.ts @@ -0,0 +1,128 @@ +import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; +import { User } from '@app/entity/user/user.entity'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import 'reflect-metadata'; +import { Auth, AuthUser, ReqGithubProfile } from '../auth.decorator'; +import { REQUIRE_ROLES } from '../constant'; +import { getParamDecorator } from './getParamDecoratorFactory'; + +describe('AuthDecorator', () => { + describe('Auth', () => { + test('Auth๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฉด ๊ถŒํ•œ์ด ์„ค์ •์ด ๋˜์ง€ ์•Š๋Š”๋‹ค.', async () => { + class TestClass { + testMethod() {} + } + + const result = Reflect.getMetadata(REQUIRE_ROLES, new TestClass().testMethod); + + expect(result).toBeUndefined(); + }); + + test(`Auth('allow', UserRole.GUEST, UserRole.ADMIN) ๋Š” GUEST, ADMIN ๋‘˜๋‹ค ํ—ˆ๋ฝํ•œ๋‹ค`, async () => { + class TestClass { + @Auth('allow', UserRole.GUEST, UserRole.ADMIN) + testMethod() {} + } + + const result = Reflect.getMetadata(REQUIRE_ROLES, new TestClass().testMethod); + + expect(result).toStrictEqual(['allow', UserRole.GUEST, UserRole.ADMIN]); + }); + + test(`Auth('allow') ๋Š” ์•„๋ฌด๊ถŒํ•œ๋„ ํ—ˆ๋ฝํ•˜์ง€ ์•Š๋Š”๋‹ค.`, async () => { + class TestClass { + @Auth('allow') + testMethod() {} + } + + const result = Reflect.getMetadata(REQUIRE_ROLES, new TestClass().testMethod); + + expect(result).toStrictEqual(['allow']); + }); + + test(`Auth('deny', UserRole.GUEST, UserRole.ADMIN) ๋Š” GUEST, ADMIN ๋‘˜๋‹ค ํ—ˆ๋ฝํ•œ๋‹ค`, async () => { + class TestClass { + @Auth('deny', UserRole.GUEST, UserRole.ADMIN) + testMethod() {} + } + + const result = Reflect.getMetadata(REQUIRE_ROLES, new TestClass().testMethod); + + expect(result).toStrictEqual(['deny', UserRole.GUEST, UserRole.ADMIN]); + }); + + test(`Auth('deny') ๋Š” ๋ชจ๋“  ๊ถŒํ•œ์„ ํ—ˆ๋ฝํ•œ๋‹ค.`, async () => { + class TestClass { + @Auth('deny') + testMethod() {} + } + + const result = Reflect.getMetadata(REQUIRE_ROLES, new TestClass().testMethod); + + expect(result).toStrictEqual(['deny']); + }); + + test(`Auth('public') ์€ ๋ชจ๋“  ๊ถŒํ•œ์„ ํ—ˆ๋ฝํ•œ๋‹ค.`, async () => { + class TestClass { + @Auth('public') + testMethod() {} + } + + const result = Reflect.getMetadata(REQUIRE_ROLES, new TestClass().testMethod); + + expect(result).toStrictEqual(['deny']); + }); + + test(`Auth() ์€ GUEST๋ฅผ ์ œ์™ธํ•œ ๊ถŒํ•œ์„ ํ—ˆ๋ฝํ•œ๋‹ค.`, async () => { + class TestClass { + @Auth() + testMethod() {} + } + + const result = Reflect.getMetadata(REQUIRE_ROLES, new TestClass().testMethod); + + expect(result).toStrictEqual(['deny', UserRole.GUEST]); + }); + }); + + describe('AuthUser', () => { + test('Request ์— ๋‹ด๊ธด user๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค', async () => { + const user = new User(); + const context = new ExecutionContextHost([{ user }, {}]); + + const result = getParamDecorator(AuthUser)(null, context); + + expect(result).toStrictEqual(user); + }); + + test('Request ์— ๋‹ด๊ธด user์˜ id๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค', async () => { + const user = new User(); + user.id = 1; + const context = new ExecutionContextHost([{ user }, {}]); + + const result = getParamDecorator(AuthUser)('id', context); + + expect(result).toStrictEqual(user.id); + }); + + test('Request ์— ๋‹ด๊ธด user์˜ id๊ฐ€ ์—†๋Š”๊ฒฝ์šฐ', async () => { + const user = new User(); + const context = new ExecutionContextHost([{ user }, {}]); + + const result = getParamDecorator(AuthUser)('id', context); + + expect(result).toBeUndefined(); + }); + }); + + describe('ReqGithubProfile', () => { + test('Request ์— ๋‹ด๊ธด githubprofile๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค', async () => { + const user = { id: 1, username: 'test' }; + const context = new ExecutionContextHost([{ user }, {}]); + + const result = getParamDecorator(ReqGithubProfile)(null, context); + + expect(result).toStrictEqual(user); + }); + }); +}); diff --git a/apps/api/src/auth/__test__/auth.module.test.ts b/apps/api/src/auth/__test__/auth.module.test.ts new file mode 100644 index 00000000..a8d933ab --- /dev/null +++ b/apps/api/src/auth/__test__/auth.module.test.ts @@ -0,0 +1,13 @@ +import { AuthModule } from '../auth.module'; + +describe('AuthModule', () => { + test('๋ชจ๋“ˆ์ด ์ž˜ ์ปดํŒŒ์ผ๋œ๋‹ค.', async () => { + // TODO: Test.createTestingModule ๋กœ complie ํ• ๊ฒƒ + // const module = await Test.createTestingModule({ + // imports: [AuthModule], + // }).compile(); + const module = new AuthModule(); + + expect(module).toBeDefined(); + }); +}); diff --git a/apps/api/src/auth/__test__/auth.service.test.ts b/apps/api/src/auth/__test__/auth.service.test.ts new file mode 100644 index 00000000..50a4a83f --- /dev/null +++ b/apps/api/src/auth/__test__/auth.service.test.ts @@ -0,0 +1,113 @@ +import { UserService } from '@api/user/user.service'; +import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; +import { User } from '@app/entity/user/user.entity'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { mock, mockFn } from 'jest-mock-extended'; +import { AuthService } from '../auth.service'; +import { GithubProfile } from '../types'; + +describe('AuthService', () => { + const mockUserSerivce = mock(); + const mockJwtService = mock({ + sign: mockFn().mockReturnValue('jwt'), + }); + const mockConfigService = mock(); + const authService = new AuthService(mockUserSerivce, mockJwtService, mockConfigService); + + beforeEach(() => { + mockUserSerivce.findOneByGithubUId.mockClear(); + mockConfigService.get.mockClear(); + jest.clearAllTimers(); + }); + + describe('login', () => { + test('์ด๋ฏธ ๊ฐ€์ž…ํ•œ ์œ ์ €๋Š” ๋กœ๊ทธ์ธ ์‹œ๊ฐ„์„ ์—…๋ฐ์ดํŠธ ํ•œ๋‹ค', async () => { + const user = new User(); + const oldLastLogin = new Date(); + user.lastLogin = oldLastLogin; + user.save = mockFn().mockReturnThis(); + + const githubProfile: GithubProfile = { id: '1', username: 'test' }; + mockUserSerivce.findOneByGithubUId.mockResolvedValue(user); + + const result: User = await authService.login(githubProfile); + + expect(result).toBeDefined(); + expect(result.lastLogin).not.toBe(oldLastLogin); + }); + + test('์ฒ˜์Œ ๋กœ๊ทธ์ธํ•˜๋Š” ์œ ์ €๋Š” ์œ ์ €๋ฅผ ์ƒ์„ฑํ•œ๋‹ค', async () => { + const githubProfile: GithubProfile = { id: '1', username: 'test' }; + mockUserSerivce.findOneByGithubUId.mockResolvedValue(undefined); + mockUserSerivce.create.mockImplementation(async (user: User) => user); + + const result: User = await authService.login(githubProfile); + + expect(result).toBeDefined(); + expect(result.nickname).toBe(githubProfile.username); + expect(result.githubUsername).toBe(githubProfile.username); + expect(result.githubUid).toBe(githubProfile.id); + expect(mockUserSerivce.create).toBeCalledTimes(1); + }); + }); + + describe('getJwt', () => { + test('jwt๋ฅผ ๋งŒ๋“ ๋‹ค', async () => { + const user = new User(); + user.id = 1; + user.role = UserRole.ADMIN; + + const result = authService.getJwt(user); + + expect(result).toBe('jwt'); + expect(mockJwtService.sign).toBeCalledTimes(1); + expect(mockJwtService.sign).toBeCalledWith({ userId: 1, userRole: UserRole.ADMIN }); + }); + }); + + describe('getCookieOption', () => { + test('prod ์ผ๋–„ ์ฟ ํ‚ค ์˜ต์…˜', async () => { + mockConfigService.get.mockReturnValue('prod'); + + const result = authService.getCookieOption(); + + expect(result).toMatchInlineSnapshot(` + Object { + "httpOnly": true, + "maxAge": 604800000, + "sameSite": "lax", + "secure": true, + } + `); + }); + + test('alpha ์ผ๋–„ ์ฟ ํ‚ค ์˜ต์…˜', async () => { + mockConfigService.get.mockReturnValue('alpha'); + + const result = authService.getCookieOption(); + + expect(result).toMatchInlineSnapshot(` + Object { + "httpOnly": true, + "maxAge": 604800000, + "sameSite": "none", + "secure": true, + } + `); + }); + + test('dev/test ์ผ๋•Œ ์ฟ ํ‚ค ์˜ต์…˜', async () => { + mockConfigService.get.mockReturnValue('dev'); + + const result = authService.getCookieOption(); + + expect(result).toMatchInlineSnapshot(` + Object { + "httpOnly": true, + "maxAge": 604800000, + } + `); + }); + }); +}); diff --git a/apps/api/src/auth/__test__/getParamDecoratorFactory.ts b/apps/api/src/auth/__test__/getParamDecoratorFactory.ts new file mode 100644 index 00000000..8f8560a4 --- /dev/null +++ b/apps/api/src/auth/__test__/getParamDecoratorFactory.ts @@ -0,0 +1,21 @@ +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +/** + * @description Param Decorator๋ฅผ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•œ ํ—ฌํผ ํ•จ์ˆ˜ + * + * @example + * ``` + * const result = getParamDecorator(AuthUser)('id', context) + * ``` + * + * @see https://github.com/nestjs/nest/issues/1020 + * @see https://github.com/EnricoFerro/test-NestJs7-Decorator/blob/master/src/app.controller.spec.ts + */ +export function getParamDecorator(decorator: Function) { + class Test { + public test(@decorator() value: unknown) {} + } + + const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test'); + return args[Object.keys(args)[0]].factory; +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 3f1fc710..78358cc1 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,18 +1,16 @@ -import { UserService } from '@api/user/user.service'; -import { getCookieOption } from '@app/utils/utils'; import { Controller, Delete, Get, Res, UseGuards } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ApiCookieAuth, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; import { Response } from 'express'; import { Auth, ReqGithubProfile } from './auth.decorator'; import { AuthService } from './auth.service'; -import { GithubAuthGuard } from './github-auth.guard'; -import { GithubProfile } from './interfaces/github-profile.interface'; -import { JWTPayload } from './interfaces/jwt-payload.interface'; +import { GithubAuthGuard } from './github-auth/github-auth.guard'; +import { GithubProfile } from './types'; @ApiTags('Auth') @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService, private readonly userService: UserService) {} + constructor(private readonly authService: AuthService, private readonly configService: ConfigService) {} @Get('github') @UseGuards(GithubAuthGuard) @@ -24,7 +22,6 @@ export class AuthController { }) @ApiOkResponse({ description: '๊นƒํ—ˆ๋ธŒ ํŽ˜์ด์ง€' }) githubLogin(): void { - console.log('send to login page'); return; } @@ -42,12 +39,11 @@ export class AuthController { @ReqGithubProfile() githubProfile: GithubProfile, @Res({ passthrough: true }) response: Response, ): Promise { - const user = await this.userService.githubLogin(githubProfile); - const jwt = this.authService.getJWT({ - userId: user.id, - userRole: user.role, - } as JWTPayload); - response.cookie(process.env.ACCESS_TOKEN_KEY, jwt, getCookieOption()); + const user = await this.authService.login(githubProfile); + const jwt = this.authService.getJwt(user); + const cookieOption = this.authService.getCookieOption(); + + response.cookie(this.configService.get('ACCESS_TOKEN_KEY'), jwt, cookieOption); } @Delete('signout') @@ -57,6 +53,8 @@ export class AuthController { @ApiOkResponse({ description: '๋กœ๊ทธ์•„์›ƒ ์„ฑ๊ณต' }) @ApiUnauthorizedResponse({ description: '์ธ์ฆ ์‹คํŒจ' }) signout(@Res({ passthrough: true }) response: Response): void { - response.clearCookie(process.env.ACCESS_TOKEN_KEY, getCookieOption()); + const cookieOption = this.authService.getCookieOption(); + + response.clearCookie(this.configService.get('ACCESS_TOKEN_KEY'), cookieOption); } } diff --git a/apps/api/src/auth/auth.decorator.ts b/apps/api/src/auth/auth.decorator.ts index 8748fea4..d11ac97a 100644 --- a/apps/api/src/auth/auth.decorator.ts +++ b/apps/api/src/auth/auth.decorator.ts @@ -1,32 +1,15 @@ import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; import { User } from '@app/entity/user/user.entity'; import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common'; -import { REQUIRE_ROLES } from './auth.constant'; -import { GithubProfile } from './interfaces/github-profile.interface'; +import { REQUIRE_ROLES } from './constant'; +import { AuthType, GithubProfile } from './types'; /** - * @description Auth decorator๋ฅผ ์จ์•ผ ์‚ฌ์šฉ๊ฐ€๋Šฅ - */ -export const AuthUser = createParamDecorator((data: 'id' | null, ctx: ExecutionContext): User => { - const req = ctx.switchToHttp().getRequest(); - if (data) return req.user[data]; - return req.user; -}); - -export const ReqGithubProfile = createParamDecorator((data, ctx: ExecutionContext): GithubProfile => { - const req = ctx.switchToHttp().getRequest(); - return req.user; -}); - -type AuthType = 'allow' | 'deny'; - -export type AuthDecoratorParam = [AuthType, ...UserRole[]]; - -/** - * 1. ๋ˆ„๊ตฌ๋‚˜ ์‚ฌ์šฉ๊ฐ€๋Šฅ -> Auth() ์—†์Œ - * 2. ๋ˆ„๊ตฌ๋‚˜ ์ ‘์†๊ฐ€๋Šฅํ•˜์ง€๋งŒ ๊ถŒํ•œ๋”ฐ๋ผ ์‚ฌ์šฉ๊ฐ€๋Šฅ -> Auth('public') ==> Auth('deny') - * 3. ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž๋งŒ ์‚ฌ์šฉ๊ฐ€๋Šฅ -> Auth() ==> Auth('deny', UserRole.GUEST) - * 4. ํŠน์ • ์‚ฌ์šฉ์ž๋งŒ ์‚ฌ์šฉ๊ฐ€๋Šฅ -> Auth('only', UserRole.ADMIN) + * @description + * 1. Auth() ์—†์Œ => ๋ˆ„๊ตฌ๋‚˜ ์‚ฌ์šฉ๊ฐ€๋Šฅ(์ธ์ฆ X, ์ธ๊ฐ€ X) + * 2. Auth('public') or Auth('deny') => ๋ˆ„๊ตฌ๋‚˜ ์ ‘์†๊ฐ€๋Šฅํ•˜์ง€๋งŒ ๊ถŒํ•œ๋”ฐ๋ผ ์‚ฌ์šฉ๊ฐ€๋Šฅ(์ธ์ฆ โ–ณ, ์ธ๊ฐ€ O) + * 3. Auth() or Auth('deny', UserRole.GUEST) => ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž๋งŒ ๊ถŒํ•œ๋”ฐ๋ผ ์‚ฌ์šฉ๊ฐ€๋Šฅ(์ธ์ฆ O, ์ธ๊ฐ€ O) + * 4. Auth('only', UserRole.ADMIN) => ํŠน์ • ์‚ฌ์šฉ์ž๋งŒ ์‚ฌ์šฉ๊ฐ€๋Šฅ(์ธ์ฆ O, ์ธ๊ฐ€ O) * * 1 ๋ฒˆ๊ณผ 2๋ฒˆ์ด ๋‹ค๋ฅธ์  : 2๋ฒˆ์€ AuthUser๋ฅผ ์“ธ ์ˆ˜ ์žˆ์ง€๋งŒ, 1๋ฒˆ์€ ๋ชป์”€! */ @@ -41,3 +24,22 @@ export const Auth = (allow?: AuthType | 'public', ...param: UserRole[]) => { return SetMetadata(REQUIRE_ROLES, [allow, ...param]); }; + +/** + * @description Request์— ๋‹ด๊ธด User๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค. + * @note Auth decorator๋ฅผ ์จ์•ผ ์‚ฌ์šฉ๊ฐ€๋Šฅ + */ +export const AuthUser = createParamDecorator((data: 'id' | null, ctx: ExecutionContext): User => { + const req = ctx.switchToHttp().getRequest(); + if (data) return req.user[data]; + return req.user; +}); + +/** + * @description Request์— ๋‹ด๊ธด GithubProfile๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค. + * @note GithubAuthGuard ๋ฅผ ์จ์•ผ ์‚ฌ์šฉ๊ฐ€๋Šฅ + */ +export const ReqGithubProfile = createParamDecorator((data, ctx: ExecutionContext): GithubProfile => { + const req = ctx.switchToHttp().getRequest(); + return req.user; +}); diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 21730a48..69f1540d 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -2,17 +2,16 @@ import { UserModule } from '@api/user/user.module'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; -import { GithubStrategy } from './github.strategy'; -import { JwtStrategy } from './jwt.strategy'; +import { GithubAuthModule } from './github-auth/github-auth.module'; +import { JwtAuthModule } from './jwt-auth/jwt-auth.module'; @Module({ imports: [ UserModule, - PassportModule, - ConfigModule, + GithubAuthModule, + JwtAuthModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -22,7 +21,7 @@ import { JwtStrategy } from './jwt.strategy'; }), }), ], - providers: [AuthService, GithubStrategy, JwtStrategy], + providers: [AuthService], exports: [AuthService], controllers: [AuthController], }) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index d19b29b0..0c4b06fe 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,12 +1,54 @@ +import { UserService } from '@api/user/user.service'; +import { User } from '@app/entity/user/user.entity'; import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import { JWTPayload } from './interfaces/jwt-payload.interface'; +import { CookieOptions } from 'express'; +import { GithubProfile, JWTPayload } from './types'; @Injectable() export class AuthService { - constructor(private jwtService: JwtService) {} + constructor( + private readonly userService: UserService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} - getJWT(payload: JWTPayload): string { + async login(githubProfile: GithubProfile): Promise { + const user = await this.userService.findOneByGithubUId(githubProfile.id); + + if (user) { + user.lastLogin = new Date(); + return await user.save(); + } + + const newUser = new User(); + newUser.nickname = githubProfile.username; + newUser.githubUsername = githubProfile.username; + newUser.githubUid = githubProfile.id; + newUser.lastLogin = new Date(); + + return await this.userService.create(newUser); + } + + getJwt(user: User): string { + const payload: JWTPayload = { + userId: user.id, + userRole: user.role, + }; return this.jwtService.sign(payload); } + + getCookieOption = (): CookieOptions => { + const oneHour = 60 * 60 * 1000; + const maxAge = 7 * 24 * oneHour; // 7days + + if (this.configService.get('NODE_ENV') === 'prod') { + return { httpOnly: true, secure: true, sameSite: 'lax', maxAge }; + } else if (this.configService.get('NODE_ENV') === 'alpha') { + return { httpOnly: true, secure: true, sameSite: 'none', maxAge }; + } + + return { httpOnly: true, maxAge }; + }; } diff --git a/apps/api/src/auth/auth.constant.ts b/apps/api/src/auth/constant.ts similarity index 100% rename from apps/api/src/auth/auth.constant.ts rename to apps/api/src/auth/constant.ts diff --git a/apps/api/src/auth/github-auth/__test__/github-auth.guard.test.ts b/apps/api/src/auth/github-auth/__test__/github-auth.guard.test.ts new file mode 100644 index 00000000..551a8051 --- /dev/null +++ b/apps/api/src/auth/github-auth/__test__/github-auth.guard.test.ts @@ -0,0 +1,41 @@ +import { BadRequestException } from '@nestjs/common'; +import { mockFn } from 'jest-mock-extended'; +import { GithubAuthGuard } from '../github-auth.guard'; + +describe('GithubAuthGuard', () => { + const guard = new GithubAuthGuard(); + const mockSuperHandleRequest = mockFn(); + + beforeAll(async () => { + // ๋ถ€๋ชจ ํด๋ž˜์Šค mock ์ฒ˜๋ฆฌ + (GithubAuthGuard.prototype as any).__proto__.handleRequest = mockSuperHandleRequest; + }); + + beforeEach(async () => { + mockSuperHandleRequest.mockClear(); + jest.clearAllTimers(); + }); + + describe('handleRequest', () => { + test('์ •์ƒ์ ์ธ ์š”์ฒญ์€ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.', async () => { + const githubProfile = { id: 1 }; + mockSuperHandleRequest.mockReturnValue(githubProfile); + + const result = guard.handleRequest(null, null, null, null); + + expect(result).toBe(githubProfile); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + }); + + test('์ •์ƒ์ ์ธ ์š”์ฒญ์ด ์•„๋‹ˆ๋ฉด BadRequestException ์—๋Ÿฌ๋ฅผ ๋˜์ง„๋‹ค.', async () => { + mockSuperHandleRequest.mockImplementation(() => { + throw new Error('error'); + }); + + const act = () => guard.handleRequest(null, null, null, null); + + expect(act).toThrowError(BadRequestException); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + }); + }); +}); diff --git a/apps/api/src/auth/github-auth/__test__/github-auth.module.test.ts b/apps/api/src/auth/github-auth/__test__/github-auth.module.test.ts new file mode 100644 index 00000000..49ac4db0 --- /dev/null +++ b/apps/api/src/auth/github-auth/__test__/github-auth.module.test.ts @@ -0,0 +1,25 @@ +import { ConfigModule } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { GithubAuthModule } from '../github-auth.module'; + +describe('GithubAuthModule', () => { + test('๋ชจ๋“ˆ์ด ์ž˜ ์ปดํŒŒ์ผ๋œ๋‹ค.', async () => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + () => ({ + GITHUB_CLIENT_ID: '123', + GITHUB_CLIENT_SECRET: '123', + GITHUB_CALLBACK_URL: '123', + }), + ], + }), + GithubAuthModule, + ], + }).compile(); + + expect(module).toBeDefined(); + }); +}); diff --git a/apps/api/src/auth/github-auth/__test__/github-auth.strategy.test.ts b/apps/api/src/auth/github-auth/__test__/github-auth.strategy.test.ts new file mode 100644 index 00000000..c473e2b9 --- /dev/null +++ b/apps/api/src/auth/github-auth/__test__/github-auth.strategy.test.ts @@ -0,0 +1,31 @@ +import { ConfigService } from '@nestjs/config'; +import { mock, mockFn } from 'jest-mock-extended'; +import { VerifiedCallback } from 'passport-jwt'; +import { GithubAuthStrategy } from '../github-auth.strategy'; + +describe('GithubAuthStrategy', () => { + const mockConfigService = mock({ + get: mockFn().mockReturnValue('test'), + }); + const githubAuthStrategy = new GithubAuthStrategy(mockConfigService); + + describe('validate', () => { + test('GithubProfile์ด ๋ฐ˜ํ™˜๋œ๋‹ค', async () => { + const accessToken = ''; + const refreshToken = ''; + const profile = { + id: 'testid', + username: 'testusername', + other: 'zzz', + }; + const done = mockFn(); + + await githubAuthStrategy.validate(accessToken, refreshToken, profile, done); + + expect(done).toBeCalledWith(null, { + id: 'testid', + username: 'testusername', + }); + }); + }); +}); diff --git a/apps/api/src/auth/github-auth.guard.ts b/apps/api/src/auth/github-auth/github-auth.guard.ts similarity index 100% rename from apps/api/src/auth/github-auth.guard.ts rename to apps/api/src/auth/github-auth/github-auth.guard.ts diff --git a/apps/api/src/auth/github-auth/github-auth.module.ts b/apps/api/src/auth/github-auth/github-auth.module.ts new file mode 100644 index 00000000..e71c1355 --- /dev/null +++ b/apps/api/src/auth/github-auth/github-auth.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { GithubAuthStrategy } from './github-auth.strategy'; + +@Module({ + imports: [PassportModule], + providers: [GithubAuthStrategy], +}) +export class GithubAuthModule {} diff --git a/apps/api/src/auth/github.strategy.ts b/apps/api/src/auth/github-auth/github-auth.strategy.ts similarity index 52% rename from apps/api/src/auth/github.strategy.ts rename to apps/api/src/auth/github-auth/github-auth.strategy.ts index 07a2873e..aad15023 100644 --- a/apps/api/src/auth/github.strategy.ts +++ b/apps/api/src/auth/github-auth/github-auth.strategy.ts @@ -1,17 +1,19 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, VerifyCallback } from 'passport-github2'; -import { GithubProfile } from './interfaces/github-profile.interface'; +import { GithubProfile } from '../types'; @Injectable() -export class GithubStrategy extends PassportStrategy(Strategy) { - constructor() { +export class GithubAuthStrategy extends PassportStrategy(Strategy) { + constructor(readonly configService: ConfigService) { super({ - clientID: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - callbackURL: process.env.GITHUB_CALLBACK_URL, // frontend url + clientID: configService.get('GITHUB_CLIENT_ID'), + clientSecret: configService.get('GITHUB_CLIENT_SECRET'), + callbackURL: configService.get('GITHUB_CALLBACK_URL'), // frontend url }); } + async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback): Promise { const githubProfile: GithubProfile = { id: profile.id, diff --git a/apps/api/src/auth/jwt-auth.guard.ts b/apps/api/src/auth/jwt-auth.guard.ts deleted file mode 100644 index e8e5121b..00000000 --- a/apps/api/src/auth/jwt-auth.guard.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { FORBIDDEN_USER_ROLE, REQUIRE_ROLES } from '@api/auth/auth.constant'; -import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; -import { User } from '@app/entity/user/user.entity'; -import { ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { AuthGuard } from '@nestjs/passport'; -import { AuthDecoratorParam } from './auth.decorator'; -import { getAccessToken } from './jwt.strategy'; - -/** - * Custom AuthGuard to check public handler and user roles - * @see also https://docs.nestjs.com/security/authentication#extending-guards - * - * 1. JwtAuthGuard.canActivate -> check if handler is public or not - * 2. JwtStrategy.validate -> get user from jwt payload or db - * 3. JwtAuthGuard.handleRequest -> check user roles - */ -@Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') { - constructor(private reflector: Reflector) { - super(); - } - - canActivate(context: ExecutionContext) { - const requireAuth = this.reflector.get(REQUIRE_ROLES, context.getHandler()); - - if (!requireAuth) { - return true; - } - - const request = context.switchToHttp().getRequest(); - if (!getAccessToken(request)) { - request.user = new User(); - request.user.id = -1; - request.user.role = UserRole.GUEST; - try { - this.handleRequest(null, request.user, null, context); - } catch (error) { - throw new UnauthorizedException(); - } - return true; - } - - return super.canActivate(context); - } - - handleRequest(err: any, user: any, info: any, context: any, status?: any): TUser { - const u = super.handleRequest(err, user, info, context, status) as User; - const requireAuth = this.reflector.get(REQUIRE_ROLES, context.getHandler()); - - if (requireAuth[0] === 'allow' && !requireAuth.includes(u.role)) { - throw new ForbiddenException(FORBIDDEN_USER_ROLE); - } - - if (requireAuth[0] === 'deny' && requireAuth.includes(u.role)) { - throw new ForbiddenException(FORBIDDEN_USER_ROLE); - } - - return user; - } -} diff --git a/apps/api/src/auth/jwt-auth/__test__/jwt-auth.guard.test.ts b/apps/api/src/auth/jwt-auth/__test__/jwt-auth.guard.test.ts new file mode 100644 index 00000000..ebde23af --- /dev/null +++ b/apps/api/src/auth/jwt-auth/__test__/jwt-auth.guard.test.ts @@ -0,0 +1,163 @@ +import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; +import { User } from '@app/entity/user/user.entity'; +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { mock, mockFn } from 'jest-mock-extended'; +import { AuthDecoratorParam } from '../../types'; +import { JwtAuthGuard } from '../jwt-auth.guard'; + +describe('JwtAuthGuard', () => { + const mockRelfector = mock(); + const context = new ExecutionContextHost([]); + const mockSuperCanActivate = mockFn(); + const mockSuperHandleRequest = mockFn(); + const jwtAuthGuard = new JwtAuthGuard(mockRelfector); + + beforeAll(async () => { + // ๋ถ€๋ชจ ํด๋ž˜์Šค mock ์ฒ˜๋ฆฌ + (JwtAuthGuard.prototype as any).__proto__.canActivate = mockSuperCanActivate; + (JwtAuthGuard.prototype as any).__proto__.handleRequest = mockSuperHandleRequest; + }); + + beforeEach(async () => { + mockRelfector.get.mockClear(); + mockSuperCanActivate.mockClear(); + mockSuperHandleRequest.mockClear(); + jest.clearAllTimers(); + }); + + describe('canActivate', () => { + test('REQUIRE_ROLES ๊ฐ€ ์—†์œผ๋ฉด ๋ฌด์กฐ๊ฑด true ์ด๋‹ค. ', async () => { + mockRelfector.get.mockReturnValue(undefined); + + const result = jwtAuthGuard.canActivate(context); + + expect(result).toBe(true); + expect(mockSuperCanActivate).toBeCalledTimes(0); + }); + + test('REQUIRE_ROLES ๊ฐ€ ์žˆ์œผ๋ฉด super๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['allow'] as AuthDecoratorParam); + mockSuperCanActivate.mockReturnValue(true); + + const result = jwtAuthGuard.canActivate(context); + + expect(result).toBe(true); + expect(mockSuperCanActivate).toBeCalledTimes(1); + }); + }); + + describe('handleRequest', () => { + test('allow ํ•˜๋Š” ๊ถŒํ•œ์ด ์•„์˜ˆ ์—†์œผ๋ฉด ์•„๋ฌด ๊ถŒํ•œ๋„ ํ†ต๊ณผํ•˜์ง€ ๋ชปํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['allow'] as AuthDecoratorParam); + + const user = new User(); + user.role = UserRole.ADMIN; + mockSuperHandleRequest.mockReturnValue(user); + + const act = () => jwtAuthGuard.handleRequest(null, null, null, context); + + expect(act).toThrowError(ForbiddenException); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + expect(act).toThrowErrorMatchingInlineSnapshot(`"์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ"`); + }); + + test('allow ํ•˜๋Š” ๊ถŒํ•œ์— ํฌํ•จ๋˜์–ด์žˆ์œผ๋ฉด ํ†ต๊ณผํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['allow', UserRole.ADMIN] as AuthDecoratorParam); + + const user = new User(); + user.role = UserRole.ADMIN; + mockSuperHandleRequest.mockReturnValue(user); + + const result = jwtAuthGuard.handleRequest(null, null, null, context); + + expect(result).toBe(user); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + }); + + test('allow ํ•˜๋Š” ๊ถŒํ•œ์— ํฌํ•จ๋˜์–ด์žˆ์ง€ ์•Š์œผ๋ฉด ํ†ต๊ณผํ•˜์ง€ ๋ชปํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['allow', UserRole.ADMIN] as AuthDecoratorParam); + + const user = new User(); + user.role = UserRole.GUEST; + mockSuperHandleRequest.mockReturnValue(user); + + const act = () => jwtAuthGuard.handleRequest(null, null, null, context); + + expect(act).toThrowError(ForbiddenException); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + expect(act).toThrowErrorMatchingInlineSnapshot(`"์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ"`); + }); + + test('deny ํ•˜๋Š” ๊ถŒํ•œ์ด ์•„์˜ˆ ์—†์œผ๋ฉด ๋ชจ๋“  ๊ถŒํ•œ์ด ํ†ต๊ณผํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['deny'] as AuthDecoratorParam); + + const user = new User(); + user.role = UserRole.ADMIN; + mockSuperHandleRequest.mockReturnValue(user); + + const result = jwtAuthGuard.handleRequest(null, null, null, context); + + expect(result).toBe(user); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + }); + + test('deny ํ•˜๋Š” ๊ถŒํ•œ์— ํฌํ•จ๋˜์–ด์žˆ์œผ๋ฉด ํ†ต๊ณผํ•˜์ง€ ์•Š๋Š”๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['deny', UserRole.ADMIN] as AuthDecoratorParam); + + const user = new User(); + user.role = UserRole.ADMIN; + mockSuperHandleRequest.mockReturnValue(user); + + const act = () => jwtAuthGuard.handleRequest(null, null, null, context); + + expect(act).toThrowError(ForbiddenException); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + expect(act).toThrowErrorMatchingInlineSnapshot(`"์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ"`); + }); + + test('deny ํ•˜๋Š” ๊ถŒํ•œ์— ํฌํ•จ๋˜์–ด์žˆ์ง€ ์•Š์œผ๋ฉด ํ†ต๊ณผํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['deny', UserRole.GUEST] as AuthDecoratorParam); + + const user = new User(); + user.role = UserRole.ADMIN; + mockSuperHandleRequest.mockReturnValue(user); + + const result = jwtAuthGuard.handleRequest(null, null, null, context); + + expect(result).toBe(user); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + }); + + test('jwt ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์„๋•Œ, GUEST ๊ถŒํ•œ์ด ํ—ˆ๋ฝ๋˜๋ฉด GUEST๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['allow', UserRole.GUEST] as AuthDecoratorParam); + + // jwt ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์„๋•Œ ์—๋Ÿฌ๋ฅผ ๋˜์ง. + mockSuperHandleRequest.mockImplementation(() => { + throw new UnauthorizedException(); + }); + + const result = jwtAuthGuard.handleRequest(null, null, null, context) as User; + + expect(result.id).toBe(-1); + expect(result.role).toBe(UserRole.GUEST); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + }); + + test('jwt ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์„๋•Œ, GUEST ๊ถŒํ•œ๋„ ํ—ˆ๋ฝ๋˜์ง€ ์•Š์œผ๋ฉด ํ†ต๊ณผํ•˜์ง€ ๋ชปํ•œ๋‹ค.', async () => { + mockRelfector.get.mockReturnValue(['deny', UserRole.GUEST] as AuthDecoratorParam); + + // jwt ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์„๋•Œ ์—๋Ÿฌ๋ฅผ ๋˜์ง. + mockSuperHandleRequest.mockImplementation(() => { + throw new UnauthorizedException(); + }); + + const act = () => jwtAuthGuard.handleRequest(null, null, null, context) as User; + + expect(act).toThrowError(UnauthorizedException); + expect(mockSuperHandleRequest).toBeCalledTimes(1); + expect(act).toThrowErrorMatchingInlineSnapshot(`"Unauthorized"`); + }); + }); +}); diff --git a/apps/api/src/auth/jwt-auth/__test__/jwt-auth.module.test.ts b/apps/api/src/auth/jwt-auth/__test__/jwt-auth.module.test.ts new file mode 100644 index 00000000..8fd70e75 --- /dev/null +++ b/apps/api/src/auth/jwt-auth/__test__/jwt-auth.module.test.ts @@ -0,0 +1,13 @@ +import { JwtAuthModule } from '../jwt-auth.module'; + +describe('JwtAuthGuard', () => { + test('๋ชจ๋“ˆ์ด ์ž˜ ์ปดํŒŒ์ผ๋œ๋‹ค.', async () => { + // TODO: Test.createTestingModule ๋กœ complie ํ• ๊ฒƒ + // const module = await Test.createTestingModule({ + // imports: [JwtAuthModule], + // }).compile(); + const module = new JwtAuthModule(); + + expect(module).toBeDefined(); + }); +}); diff --git a/apps/api/src/auth/jwt-auth/__test__/jwt-auth.strategy.test.ts b/apps/api/src/auth/jwt-auth/__test__/jwt-auth.strategy.test.ts new file mode 100644 index 00000000..59328e68 --- /dev/null +++ b/apps/api/src/auth/jwt-auth/__test__/jwt-auth.strategy.test.ts @@ -0,0 +1,69 @@ +import { UserService } from '@api/user/user.service'; +import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; +import { User } from '@app/entity/user/user.entity'; +import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { mock, mockFn } from 'jest-mock-extended'; +import { JWTPayload } from '../../types'; +import { getAccessToken, JwtAuthStrategy } from '../jwt-auth.strategy'; + +describe('JwtAuthStrategy', () => { + const mockConfigService = mock({ + get: mockFn().mockReturnValue('test'), + }); + const mockUserService = mock(); + const jwtAuthStrategy = new JwtAuthStrategy(mockUserService, mockConfigService); + const payload: JWTPayload = { + userId: 1, + userRole: UserRole.GUEST, + }; + + beforeEach(() => { + mockUserService.findOneByIdOrFail.mockClear(); + jest.clearAllTimers(); + }); + + describe('validate', () => { + test('์œ ์ €๊ฐ€ ์กด์žฌํ•˜๋ฉด ์œ ์ €๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค', async () => { + const user = new User(); + mockUserService.findOneByIdOrFail.mockResolvedValue(user); + + const result = await jwtAuthStrategy.validate(payload); + + expect(result).toBe(user); + expect(mockUserService.findOneByIdOrFail).toBeCalledTimes(1); + }); + + test('์œ ์ €๊ฐ€ ์—†์œผ๋ฉด UnauthorizedException ์—๋Ÿฌ๋ฅผ ๋˜์ง„๋‹ค', async () => { + mockUserService.findOneByIdOrFail.mockRejectedValue(new NotFoundException()); + + const act = async () => await jwtAuthStrategy.validate(payload); + + expect(act).rejects.toThrowError(UnauthorizedException); + expect(mockUserService.findOneByIdOrFail).toBeCalledTimes(1); + }); + + test('์—๋Ÿฌ๊ฐ€ ๋‚˜๋ฉด ์—๋Ÿฌ๋ฅผ ๋˜์ง„๋‹ค', async () => { + mockUserService.findOneByIdOrFail.mockRejectedValue(new Error()); + + const act = async () => await jwtAuthStrategy.validate(payload); + + expect(act).rejects.toThrowError(Error); + expect(mockUserService.findOneByIdOrFail).toBeCalledTimes(1); + }); + }); + + describe('getAccessToken', () => { + test('์ฟ ํ‚ค์—์„œ ACCESS_TOKEN_KEY๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค', () => { + const request = { + cookies: { + ACCESS_TOKEN_KEY: 'test', + }, + }; + + const result = getAccessToken('ACCESS_TOKEN_KEY', request); + + expect(result).toBe('test'); + }); + }); +}); diff --git a/apps/api/src/auth/jwt-auth/jwt-auth.guard.ts b/apps/api/src/auth/jwt-auth/jwt-auth.guard.ts new file mode 100644 index 00000000..060829b9 --- /dev/null +++ b/apps/api/src/auth/jwt-auth/jwt-auth.guard.ts @@ -0,0 +1,65 @@ +import { FORBIDDEN_USER_ROLE, REQUIRE_ROLES } from '@api/auth/constant'; +import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; +import { User } from '@app/entity/user/user.entity'; +import { ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { AuthDecoratorParam } from '../types'; + +/** + * Custom AuthGuard to check public handler and user roles + * @see also https://docs.nestjs.com/security/authentication#extending-guards + * + * 1. JwtAuthGuard.canActivate -> check if handler is public or not + * 2. JwtStrategy.validate -> get user from jwt payload or db + * 3. JwtAuthGuard.handleRequest -> check user roles + */ +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const requireAuth = this.reflector.get(REQUIRE_ROLES, context.getHandler()); + + if (!requireAuth) { + return true; + } + + return super.canActivate(context); + } + + handleRequest(err: any, _user: any, info: any, context: any, status?: any): TUser { + const requireAuth = this.reflector.get(REQUIRE_ROLES, context.getHandler()); + + let user: User; + + try { + // verity JWT token + user = super.handleRequest(err, _user, info, context, status); + } catch (error) { + // Auth('public') ์ธ๊ฒฝ์šฐ Guest ๋กœ ์ฒ˜๋ฆฌ + if (this.isAuthAllowRole(requireAuth, UserRole.GUEST)) { + user = new User(); + user.id = -1; + user.role = UserRole.GUEST; + return user as unknown as TUser; + } + throw error; + } + + if (!this.isAuthAllowRole(requireAuth, user.role)) { + throw new ForbiddenException(FORBIDDEN_USER_ROLE); + } + + return user as unknown as TUser; + } + + private isAuthAllowRole(requireAuth: AuthDecoratorParam, role: UserRole): boolean { + return ( + (requireAuth[0] === 'allow' && requireAuth.includes(role)) || + (requireAuth[0] === 'deny' && !requireAuth.includes(role)) + ); + } +} diff --git a/apps/api/src/auth/jwt-auth/jwt-auth.module.ts b/apps/api/src/auth/jwt-auth/jwt-auth.module.ts new file mode 100644 index 00000000..1a4e6661 --- /dev/null +++ b/apps/api/src/auth/jwt-auth/jwt-auth.module.ts @@ -0,0 +1,10 @@ +import { UserModule } from '@api/user/user.module'; +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { JwtAuthStrategy } from './jwt-auth.strategy'; + +@Module({ + imports: [UserModule, PassportModule], + providers: [JwtAuthStrategy], +}) +export class JwtAuthModule {} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt-auth/jwt-auth.strategy.ts similarity index 72% rename from apps/api/src/auth/jwt.strategy.ts rename to apps/api/src/auth/jwt-auth/jwt-auth.strategy.ts index 72acf235..6390f1fc 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt-auth/jwt-auth.strategy.ts @@ -4,17 +4,17 @@ import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/co import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import { JWTPayload } from './interfaces/jwt-payload.interface'; +import { JWTPayload } from '../types'; -export const getAccessToken = (request: any): string => { - return request.cookies[process.env.ACCESS_TOKEN_KEY]; +export const getAccessToken = (key: string, request: any): string => { + return request.cookies[key]; }; @Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { +export class JwtAuthStrategy extends PassportStrategy(Strategy) { constructor(private userService: UserService, private configService: ConfigService) { super({ - jwtFromRequest: ExtractJwt.fromExtractors([getAccessToken]), + jwtFromRequest: ExtractJwt.fromExtractors([getAccessToken.bind(null, configService.get('ACCESS_TOKEN_KEY'))]), ignoreExpiration: false, secretOrKey: configService.get('JWT_SECRET'), }); diff --git a/apps/api/src/auth/types/auth.type.ts b/apps/api/src/auth/types/auth.type.ts new file mode 100644 index 00000000..618ae70a --- /dev/null +++ b/apps/api/src/auth/types/auth.type.ts @@ -0,0 +1,5 @@ +import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; + +export type AuthType = 'allow' | 'deny'; + +export type AuthDecoratorParam = [AuthType, ...UserRole[]]; diff --git a/apps/api/src/auth/interfaces/github-profile.interface.ts b/apps/api/src/auth/types/github-profile.interface.ts similarity index 100% rename from apps/api/src/auth/interfaces/github-profile.interface.ts rename to apps/api/src/auth/types/github-profile.interface.ts diff --git a/apps/api/src/auth/types/index.ts b/apps/api/src/auth/types/index.ts new file mode 100644 index 00000000..1c4fb419 --- /dev/null +++ b/apps/api/src/auth/types/index.ts @@ -0,0 +1,3 @@ +export * from './auth.type'; +export * from './github-profile.interface'; +export * from './jwt-payload.interface'; diff --git a/apps/api/src/auth/interfaces/jwt-payload.interface.ts b/apps/api/src/auth/types/jwt-payload.interface.ts similarity index 100% rename from apps/api/src/auth/interfaces/jwt-payload.interface.ts rename to apps/api/src/auth/types/jwt-payload.interface.ts diff --git a/apps/api/src/user/dto/base-user.dto.ts b/apps/api/src/user/dto/base-user.dto.ts index a08fd04f..b7fe83c4 100644 --- a/apps/api/src/user/dto/base-user.dto.ts +++ b/apps/api/src/user/dto/base-user.dto.ts @@ -16,10 +16,10 @@ export class BaseUserDto { @IsInt() @Min(0) - @Max(10) + @Max(11) @ApiProperty({ minimum: 0, - maximum: 10, + maximum: 11, }) character!: number; diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index e11945bf..b27c67c7 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -1,6 +1,4 @@ -import { GithubProfile } from '@api/auth/interfaces/github-profile.interface'; import { User } from '@app/entity/user/user.entity'; -import { getNextMonth } from '@app/utils/utils'; import { Injectable } from '@nestjs/common'; import { UpdateUserProfileRequestDto } from './dto/request/update-user-profile-request.dto'; import { UpdateToCadetDto } from './dto/update-user-to-cadet.dto'; @@ -10,24 +8,12 @@ import { UserRepository } from './repositories/user.repository'; export class UserService { constructor(private readonly userRepository: UserRepository) {} - async githubLogin(githubProfile: GithubProfile): Promise { - const user = await this.userRepository.findOne({ - githubUid: githubProfile.id, - }); - - if (user) { - user.lastLogin = getNextMonth(); - return this.userRepository.save(user); - } - - const newUser = { - nickname: githubProfile.username, - githubUsername: githubProfile.username, - githubUid: githubProfile.id, - lastLogin: getNextMonth(), - }; + async create(user: User): Promise { + return this.userRepository.save(user); + } - return this.userRepository.save(newUser); + async findOneByGithubUId(githubUid: string): Promise { + return this.userRepository.findOne({ where: { githubUid } }); } async findOneByIdOrFail(id: number): Promise { diff --git a/apps/api/test/e2e/article.e2e-spec.ts b/apps/api/test/e2e/article.e2e-spec.ts index 8eea4a66..88f568c2 100644 --- a/apps/api/test/e2e/article.e2e-spec.ts +++ b/apps/api/test/e2e/article.e2e-spec.ts @@ -673,8 +673,10 @@ describe('Article', () => { const searchWord = '42'; const titleWithSearchWord = 'aaa42aaa'; const titleWithoutSearchWord = 'aaaaaa'; + const titleWithImage = 'cccccc'; const contentWithSearchWord = 'bbb42bbb'; const contentWithoutSearchWord = 'bbbbbb'; + const contentWithImage = '![image.png](https://42world-image.s3.ap-northeast-2.amazonaws.com/111111111.png)'; const SearchArticleRequestDto = { q: searchWord, }; @@ -779,6 +781,21 @@ describe('Article', () => { expect(responseArticles.length).toBe(1); expect(responseArticles[0].content).toBe(contentWithSearchWord); }); + + test('[์„ฑ๊ณต] GET - ์ด๋ฏธ์ง€ ํฌํ•จ', async () => { + await articleRepository.save( + dummy.article(categories.free.id, users.cadet[0].id, titleWithImage, contentWithImage), + ); + + const response = await request(httpServer) + .get('/articles/search') + .query(SearchArticleRequestDto) + .set('Cookie', `${process.env.ACCESS_TOKEN_KEY}=${cadetJWT}`); + + expect(response.status).toEqual(HttpStatus.OK); + const responseArticles = response.body.data as Article[]; + expect(responseArticles.length).toBe(0); + }); }); // ์นดํ…Œ๊ณ ๋ฆฌ ๋ณ„ ๊ฒ€์ƒ‰ diff --git a/apps/api/test/e2e/e2e-test.base.module.ts b/apps/api/test/e2e/e2e-test.base.module.ts index a002f6db..07b955eb 100644 --- a/apps/api/test/e2e/e2e-test.base.module.ts +++ b/apps/api/test/e2e/e2e-test.base.module.ts @@ -1,4 +1,4 @@ -import { JwtAuthGuard } from '@api/auth/jwt-auth.guard'; +import { JwtAuthGuard } from '@api/auth/jwt-auth/jwt-auth.guard'; import { AWS_ACCESS_KEY, AWS_REGION, AWS_SECRET_KEY } from '@api/image/image.constant'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; diff --git a/apps/api/test/e2e/utils/dummy.ts b/apps/api/test/e2e/utils/dummy.ts index e50a7fb9..4888b6b9 100644 --- a/apps/api/test/e2e/utils/dummy.ts +++ b/apps/api/test/e2e/utils/dummy.ts @@ -1,6 +1,5 @@ import { ArticleRepository } from '@api/article/repositories/article.repository'; import { AuthService } from '@api/auth/auth.service'; -import { JWTPayload } from '@api/auth/interfaces/jwt-payload.interface'; import { CategoryRepository } from '@api/category/repositories/category.repository'; import { CommentRepository } from '@api/comment/repositories/comment.repository'; import { UserRepository } from '@api/user/repositories/user.repository'; @@ -14,10 +13,7 @@ import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; import { User } from '@app/entity/user/user.entity'; export const jwt = (user: User, authService: AuthService): string => { - return authService.getJWT({ - userId: user.id, - userRole: user.role, - } as JWTPayload); + return authService.getJwt(user); }; export const user = ( diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 152797db..8f12e48e 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -61,9 +61,8 @@ services: order: start-first #----------------------------------------------------------------------------------- db: - image: mysql:5.7 + image: mysql:8.0 container_name: 42world-backend-db - platform: linux/x86_64 ports: - '${DB_PORT}:3306' environment: diff --git a/infra/run_test_db.sh b/infra/run_test_db.sh index 8f62976c..02231ef0 100755 --- a/infra/run_test_db.sh +++ b/infra/run_test_db.sh @@ -2,7 +2,6 @@ if ! $( docker container inspect -f '{{.State.Running}}' ft_world-mysql-test 2> /dev/null ); then docker run -d --rm --name ft_world-mysql-test \ - --platform linux/x86_64 \ -e MYSQL_DATABASE=ft_world \ -e MYSQL_USER=ft_world \ -e MYSQL_PASSWORD=ft_world \ @@ -14,6 +13,6 @@ if ! $( docker container inspect -f '{{.State.Running}}' ft_world-mysql-test 2> --health-start-period=0s \ --health-timeout=1s \ -e TZ=Asia/Seoul \ - -p 3308:3306 mysql:5.7 \ + -p 3308:3306 mysql:8.0 \ mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci fi \ No newline at end of file diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 00000000..69d7ccb6 --- /dev/null +++ b/jest.config.json @@ -0,0 +1,16 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testMatch": ["**/?(*.)+(spec|test).+(ts)"], + "transform": { "^.+\\.ts$": "ts-jest" }, + "collectCoverageFrom": ["**/*.ts", "!**/__test__/**/*.ts", "!**/index.ts"], + "coverageDirectory": "./coverage", + "testEnvironment": "node", + "moduleNameMapper": { + "^@api(|/.*)$": "/apps/api/src/$1", + "^@test/(.*)$": "/apps/api/test/$1", + "^@app/common(|/.*)$": "/libs/common/src/$1", + "^@app/utils(|/.*)$": "/libs/utils/src/$1", + "^@app/entity(|/.*)$": "/libs/entity/src/$1" + } +} diff --git a/libs/utils/src/utils.ts b/libs/utils/src/utils.ts index daa460dd..eed08a1e 100644 --- a/libs/utils/src/utils.ts +++ b/libs/utils/src/utils.ts @@ -1,7 +1,6 @@ import { PaginationRequestDto } from '@api/pagination/dto/pagination-request.dto'; import { UserRole } from '@app/entity/user/interfaces/userrole.interface'; import axios from 'axios'; -import { CookieOptions } from 'express'; import { logger } from './logger'; export const MINUTE = 60; @@ -14,24 +13,11 @@ export function getRandomInt(min: number, max: number) { return Math.floor(Math.random() * (max - min)) + min; //์ตœ๋Œ“๊ฐ’์€ ์ œ์™ธ, ์ตœ์†Ÿ๊ฐ’์€ ํฌํ•จ } -export const getNextMonth = () => { - const now = new Date(); - return new Date(now.setMonth(now.getMonth() + 1)); -}; - export const isExpired = (exp: Date): boolean => { const now = new Date(); return now >= exp; }; -export const getCookieOption = (): CookieOptions => { - if (process.env.NODE_ENV === 'prod') { - return { httpOnly: true, secure: true, sameSite: 'lax' }; - } else if (process.env.NODE_ENV === 'alpha') { - return { httpOnly: true, secure: true, sameSite: 'none' }; - } - return {}; -}; export const errorHook = async (exceptionName: string, exceptionMessage: string) => { const phase = process.env.NODE_ENV; diff --git a/package.json b/package.json index a3552859..b8f79232 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,9 @@ "admin": "NODE_ENV=dev nest start admin", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "prettier": "prettier --write ./**/*.ts", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test": "NODE_ENV=test jest --maxWorkers=6 --config ./jest.config.json", + "test:watch": "yarn test --watch", + "test:cov": "yarn test --coverage", "test:e2e": "NODE_ENV=test jest --runInBand --config ./apps/api/test/e2e/jest-e2e.json", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config libs/common/src/database/ormconfig.ts", "typeorm:migrate": "yarn typeorm migration:generate -n", @@ -94,6 +93,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", "jest": "^27.0.6", + "jest-mock-extended": "^2.0.7", "prettier": "^2.3.2", "prettier-plugin-organize-imports": "^2.3.4", "supertest": "^6.1.3", @@ -102,32 +102,6 @@ "ts-mockito": "^2.6.1", "ts-node": "^10.0.0", "tsconfig-paths": "^3.10.1", - "typescript": "^4.3.5" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": ".", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "./coverage", - "testEnvironment": "node", - "roots": [ - "/apps/", - "/libs/" - ], - "moduleNameMapper": { - "^@app/common(|/.*)$": "/libs/common/src/$1", - "^@app/utils(|/.*)$": "/libs/utils/src/$1", - "^@app/entity(|/.*)$": "/libs/entity/src/$1" - } + "typescript": "4.3.5" } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6b..8391f52b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*test.ts", "**/__test__/**/*.ts"] } diff --git a/yarn.lock b/yarn.lock index 65c254d2..df691182 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5448,6 +5448,13 @@ jest-message-util@^27.5.1: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock-extended@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-2.0.7.tgz#73ad87d8a744949bc3415d840f03468229f73f2a" + integrity sha512-h8brJJN5BZb03hTwplvt+raT6Nj0U2U71Z26Py12Qc3kvYnAjDW/zSuQJLnXCNyyufy592VC9k3X7AOz+2H52g== + dependencies: + ts-essentials "^7.0.3" + jest-mock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" @@ -7873,9 +7880,9 @@ terser-webpack-plugin@^5.1.3: terser "^5.7.2" terser@^4.7.0: - version "4.8.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17" - integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw== + version "4.8.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f" + integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw== dependencies: commander "^2.20.0" source-map "~0.6.1" @@ -8024,6 +8031,11 @@ triple-beam@^1.3.0: resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== +ts-essentials@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" + integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + ts-jest@^27.0.3: version "27.1.4" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-27.1.4.tgz#84d42cf0f4e7157a52e7c64b1492c46330943e00" @@ -8212,16 +8224,16 @@ typeorm@^0.2.41: yargs "^17.0.1" zen-observable-ts "^1.0.0" +typescript@4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" + integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== + typescript@4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg== -typescript@^4.3.5: - version "4.6.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" - integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== - uid-safe@2.1.5, uid-safe@~2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"