From b4ad410be75ae7af897f0d9a93360a520b585e51 Mon Sep 17 00:00:00 2001 From: Nuung Date: Thu, 19 Jun 2025 00:28:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feature:=20=EA=B8=B0=EB=B3=B8=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/user.controller.ts | 39 ++++++++++++++++------------- src/repositories/user.repository.ts | 18 +++++++------ src/services/user.service.ts | 12 ++++++--- src/types/dto/userWithToken.type.ts | 11 +++++++- 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 4ee2dd9..1985921 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -14,23 +14,26 @@ const THREE_WEEKS_IN_MS = 21 * 24 * 60 * 60 * 1000; export class UserController { constructor(private userService: UserService) {} - private cookieOption(): CookieOptions { + /** + * 환경 및 쿠키 삭제 여부에 따라 쿠키 옵션을 생성합니다. + * + * @param isClear - true일 경우 쿠키 삭제용 옵션을 생성합니다. 기본값은 false입니다. + * @returns 현재 환경에 맞게 설정된 CookieOptions 객체를 반환합니다. + */ + private cookieOption(isClear: boolean = false): CookieOptions { const isProd = process.env.NODE_ENV === 'production'; - - const baseOptions: CookieOptions = { + const options: CookieOptions = { httpOnly: isProd, secure: isProd, + sameSite: isProd ? 'lax' : undefined, + domain: isProd ? 'velog-dashboard.kro.kr' : 'localhost', }; - if (isProd) { - baseOptions.sameSite = 'lax'; - baseOptions.domain = 'velog-dashboard.kro.kr'; - baseOptions.maxAge = THREE_WEEKS_IN_MS; // 3주 - } else { - baseOptions.domain = 'localhost'; + if (isProd && !isClear) { + options.maxAge = THREE_WEEKS_IN_MS; } - return baseOptions; + return options; } login: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { @@ -43,8 +46,8 @@ export class UserController { const user = await this.userService.handleUserTokensByVelogUUID(velogUser, accessToken, refreshToken); // 3. 로그이 완료 후 쿠키 세팅 - res.clearCookie('access_token', this.cookieOption()); - res.clearCookie('refresh_token', this.cookieOption()); + res.clearCookie('access_token', this.cookieOption(true)); + res.clearCookie('refresh_token', this.cookieOption(true)); res.cookie('access_token', accessToken, this.cookieOption()); res.cookie('refresh_token', refreshToken, this.cookieOption()); @@ -71,8 +74,8 @@ export class UserController { try { const sampleUser = await this.userService.findSampleUser(); - res.clearCookie('access_token', this.cookieOption()); - res.clearCookie('refresh_token', this.cookieOption()); + res.clearCookie('access_token', this.cookieOption(true)); + res.clearCookie('refresh_token', this.cookieOption(true)); res.cookie('access_token', sampleUser.decryptedAccessToken, this.cookieOption()); res.cookie('refresh_token', sampleUser.decryptedRefreshToken, this.cookieOption()); @@ -98,8 +101,8 @@ export class UserController { }; logout: RequestHandler = async (req: Request, res: Response) => { - res.clearCookie('access_token', this.cookieOption()); - res.clearCookie('refresh_token', this.cookieOption()); + res.clearCookie('access_token', this.cookieOption(true)); + res.clearCookie('refresh_token', this.cookieOption(true)); const response = new EmptyResponseDto(true, '로그아웃에 성공하였습니다.', {}, null); @@ -155,8 +158,8 @@ export class UserController { throw new QRTokenExpiredError(); } - res.clearCookie('access_token', this.cookieOption()); - res.clearCookie('refresh_token', this.cookieOption()); + res.clearCookie('access_token', this.cookieOption(true)); + res.clearCookie('refresh_token', this.cookieOption(true)); res.cookie('access_token', userLoginToken.decryptedAccessToken, this.cookieOption()); res.cookie('refresh_token', userLoginToken.decryptedRefreshToken, this.cookieOption()); diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 7737217..3f7bda9 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -5,7 +5,7 @@ import { QRLoginToken } from '@/types/models/QRLoginToken.type'; import { DBError } from '@/exception'; export class UserRepository { - constructor(private readonly pool: Pool) {} + constructor(private readonly pool: Pool) { } async findByUserId(id: number): Promise { try { @@ -42,15 +42,15 @@ export class UserRepository { } } - async updateTokens(uuid: string, encryptedAccessToken: string, encryptedRefreshToken: string): Promise { + async updateTokens(uuid: string, email: string, username: string, thumbnail: string, encryptedAccessToken: string, encryptedRefreshToken: string): Promise { try { const query = ` UPDATE "users_user" - SET access_token = $1, refresh_token = $2, updated_at = NOW(), is_active = true - WHERE velog_uuid = $3 + SET access_token = $1, refresh_token = $2, email = $3, username = $4, thumbnail = $5, updated_at = NOW(), is_active = true + WHERE velog_uuid = $6 RETURNING *; `; - const values = [encryptedAccessToken, encryptedRefreshToken, uuid]; + const values = [encryptedAccessToken, encryptedRefreshToken, email, username, thumbnail, uuid]; const result = await this.pool.query(query, values); @@ -67,6 +67,8 @@ export class UserRepository { async createUser( uuid: string, email: string | null, + username: string, + thumbnail: string | null, encryptedAccessToken: string, encryptedRefreshToken: string, groupId: number, @@ -78,17 +80,19 @@ export class UserRepository { access_token, refresh_token, email, + username, + thumbnail, group_id, is_active, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, true, NOW(), NOW() + $1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW() ) RETURNING *; `; - const values = [uuid, encryptedAccessToken, encryptedRefreshToken, email, groupId]; + const values = [uuid, encryptedAccessToken, encryptedRefreshToken, email, username, thumbnail, groupId]; const result = await this.pool.query(query, values); if (!result.rows[0]) { diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 93a8191..26bbd1f 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -10,7 +10,7 @@ import { generateRandomToken } from '@/utils/generateRandomToken.util'; import { VelogUserCurrentResponse } from '@/modules/velog/velog.type'; export class UserService { - constructor(private userRepo: UserRepository) {} + constructor(private userRepo: UserRepository) { } private encryptTokens(groupId: number, accessToken: string, refreshToken: string) { const key = getKeyByGroup(groupId); @@ -56,7 +56,7 @@ export class UserService { refreshToken: string, ): Promise { // velog response 에서 주는 응답 혼용 방지를 위한 변경 id -> uuid - const { id: uuid, email = null } = userData; + const { id: uuid, email = null, username, profile: { thumbnail } } = userData; try { let user = await this.userRepo.findByUserVelogUUID(uuid); @@ -65,6 +65,8 @@ export class UserService { user = await this.createUser({ uuid, email, + username, + thumbnail, accessToken, refreshToken, }); @@ -81,6 +83,8 @@ export class UserService { return await this.updateUserTokens({ uuid, email, + username, + thumbnail, accessToken: encryptedAccessToken, refreshToken: encryptedRefreshToken, }); @@ -110,6 +114,8 @@ export class UserService { const newUser = await this.userRepo.createUser( userData.uuid, userData.email, + userData.username, + userData.thumbnail, userData.accessToken, userData.refreshToken, groupId, @@ -126,7 +132,7 @@ export class UserService { } async updateUserTokens(userData: UserWithTokenDto) { - return await this.userRepo.updateTokens(userData.uuid, userData.accessToken, userData.refreshToken); + return await this.userRepo.updateTokens(userData.uuid, userData.email || '', userData.username, userData.thumbnail || '', userData.accessToken, userData.refreshToken); } async createUserQRToken(userId: number, ip: string, userAgent: string): Promise { diff --git a/src/types/dto/userWithToken.type.ts b/src/types/dto/userWithToken.type.ts index c03a8b7..35f0aa9 100644 --- a/src/types/dto/userWithToken.type.ts +++ b/src/types/dto/userWithToken.type.ts @@ -9,6 +9,13 @@ export class UserWithTokenDto { @IsEmail() email: string | null = null; // undefined 가능성 없애고 null 로 고정 + @IsNotEmpty() + @IsString() + username: string; + + @IsOptional() + thumbnail: string | null = null; + @IsNotEmpty() @IsString() accessToken: string; @@ -17,9 +24,11 @@ export class UserWithTokenDto { @IsString() refreshToken: string; - constructor(uuid: string, email: string | null, accessToken: string, refreshToken: string) { + constructor(uuid: string, email: string | null, username: string, thumbnail: string | null, accessToken: string, refreshToken: string) { this.uuid = uuid; this.email = email; + this.username = username; + this.thumbnail = thumbnail; this.accessToken = accessToken; this.refreshToken = refreshToken; } From 5ea5847aa7b59a1c31f8121d66ff6817967f6a9f Mon Sep 17 00:00:00 2001 From: Nuung Date: Thu, 19 Jun 2025 00:34:40 +0900 Subject: [PATCH 2/7] =?UTF-8?q?modify:=20type=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=EC=B2=A0=EC=A0=80=ED=95=9C=20=EB=82=B4?= =?UTF-8?q?=EC=A0=9C=ED=99=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/__test__/user.controller.test.ts | 0 src/controllers/user.controller.ts | 4 ++-- src/services/user.service.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 src/controllers/__test__/user.controller.test.ts diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 1985921..2b7d043 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -12,7 +12,7 @@ type Token10 = string & { __lengthBrand: 10 }; const THREE_WEEKS_IN_MS = 21 * 24 * 60 * 60 * 1000; export class UserController { - constructor(private userService: UserService) {} + constructor(private userService: UserService) { } /** * 환경 및 쿠키 삭제 여부에 따라 쿠키 옵션을 생성합니다. @@ -55,7 +55,7 @@ export class UserController { const response = new LoginResponseDto( true, '로그인에 성공하였습니다.', - { id: user.id, username: velogUser.username, profile: velogUser.profile }, + { id: user.id, username: user.username || '', profile: { thumbnail: user.thumbnail || '' } }, null, ); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 26bbd1f..2cea7cb 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -132,7 +132,7 @@ export class UserService { } async updateUserTokens(userData: UserWithTokenDto) { - return await this.userRepo.updateTokens(userData.uuid, userData.email || '', userData.username, userData.thumbnail || '', userData.accessToken, userData.refreshToken); + return await this.userRepo.updateTokens(userData.uuid, userData.email, userData.username, userData.thumbnail, userData.accessToken, userData.refreshToken); } async createUserQRToken(userId: number, ip: string, userAgent: string): Promise { From df74bc35813399ac0964ebaeb8e1b358bf2a190e Mon Sep 17 00:00:00 2001 From: Nuung Date: Thu, 19 Jun 2025 00:35:42 +0900 Subject: [PATCH 3/7] =?UTF-8?q?modify:=20type=20undefined=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/user.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 2cea7cb..aef36bb 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -56,7 +56,8 @@ export class UserService { refreshToken: string, ): Promise { // velog response 에서 주는 응답 혼용 방지를 위한 변경 id -> uuid - const { id: uuid, email = null, username, profile: { thumbnail } } = userData; + const { id: uuid, email = null, username, profile } = userData; + const thumbnail = profile?.thumbnail || null // undefined 방어 try { let user = await this.userRepo.findByUserVelogUUID(uuid); From 6dfe9cc5b1552720c537699e79b87b4e11221621 Mon Sep 17 00:00:00 2001 From: Nuung Date: Thu, 19 Jun 2025 00:53:20 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feature:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=B2=AB=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/user.controller.test.ts | 409 ++++++++++++++++++ src/repositories/user.repository.ts | 4 +- 2 files changed, 411 insertions(+), 2 deletions(-) diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts index e69de29..6ab0009 100644 --- a/src/controllers/__test__/user.controller.test.ts +++ b/src/controllers/__test__/user.controller.test.ts @@ -0,0 +1,409 @@ +import 'reflect-metadata'; // class-validator와 class-transformer 데코레이터, reflect-metadata 의존 +import { Request, Response } from 'express'; +import { UserController } from '@/controllers/user.controller'; +import { UserService } from '@/services/user.service'; +import { UserRepository } from '@/repositories/user.repository'; +import { fetchVelogApi } from '@/modules/velog/velog.api'; +import { NotFoundError } from '@/exception'; +import { mockUser, mockPool } from '@/utils/fixtures'; +import { Pool } from 'pg'; + +// Mock dependencies +jest.mock('@/services/user.service'); +jest.mock('@/modules/velog/velog.api'); + +// logger 모킹 +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +describe('UserController', () => { + let userController: UserController; + let mockUserService: jest.Mocked; + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: jest.Mock; + + beforeEach(() => { + // UserService 모킹 + const userRepo = new UserRepository(mockPool as unknown as Pool); + const serviceInstance = new UserService(userRepo); + mockUserService = serviceInstance as jest.Mocked; + + // UserController 인스턴스 생성 + userController = new UserController(mockUserService); + + // Request, Response, NextFunction 모킹 + mockRequest = { + body: {}, + headers: {}, + user: mockUser, + ip: '127.0.0.1', + query: {}, + }; + + mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + clearCookie: jest.fn().mockReturnThis(), + redirect: jest.fn().mockReturnThis(), + }; + + nextFunction = jest.fn(); + + // 환경변수 모킹 + process.env.NODE_ENV = 'development'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('login', () => { + const mockVelogUser = { + id: 'velog-uuid-123', + username: 'testuser', + email: 'test@example.com', + profile: { thumbnail: 'https://example.com/avatar.png' } + }; + + it('유효한 토큰으로 로그인에 성공해야 한다', async () => { + mockRequest.body = { + accessToken: 'valid-access-token', + refreshToken: 'valid-refresh-token' + }; + + (fetchVelogApi as jest.Mock).mockResolvedValue(mockVelogUser); + mockUserService.handleUserTokensByVelogUUID.mockResolvedValue(mockUser); + + await userController.login( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(fetchVelogApi).toHaveBeenCalledWith('valid-access-token', 'valid-refresh-token'); + expect(mockUserService.handleUserTokensByVelogUUID).toHaveBeenCalledWith( + mockVelogUser, + 'valid-access-token', + 'valid-refresh-token' + ); + expect(mockResponse.clearCookie).toHaveBeenCalledTimes(2); + expect(mockResponse.cookie).toHaveBeenCalledTimes(2); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: '로그인에 성공하였습니다.', + data: { + id: mockUser.id, + username: mockUser.username, + profile: { thumbnail: mockUser.thumbnail } + }, + error: null + }); + }); + + it('Velog API 호출 실패 시 에러를 전달해야 한다', async () => { + mockRequest.body = { + accessToken: 'invalid-access-token', + refreshToken: 'invalid-refresh-token' + }; + + const apiError = new Error('Velog API 호출 실패'); + (fetchVelogApi as jest.Mock).mockRejectedValue(apiError); + + await userController.login( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith(apiError); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('프로덕션 환경에서 올바른 쿠키 옵션을 설정해야 한다', async () => { + process.env.NODE_ENV = 'production'; + mockRequest.body = { + accessToken: 'valid-access-token', + refreshToken: 'valid-refresh-token' + }; + + (fetchVelogApi as jest.Mock).mockResolvedValue(mockVelogUser); + mockUserService.handleUserTokensByVelogUUID.mockResolvedValue(mockUser); + + await userController.login( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'access_token', + 'valid-access-token', + expect.objectContaining({ + httpOnly: true, + secure: true, + sameSite: 'lax', + domain: 'velog-dashboard.kro.kr' + }) + ); + }); + }); + + describe('sampleLogin', () => { + const mockSampleUser = { + user: mockUser, + decryptedAccessToken: 'decrypted-access-token', + decryptedRefreshToken: 'decrypted-refresh-token' + }; + + it('샘플 로그인에 성공해야 한다', async () => { + mockUserService.findSampleUser.mockResolvedValue(mockSampleUser); + + await userController.sampleLogin( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockUserService.findSampleUser).toHaveBeenCalled(); + expect(mockResponse.clearCookie).toHaveBeenCalledTimes(2); + expect(mockResponse.cookie).toHaveBeenCalledTimes(2); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: '로그인에 성공하였습니다.', + data: { + id: mockUser.id, + username: '테스트 유저', + profile: { thumbnail: 'https://velog.io/favicon.ico' } + }, + error: null + }); + }); + + it('샘플 사용자 찾기 실패 시 에러를 전달해야 한다', async () => { + const error = new NotFoundError('샘플 사용자를 찾을 수 없습니다.'); + mockUserService.findSampleUser.mockRejectedValue(error); + + await userController.sampleLogin( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith(error); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('로그아웃에 성공해야 한다', async () => { + await userController.logout( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.clearCookie).toHaveBeenCalledTimes(2); + expect(mockResponse.clearCookie).toHaveBeenCalledWith( + 'access_token', + expect.objectContaining({ domain: 'localhost' }) + ); + expect(mockResponse.clearCookie).toHaveBeenCalledWith( + 'refresh_token', + expect.objectContaining({ domain: 'localhost' }) + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: '로그아웃에 성공하였습니다.', + data: {}, + error: null + }); + }); + }); + + describe('fetchCurrentUser', () => { + it('현재 사용자 정보를 반환해야 한다', async () => { + await userController.fetchCurrentUser( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: '유저 정보 조회에 성공하였습니다.', + data: { + id: mockUser.id, + username: mockUser.username || '', + profile: { thumbnail: mockUser.thumbnail || '' } + }, + error: null + }); + }); + + it('username과 thumbnail이 null인 경우 빈 문자열로 처리해야 한다', async () => { + const userWithNulls = { ...mockUser, username: null, thumbnail: null }; + mockRequest.user = userWithNulls; + + await userController.fetchCurrentUser( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: '유저 정보 조회에 성공하였습니다.', + data: { + id: userWithNulls.id, + username: '', + profile: { thumbnail: '' } + }, + error: null + }); + }); + }); + + describe('createToken', () => { + it('QR 토큰 생성에 성공해야 한다', async () => { + const mockToken = 'ABCD123456'; + mockRequest.headers = { + 'x-forwarded-for': '192.168.1.1, 127.0.0.1', + 'user-agent': 'Mozilla/5.0 (Test Browser)' + }; + mockUserService.createUserQRToken.mockResolvedValue(mockToken); + + await userController.createToken( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockUserService.createUserQRToken).toHaveBeenCalledWith( + mockUser.id, + '192.168.1.1', + 'Mozilla/5.0 (Test Browser)' + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'QR 토큰 생성 완료', + data: { token: mockToken }, + error: null + }); + }); + + it('IP 주소가 없는 경우 빈 문자열을 사용해야 한다', async () => { + const mockToken = 'ABCD123456'; + const mockRequestWithoutIp = { + ...mockRequest, + headers: { 'user-agent': 'Test Browser' }, + ip: undefined + }; + mockUserService.createUserQRToken.mockResolvedValue(mockToken); + + await userController.createToken( + mockRequestWithoutIp as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockUserService.createUserQRToken).toHaveBeenCalledWith( + mockUser.id, + '', + 'Test Browser' + ); + }); + + it('QR 토큰 생성 실패 시 에러를 전달해야 한다', async () => { + const error = new Error('토큰 생성 실패'); + mockUserService.createUserQRToken.mockRejectedValue(error); + + await userController.createToken( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith(error); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + }); + + describe('getToken', () => { + const mockUserLoginToken = { + decryptedAccessToken: 'decrypted-access-token', + decryptedRefreshToken: 'decrypted-refresh-token' + }; + + it('유효한 토큰으로 QR 로그인에 성공해야 한다', async () => { + mockRequest.query = { token: 'valid-token' }; + mockUserService.useToken.mockResolvedValue(mockUserLoginToken); + + await userController.getToken( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockUserService.useToken).toHaveBeenCalledWith('valid-token'); + expect(mockResponse.clearCookie).toHaveBeenCalledTimes(2); + expect(mockResponse.cookie).toHaveBeenCalledTimes(2); + expect(mockResponse.redirect).toHaveBeenCalledWith('/main'); + }); + + it('토큰이 없는 경우 에러를 전달해야 한다', async () => { + mockRequest.query = {}; + + await userController.getToken( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: '토큰이 필요합니다.' + }) + ); + }); + + it('만료된 토큰인 경우 에러를 전달해야 한다', async () => { + mockRequest.query = { token: 'expired-token' }; + mockUserService.useToken.mockResolvedValue(null); + + await userController.getToken( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith( + expect.any(Error) + ); + }); + + it('토큰 사용 중 에러 발생 시 에러를 전달해야 한다', async () => { + mockRequest.query = { token: 'valid-token' }; + const error = new Error('토큰 사용 실패'); + mockUserService.useToken.mockRejectedValue(error); + + await userController.getToken( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // Assert + expect(nextFunction).toHaveBeenCalledWith(error); + expect(mockResponse.redirect).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 3f7bda9..d6629dd 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -42,7 +42,7 @@ export class UserRepository { } } - async updateTokens(uuid: string, email: string, username: string, thumbnail: string, encryptedAccessToken: string, encryptedRefreshToken: string): Promise { + async updateTokens(uuid: string, email: string | null, username: string | null, thumbnail: string | null, encryptedAccessToken: string, encryptedRefreshToken: string): Promise { try { const query = ` UPDATE "users_user" @@ -67,7 +67,7 @@ export class UserRepository { async createUser( uuid: string, email: string | null, - username: string, + username: string | null, thumbnail: string | null, encryptedAccessToken: string, encryptedRefreshToken: string, From 192e6dd6d5f3bf59b364c41cb6c17f9aa8a58ee2 Mon Sep 17 00:00:00 2001 From: Nuung Date: Thu, 19 Jun 2025 01:30:29 +0900 Subject: [PATCH 5/7] =?UTF-8?q?modify:=20key=20util=20AES=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=9D=B4=EC=8A=88=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91,=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=95=8C=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/user.controller.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts index 6ab0009..93771bb 100644 --- a/src/controllers/__test__/user.controller.test.ts +++ b/src/controllers/__test__/user.controller.test.ts @@ -18,6 +18,20 @@ jest.mock('@/configs/logger.config', () => ({ info: jest.fn(), })); +// 환경변수 모킹 (AES 키 설정, 첫 메모리 로드될때 util 함수쪽 key 세팅 이슈 방지) +process.env.AES_KEY_0 = 'a'.repeat(32); +process.env.AES_KEY_1 = 'b'.repeat(32); +process.env.AES_KEY_2 = 'c'.repeat(32); +process.env.AES_KEY_3 = 'd'.repeat(32); +process.env.AES_KEY_4 = 'e'.repeat(32); +process.env.AES_KEY_5 = 'f'.repeat(32); +process.env.AES_KEY_6 = 'g'.repeat(32); +process.env.AES_KEY_7 = 'h'.repeat(32); +process.env.AES_KEY_8 = 'i'.repeat(32); +process.env.AES_KEY_9 = 'j'.repeat(32); +process.env.NODE_ENV = 'test'; + + describe('UserController', () => { let userController: UserController; let mockUserService: jest.Mocked; @@ -26,6 +40,8 @@ describe('UserController', () => { let nextFunction: jest.Mock; beforeEach(() => { + process.env.NODE_ENV = 'development'; + // UserService 모킹 const userRepo = new UserRepository(mockPool as unknown as Pool); const serviceInstance = new UserService(userRepo); @@ -52,9 +68,6 @@ describe('UserController', () => { }; nextFunction = jest.fn(); - - // 환경변수 모킹 - process.env.NODE_ENV = 'development'; }); afterEach(() => { From b27e4d86c3654fd51bc617a6d69049a1f6cc9711 Mon Sep 17 00:00:00 2001 From: Nuung Date: Thu, 19 Jun 2025 01:34:59 +0900 Subject: [PATCH 6/7] =?UTF-8?q?modify:=20key=20util=20AES=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=9D=B4=EC=8A=88=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91,=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=95=8C=EB=AC=B8=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/user.controller.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts index 93771bb..9711898 100644 --- a/src/controllers/__test__/user.controller.test.ts +++ b/src/controllers/__test__/user.controller.test.ts @@ -8,16 +8,6 @@ import { NotFoundError } from '@/exception'; import { mockUser, mockPool } from '@/utils/fixtures'; import { Pool } from 'pg'; -// Mock dependencies -jest.mock('@/services/user.service'); -jest.mock('@/modules/velog/velog.api'); - -// logger 모킹 -jest.mock('@/configs/logger.config', () => ({ - error: jest.fn(), - info: jest.fn(), -})); - // 환경변수 모킹 (AES 키 설정, 첫 메모리 로드될때 util 함수쪽 key 세팅 이슈 방지) process.env.AES_KEY_0 = 'a'.repeat(32); process.env.AES_KEY_1 = 'b'.repeat(32); @@ -31,6 +21,15 @@ process.env.AES_KEY_8 = 'i'.repeat(32); process.env.AES_KEY_9 = 'j'.repeat(32); process.env.NODE_ENV = 'test'; +// Mock dependencies +jest.mock('@/services/user.service'); +jest.mock('@/modules/velog/velog.api'); + +// logger 모킹 +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), + info: jest.fn(), +})); describe('UserController', () => { let userController: UserController; From bc956e0666968ab5c29fc01b4d0d54bbe2b613a1 Mon Sep 17 00:00:00 2001 From: Nuung Date: Thu, 19 Jun 2025 01:39:28 +0900 Subject: [PATCH 7/7] =?UTF-8?q?modify:=20key=20util=20AES=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=9D=B4=EC=8A=88=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91,=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=95=8C=EB=AC=B8=20v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-ci.yaml | 11 +++++++++++ src/controllers/__test__/user.controller.test.ts | 13 ------------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-ci.yaml b/.github/workflows/test-ci.yaml index e89e0f6..de1ba66 100644 --- a/.github/workflows/test-ci.yaml +++ b/.github/workflows/test-ci.yaml @@ -58,6 +58,17 @@ jobs: echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env echo "POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}" >> .env + # AES 키들 추가 (테스트용 더미 키) + echo "AES_KEY_0=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" >> .env + echo "AES_KEY_1=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" >> .env + echo "AES_KEY_2=cccccccccccccccccccccccccccccccc" >> .env + echo "AES_KEY_3=dddddddddddddddddddddddddddddddd" >> .env + echo "AES_KEY_4=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" >> .env + echo "AES_KEY_5=ffffffffffffffffffffffffffffffff" >> .env + echo "AES_KEY_6=gggggggggggggggggggggggggggggggg" >> .env + echo "AES_KEY_7=hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" >> .env + echo "AES_KEY_8=iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" >> .env + echo "AES_KEY_9=jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj" >> .env - name: Run lint run: pnpm run lint diff --git a/src/controllers/__test__/user.controller.test.ts b/src/controllers/__test__/user.controller.test.ts index 9711898..693d7cb 100644 --- a/src/controllers/__test__/user.controller.test.ts +++ b/src/controllers/__test__/user.controller.test.ts @@ -8,19 +8,6 @@ import { NotFoundError } from '@/exception'; import { mockUser, mockPool } from '@/utils/fixtures'; import { Pool } from 'pg'; -// 환경변수 모킹 (AES 키 설정, 첫 메모리 로드될때 util 함수쪽 key 세팅 이슈 방지) -process.env.AES_KEY_0 = 'a'.repeat(32); -process.env.AES_KEY_1 = 'b'.repeat(32); -process.env.AES_KEY_2 = 'c'.repeat(32); -process.env.AES_KEY_3 = 'd'.repeat(32); -process.env.AES_KEY_4 = 'e'.repeat(32); -process.env.AES_KEY_5 = 'f'.repeat(32); -process.env.AES_KEY_6 = 'g'.repeat(32); -process.env.AES_KEY_7 = 'h'.repeat(32); -process.env.AES_KEY_8 = 'i'.repeat(32); -process.env.AES_KEY_9 = 'j'.repeat(32); -process.env.NODE_ENV = 'test'; - // Mock dependencies jest.mock('@/services/user.service'); jest.mock('@/modules/velog/velog.api');