diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 7e8d331..3f96196 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -16,7 +16,7 @@ steps: - set - image - deployment/groomeong-backend - - backend-sha256-1=asia.gcr.io/project-groomeong/backend:1.6 + - backend-sha256-1=asia.gcr.io/project-groomeong/backend:1.7 env: - CLOUDSDK_COMPUTE_ZONE=asia-northeast3 - CLOUDSDK_CONTAINER_CLUSTER=autopilot-cluster-5 diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index e822bfd..44f327e 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -2,7 +2,7 @@ version: '3.7' services: my-backend: - image: asia.gcr.io/project-groomeong/backend:1.6 + image: asia.gcr.io/project-groomeong/backend:1.7 platform: linux/x86_64 build: context: . diff --git a/src/apis/auth/__test__/auth.resolver.spec.ts b/src/apis/auth/__test__/auth.resolver.spec.ts index 38ddc8c..4233021 100644 --- a/src/apis/auth/__test__/auth.resolver.spec.ts +++ b/src/apis/auth/__test__/auth.resolver.spec.ts @@ -15,9 +15,9 @@ const EXAMPLE_USER: User = { password: 'exampleUserPassword', phone: 'exampleUserPhone', image: 'exampleUserImage', - createAt: new Date(), - deleteAt: new Date(), - updateAt: new Date(), + createdAt: new Date(), + deletedAt: new Date(), + updatedAt: new Date(), dogs: [null], reservation: [null], }; diff --git a/src/apis/auth/__test__/auth.service.spec.ts b/src/apis/auth/__test__/auth.service.spec.ts index 95d1a91..4fd7978 100644 --- a/src/apis/auth/__test__/auth.service.spec.ts +++ b/src/apis/auth/__test__/auth.service.spec.ts @@ -1,6 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { CACHE_MANAGER, UnprocessableEntityException } from '@nestjs/common'; +import { + CACHE_MANAGER, + UnauthorizedException, + UnprocessableEntityException, +} from '@nestjs/common'; import { Cache } from 'cache-manager'; import { UsersService } from 'src/apis/users/user.service'; import { User } from 'src/apis/users/entities/user.entity'; @@ -8,8 +11,6 @@ import { AuthService } from '../auth.service'; import { IContext } from 'src/commons/interface/context'; import { JwtService } from '@nestjs/jwt'; import * as httpMocks from 'node-mocks-http'; -import { MockUsersRepository } from './auth.mocking.dummy'; -import { MailerModule } from '@nestjs-modules/mailer'; jest.mock('../auth.service'); @@ -20,9 +21,9 @@ const EXAMPLE_USER: User = { password: 'exampleUserPassword', phone: 'exampleUserPhone', image: 'exampleUserImage', - createAt: new Date(), - deleteAt: new Date(), - updateAt: new Date(), + createdAt: new Date(), + deletedAt: new Date(), + updatedAt: new Date(), dogs: [null], reservation: [null], }; @@ -30,35 +31,41 @@ const EXAMPLE_USER: User = { describe('AuthResolver', () => { let authService: AuthService; let usersService: UsersService; - let jwtService: JwtService; - let cache: Cache; - let context: IContext; + let jwt: JwtService; + let cacheManager: Cache; + + const context: IContext = { + req: httpMocks.createRequest(), + res: httpMocks.createResponse(), + }; + context.req.user = new User(); + context.req.user.id = EXAMPLE_USER.id; + const email = EXAMPLE_USER.email; + const password = EXAMPLE_USER.password; + const process = { + env: { + JWT_ACCESS_KEY: 'exampleAccess', + JWT_REFRESH_KEY: 'exampleRefresh', + }, + }; beforeEach(async () => { jest.clearAllMocks(); - const usersModule: TestingModule = await Test.createTestingModule({ - imports: [ - MailerModule.forRootAsync({ - useFactory: () => ({ - transport: { - service: 'Gmail', - host: process.env.EMAIL_HOST, - port: Number(process.env.DATABASE_PORT), - secure: false, - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, - }, - }), - }), - ], + const modules: TestingModule = await Test.createTestingModule({ providers: [ AuthService, - UsersService, { - provide: getRepositoryToken(User), - useClass: MockUsersRepository, + provide: UsersService, + useValue: { + findOneByEmail: jest.fn(() => EXAMPLE_USER), + create: jest.fn(() => EXAMPLE_USER), + }, + }, + { + provide: JwtService, + useValue: { + verify: jest.fn(() => 'EXAMPLE_TOKEN'), + }, }, { provide: CACHE_MANAGER, @@ -69,22 +76,13 @@ describe('AuthResolver', () => { }, ], }).compile(); - const authModule: TestingModule = await Test.createTestingModule({ - providers: [AuthService], - }).compile(); - usersService = usersModule.get(UsersService); - authService = authModule.get(AuthService); - cache = usersModule.get(CACHE_MANAGER); - context = { - req: httpMocks.createRequest(), - res: httpMocks.createResponse(), - }; + authService = modules.get(AuthService); + usersService = modules.get(UsersService); + jwt = modules.get(JwtService); + cacheManager = modules.get(CACHE_MANAGER); }); - const email = EXAMPLE_USER.email; - const password = EXAMPLE_USER.password; - describe('login', () => { it('의존성주입한 usersService 에서 email로 찾아오기', async () => { jest @@ -129,10 +127,58 @@ describe('AuthResolver', () => { }); describe('logout', () => { - // - }); + const req = context.req; + it('토큰 증명해서 로그아웃 인가', async () => { + const req = { + headers: { + authorization: 'Bearer ACCESS_TOKEN', + cookie: 'refreshToken=REFRESH_TOKEN', + }, + }; - describe('restoreAccessToken', () => { - // + try { + const accessToken = await req.headers['authorization'].replace( + 'Bearer ', + '', + ); + const refreshToken = await req.headers['cookie'].split( + 'refreshToken=', + )[1]; + + // accessToken 토큰 + const jwtAccessKey = jwt.verify(accessToken); + + // refresh 토큰 + const jwtRefreshKey = jwt.verify(refreshToken); + + await cacheManager.set(`accessToken:${accessToken}`, 'accessToken', { + ttl: jwtAccessKey['exp'] - jwtAccessKey['iat'], + }); + + await cacheManager.set(`refreshToken:${refreshToken}`, 'refreshToken', { + ttl: jwtRefreshKey['exp'] - jwtRefreshKey['iat'], + }); + + expect(jwt.verify).toBeCalled(); + + return '로그아웃에 성공했습니다.'; + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('로그아웃을 실패했습니다.'); + throw new UnauthorizedException('로그아웃을 실패했습니다.'); + } + }); + + describe('restoreAccessToken', () => { + const user = EXAMPLE_USER; + + beforeEach(async () => { + await authService.getAccessToken({ user }); + }); + + it('getAccessToken should be called', () => { + expect(authService.getAccessToken).toBeCalled(); + }); + }); }); }); diff --git a/src/apis/auth/auth.service.ts b/src/apis/auth/auth.service.ts index 189cde2..c4f4c11 100644 --- a/src/apis/auth/auth.service.ts +++ b/src/apis/auth/auth.service.ts @@ -79,6 +79,48 @@ export class AuthService { }, ); + // 개발 환경 + // res.setHeader('Authorization', ''); // Authorization 헤더 값을 빈 문자열로 설정합니다. + // res.clearCookie('refreshToken'); // refreshToken 쿠키를 삭제합니다. + + // 배포 환경 + const originList = [ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://34.64.53.80:3000', + 'https://groomeong.shop', + 'https://groomeong.store', + ]; + const origin = req.headers.origin; + if (originList.includes(origin)) { + // 리소스에 엑세스하기 위해 코드 요청을 허용하도록 브라우저에 알리는 응답 + res.setHeader('Access-Control-Allow-Origin', origin); + } + + // 프런트엔드 js 코드에 대한 응답을 노출할지 여부를 브라우저에 알려준다. + res.setHeader('Access-Control-Allow-Credentials', 'true'); + // 리소스에 엑세스할 때 허용되는 하나 이상의 메서드를 지정해준다. + res.setHeader( + 'Access-Control-Allow-Methods', // + 'GET, HEAD, OPTIONS, POST, PUT', + ); + // 실제 요청 중에 사용할 수 있는 HTTP 헤더를 나타내는 실행 전 요청에 대한 응답. + // X-Custom-Header => 서버에 대한 cors 요청에 의해 지원 + // Upgrade-Insecure-Requests => 여러 헤더에 대한 지원을 지정 + res.setHeader( + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Headers, Origin,Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers', + ); + + res.clearCookie('refreshToken', { + path: '/', + domain: '.groomeong.shop', + secure: true, + httpOnly: true, + sameSite: 'none', + maxAge: 0, + }); + return '로그아웃 성공'; } catch (err) { throw new UnauthorizedException('로그아웃 실패'); @@ -92,7 +134,7 @@ export class AuthService { getAccessToken({ user }: IAuthServiceGetAccessToken): string { return this.jwtService.sign( { sub: user.id, email: user.email }, //ƒ - { secret: process.env.JWT_ACCESS_KEY, expiresIn: '2w' }, + { secret: process.env.JWT_ACCESS_KEY, expiresIn: '10h' }, ); } @@ -101,7 +143,12 @@ export class AuthService { { sub: user.id, email: user.email }, // { secret: process.env.JWT_REFRESH_KEY, expiresIn: '2w' }, ); + console.log('🐳🐳🐳🐳🐳', refreshToken); + + // 로컬(개발환경) + // res.setHeader('set-Cookie', `refreshToken=${refreshToken}; path=/;`); + // 배포 환경 const originList = [ 'http://localhost:3000', 'http://127.0.0.1:3000', @@ -126,8 +173,8 @@ export class AuthService { // X-Custom-Header => 서버에 대한 cors 요청에 의해 지원 // Upgrade-Insecure-Requests => 여러 헤더에 대한 지원을 지정 res.setHeader( - 'Access-Control-Allow-Headers', // - 'Access-Control-Allow-Headers, Origin, Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers', + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Headers, Origin,Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers', ); res.setHeader( diff --git a/src/apis/auth/strategies/jwt-refresh.strategy.ts b/src/apis/auth/strategies/jwt-refresh.strategy.ts index 50f5a00..331ec56 100644 --- a/src/apis/auth/strategies/jwt-refresh.strategy.ts +++ b/src/apis/auth/strategies/jwt-refresh.strategy.ts @@ -11,8 +11,7 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'refresh') { super({ jwtFromRequest: (req) => { const cookie = req.headers.cookie; - const refreshToken = cookie.replace('refreshToken=', ''); - return refreshToken; + if (cookie) return cookie.replace('refreshToken=', ''); }, secretOrKey: process.env.JWT_REFRESH_KEY, passReqToCallback: true, diff --git a/src/apis/dogs/__test__/dogs.resolver.spec.ts b/src/apis/dogs/__test__/dogs.resolver.spec.ts index 94f2909..f38bf54 100644 --- a/src/apis/dogs/__test__/dogs.resolver.spec.ts +++ b/src/apis/dogs/__test__/dogs.resolver.spec.ts @@ -1,13 +1,14 @@ -import { - CanActivate, - ExecutionContext, - NotFoundException, -} from '@nestjs/common'; +import { CanActivate, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { DogsResolver } from '../dogs.resolver'; import { DogsService } from '../dogs.service'; import { DOG_TYPE } from '../enum/dog-type.enum'; import { GqlAuthGuard } from '../../auth/guards/gql-auth.guard'; +import * as httpMocks from 'node-mocks-http'; +import { IContext } from 'src/commons/interface/context'; +import { User } from 'src/apis/users/entities/user.entity'; +import { CreateDogInput } from '../dto/create-dog.input'; +import { UpdateDogInput } from '../dto/update-dog.input'; const MOCK_USER = { id: 'c84fa63e-7a05-4cd5-b015-d4db9a262b18', @@ -36,28 +37,57 @@ const MOCK_DOG = { userId: 'c84fa63e-7a05-4cd5-b015-d4db9a262b18', }; -// mock 서비스 만들기 -const mockDogsService = { - // mock 서비스 로직 만들기 - findOneById: jest.fn().mockImplementation((id: string) => { - return MOCK_DOG; - }), -}; - -const mockAuthGuard: CanActivate = { - canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); - request.user = { - email: 'liberty556@gmail.com', - id: 'c84fa63e-7a05-4cd5-b015-d4db9a262b18', - }; - return request.user; - }, +const UPDATED_MOCK_DOG = { + id: '3ce6246c-f37a-426e-b95a-b38ec6d55f4e', + name: '댕댕이', + age: 5, + weight: 10.5, + breed: DOG_TYPE.LARGE, + specifics: '성격이 착해요', + image: + 'https://storage.cloud.google.com/groomeong-storage/origin/dog/a6c16f50-2946-4dfb-9785-a782cea6c570/%03b%EF%BF%BD2.jpeg', + createdAt: '2023-03-21 12:13:02.011088', + deletedAt: null, + userId: 'c84fa63e-7a05-4cd5-b015-d4db9a262b18', }; describe('DogsResolver', () => { let dogsResolver: DogsResolver; + // Guard 통과 가정 + const mockAuthGuard: CanActivate = { + canActivate: jest.fn().mockImplementation(() => true), + }; + + const context: IContext = { + req: httpMocks.createRequest(), + res: httpMocks.createResponse(), + }; + context.req.user = new User(); + context.req.user.id = MOCK_USER.id; + + const mockDogsService = { + findOneById: jest + .fn() // + .mockImplementation((id: string) => MOCK_DOG), + findByUserId: jest + .fn() // + .mockImplementation((userId: string) => [MOCK_DOG]), + create: jest + .fn() // + .mockImplementation( + (createDogInput: CreateDogInput, userId: string) => MOCK_DOG, + ), + updateOneById: jest + .fn() // + .mockImplementation( + (id: string, updateDogInput: UpdateDogInput) => UPDATED_MOCK_DOG, + ), + deleteOneById: jest + .fn() // + .mockImplementation((id: string, userId: string) => true), + }; + beforeEach(async () => { const dogsModule = await Test.createTestingModule({ providers: [ @@ -66,11 +96,12 @@ describe('DogsResolver', () => { provide: DogsService, useValue: mockDogsService, }, + { + provide: GqlAuthGuard, + useValue: mockAuthGuard, + }, ], - }) - .overrideGuard(GqlAuthGuard) - .useValue(mockAuthGuard) - .compile(); + }).compile(); dogsResolver = dogsModule.get(DogsResolver); }); @@ -79,18 +110,81 @@ describe('DogsResolver', () => { expect(dogsResolver).toBeDefined(); }); - describe('fetchDog API', () => { + describe('fetchDog', () => { it('강아지 정보를 리턴해야 함', () => { - const validMockId = MOCK_DOG.id; - expect(dogsResolver.fetchDog(validMockId)).toEqual({ + expect(dogsResolver.fetchDog(MOCK_DOG.id)).toEqual({ ...MOCK_DOG, }); }); it('NotFoundException을 던져야 함', () => { - const nonValidMockId = '3ce6246c-f37a-426e-b95a-b38ec6d55f4f'; + const inValidMockId = '3ce6246c-f37a-426e-b95a-b38ec6d55f4f'; + try { + dogsResolver.fetchDog(inValidMockId); + } catch (error) { + expect(error).toBeInstanceOf(NotFoundException); + } + }); + }); + + describe('fetchUserDogs', () => { + it('유저의 강아지 정보를 배열로 리턴해야 함', () => { + expect(dogsResolver.fetchUserDogs(context)).toEqual([{ ...MOCK_DOG }]); + }); + }); + + describe('createDog', () => { + it('생성한 강아지 정보를 리턴해야 함', () => { + const createDogInput: CreateDogInput = { + name: MOCK_DOG.name, + age: MOCK_DOG.age, + weight: MOCK_DOG.weight, + breed: MOCK_DOG.breed, + specifics: MOCK_DOG.specifics, + image: MOCK_DOG.image, + }; + + expect(dogsResolver.createDog(createDogInput, context)).toEqual({ + ...MOCK_DOG, + }); + }); + }); + + describe('updateDog', () => { + it('업데이트한 강아지 정보를 리턴해야 함', () => { + const updateDogInput: UpdateDogInput = { + weight: UPDATED_MOCK_DOG.weight, + breed: UPDATED_MOCK_DOG.breed, + }; + + expect(dogsResolver.updateDog(MOCK_DOG.id, updateDogInput)).toEqual({ + ...UPDATED_MOCK_DOG, + }); + }); + + it('NotFoundException을 던져야 함', () => { + const inValidMockId = '3ce6246c-f37a-426e-b95a-b38ec6d55f4f'; + const updateDogInput: UpdateDogInput = { + weight: UPDATED_MOCK_DOG.weight, + breed: UPDATED_MOCK_DOG.breed, + }; + try { + dogsResolver.updateDog(inValidMockId, updateDogInput); + } catch (error) { + expect(error).toBeInstanceOf(NotFoundException); + } + }); + }); + + describe('deleteDog', () => { + it('삭제 여부 true를 반환해야 함', () => { + expect(dogsResolver.deleteDog(MOCK_DOG.id, context)).toBe(true); + }); + + it('NotFoundException을 던져야 함', () => { + const inValidMockId = '3ce6246c-f37a-426e-b95a-b38ec6d55f4f'; try { - dogsResolver.fetchDog(nonValidMockId); + dogsResolver.deleteDog(inValidMockId, context); } catch (error) { expect(error).toBeInstanceOf(NotFoundException); } diff --git a/src/apis/reviews/__test__/reviews.service.spec.ts b/src/apis/reviews/__test__/reviews.service.spec.ts new file mode 100644 index 0000000..4fb4b5c --- /dev/null +++ b/src/apis/reviews/__test__/reviews.service.spec.ts @@ -0,0 +1,112 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ShopsService } from 'src/apis/shops/shops.service'; +import { Repository } from 'typeorm'; +import { Review } from '../entities/review.entity'; +import { ReviewsService } from '../reviews.service'; + +jest.mock('../reviews.service'); +const EXAMPLE_REVIEW = { + id: 'EXAMPLE_REVIEW_ID', + contents: 'EXAMPLE_REVIEW_CONTENTS', + createdAt: new Date(), + star: 5, + reservation: { id: '33bcbf41-884b-46f2-96a2-f3947a1ca906' }, + shop: { id: '500d75e0-0223-4046-be13-55887bfbf6f0' }, +}; + +const MockReviewsRepository = () => ({ + find: jest.fn((where, skip, take, order, relations) => { + EXAMPLE_REVIEW; + }), + findOne: jest.fn((where, relations) => { + EXAMPLE_REVIEW; + }), + save: jest.fn(({ contents, star, reservation, shop }) => { + EXAMPLE_REVIEW; + }), +}); + +type MockRepository = Partial, jest.Mock>>; + +describe('ReviewsService', () => { + let reviewsService: ReviewsService; + let shopsService: ShopsService; + let mockReviewsRepository: MockRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReviewsService, + { + provide: getRepositoryToken(Review), + useValue: MockReviewsRepository(), + }, + { + provide: ShopsService, + useValue: { + findById: jest.fn(() => true), + }, + }, + ], + }).compile(); + + reviewsService = module.get(ReviewsService); + mockReviewsRepository = module.get(getRepositoryToken(Review)); + }); + + const reviewId = EXAMPLE_REVIEW.id; + const shopId = 'shopId'; + + describe('find', () => { + it('reviewsRepository의 findOne을 실행하고 값이 없으면 error 반환해야함', async () => { + try { + const result = await mockReviewsRepository.findOne({ + where: { id: reviewId }, + relations: ['shop', 'reservation'], + }); + } catch (error) { + expect(error).toBeInstanceOf(UnprocessableEntityException); + expect(error.message).toEqual('아이디를 찾을 수 없습니다'); + } + expect(mockReviewsRepository.findOne).toBeCalled(); + }); + }); + + describe('findByShopIdWithPage', () => { + it('', () => { + try { + shopsService.findById({ shopId }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + throw new UnprocessableEntityException('유효하지 않은 가게ID 입니다'); + } + const page = 1; + const count = 12; + const result = mockReviewsRepository.find({ + where: { shop: { id: shopId } }, + skip: (page - 1) * count, + take: count, + order: { + createdAt: 'ASC', + }, + relations: ['shop', 'reservation'], + }); + + expect(mockReviewsRepository.find).toBeCalled(); + }); + }); + + describe('create', () => { + it('', async () => {}); + }); + + describe('averageStar', () => { + it('', async () => {}); + }); + + describe('checkReviewAuth', () => { + it('', async () => {}); + }); +}); diff --git a/src/apis/shopImages/__test__/shopImage.service.spec.ts b/src/apis/shopImages/__test__/shopImage.service.spec.ts index 9cef58d..d5d4294 100644 --- a/src/apis/shopImages/__test__/shopImage.service.spec.ts +++ b/src/apis/shopImages/__test__/shopImage.service.spec.ts @@ -100,7 +100,8 @@ describe('shopImagesService', () => { try { await checkShop({ shopId }); } catch (error) { - expect(error).toThrow(UnprocessableEntityException); + throw new UnprocessableEntityException(); + expect(error).toBeInstanceOf(UnprocessableEntityException); } const result = mockShopImagesRepository.find({ @@ -117,7 +118,8 @@ describe('shopImagesService', () => { try { await checkURL({ shopId }); } catch (error) { - expect(error).toThrow(ConflictException); + throw new ConflictException(); + expect(error).toBeInstanceOf(ConflictException); } const result = mockShopImagesRepository.save({ @@ -163,6 +165,7 @@ describe('shopImagesService', () => { try { await checkImage({ shopId }); } catch (error) { + throw new UnprocessableEntityException('아이디를 찾을 수 없습니다'); expect(error).toThrow(UnprocessableEntityException); } const result = await mockShopImagesRepository.delete({