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 new file mode 100644 index 0000000..693d7cb --- /dev/null +++ b/src/controllers/__test__/user.controller.test.ts @@ -0,0 +1,408 @@ +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(() => { + process.env.NODE_ENV = 'development'; + + // 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(); + }); + + 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/controllers/user.controller.ts b/src/controllers/user.controller.ts index 4ee2dd9..2b7d043 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -12,25 +12,28 @@ type Token10 = string & { __lengthBrand: 10 }; const THREE_WEEKS_IN_MS = 21 * 24 * 60 * 60 * 1000; export class UserController { - constructor(private userService: UserService) {} - - private cookieOption(): CookieOptions { + constructor(private userService: UserService) { } + + /** + * 환경 및 쿠키 삭제 여부에 따라 쿠키 옵션을 생성합니다. + * + * @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()); @@ -52,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, ); @@ -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..d6629dd 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 | null, username: string | null, thumbnail: string | null, 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 | null, + 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..aef36bb 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,8 @@ export class UserService { refreshToken: string, ): Promise { // velog response 에서 주는 응답 혼용 방지를 위한 변경 id -> uuid - const { id: uuid, email = null } = userData; + const { id: uuid, email = null, username, profile } = userData; + const thumbnail = profile?.thumbnail || null // undefined 방어 try { let user = await this.userRepo.findByUserVelogUUID(uuid); @@ -65,6 +66,8 @@ export class UserService { user = await this.createUser({ uuid, email, + username, + thumbnail, accessToken, refreshToken, }); @@ -81,6 +84,8 @@ export class UserService { return await this.updateUserTokens({ uuid, email, + username, + thumbnail, accessToken: encryptedAccessToken, refreshToken: encryptedRefreshToken, }); @@ -110,6 +115,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 +133,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; }