diff --git a/.env.sample b/.env.sample index 49bcca0..1c5459c 100644 --- a/.env.sample +++ b/.env.sample @@ -20,3 +20,6 @@ POSTGRES_USER=vd2 POSTGRES_PASSWORD=vd2 POSTGRES_HOST=localhost POSTGRES_PORT=5432 + +# ETC +SLACK_WEBHOOK_URL=https://hooks.slack.com/services \ No newline at end of file diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 8c18d87..6b414e9 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -3,7 +3,7 @@ import logger from '@/configs/logger.config'; import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types'; import { UserService } from '@/services/user.service'; export class UserController { - constructor(private userService: UserService) {} + constructor(private userService: UserService) { } private cookieOption(): CookieOptions { const isProd = process.env.NODE_ENV === 'production'; @@ -51,6 +51,36 @@ export class UserController { } }; + sampleLogin: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const sampleUser = await this.userService.findSampleUser(); + + res.clearCookie('access_token'); + res.clearCookie('refresh_token'); + + res.cookie('access_token', sampleUser.decryptedAccessToken, this.cookieOption()); + res.cookie('refresh_token', sampleUser.decryptedRefreshToken, this.cookieOption()); + + req.user = sampleUser.user; + + const response = new LoginResponseDto( + true, + '로그인에 성공하였습니다.', + { + id: sampleUser.user.id, + username: "테스트 유저", + profile: { "thumbnail": "https://velog.io/favicon.ico" } + }, + null, + ); + + res.status(200).json(response); + } catch (error) { + logger.error('로그인 실패 : ', error); + next(error); + } + } + logout: RequestHandler = async (req: Request, res: Response) => { res.clearCookie('access_token'); res.clearCookie('refresh_token'); diff --git a/src/modules/__test__/test.slack.notifier.test.ts b/src/modules/__test__/test.slack.notifier.test.ts new file mode 100644 index 0000000..9c9982e --- /dev/null +++ b/src/modules/__test__/test.slack.notifier.test.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; +import dotenv from 'dotenv'; + +// 환경 변수 로드 (.env 파일) +dotenv.config(); + +// 테스트 환경에서 SLACK_WEBHOOK_URL이 설정되어 있지 않으면 기본값 설정 +process.env.SLACK_WEBHOOK_URL = + process.env.SLACK_WEBHOOK_URL || 'https://dummy-slack-webhook-url.com'; + +// axios 모듈을 mock 처리 +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +// 테스트 대상 모듈을 import 합니다. +// 주의: 모듈을 import하기 전에 process.env.SLACK_WEBHOOK_URL을 설정해야 합니다. +import { sendSlackMessage } from '@/modules/slack/slack.notifier'; + +describe('sendSlackMessage', () => { + // 각 테스트 실행 전 mock 호출 기록 초기화 + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('정상적인 메시지 전송 - axios.post가 올바른 파라미터로 호출되어야 한다', async () => { + // Arrange: axios.post가 성공적으로 응답하도록 설정합니다. + const fakeResponse = { data: 'ok' }; + mockedAxios.post.mockResolvedValue(fakeResponse); + + const testMessage = 'Test Slack message'; + + // Act: sendSlackMessage 함수를 호출합니다. + await sendSlackMessage(testMessage); + + // Assert: axios.post가 올바른 URL, payload, header로 호출되었는지 검증합니다. + expect(mockedAxios.post).toHaveBeenCalledWith( + process.env.SLACK_WEBHOOK_URL, + { text: testMessage }, + { headers: { 'Content-Type': 'application/json' } } + ); + }); + + test('axios.post 호출 중 에러가 발생하면 sendSlackMessage가 예외를 throw 해야 한다', async () => { + // Arrange: axios.post가 에러를 발생시키도록 설정합니다. + const errorMessage = 'Network Error'; + mockedAxios.post.mockRejectedValue(new Error(errorMessage)); + + // Act & Assert: sendSlackMessage 호출 시 에러가 발생하는지 확인합니다. + await expect(sendSlackMessage('Test error')).rejects.toThrow(errorMessage); + }); +}); diff --git a/src/modules/slack/slack.notifier.ts b/src/modules/slack/slack.notifier.ts new file mode 100644 index 0000000..c7b0332 --- /dev/null +++ b/src/modules/slack/slack.notifier.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; +import dotenv from 'dotenv'; + +// 환경 변수 로드 (.env 파일) +dotenv.config(); + +// 환경 변수 체크 +if (!process.env.SLACK_WEBHOOK_URL) { + throw new Error('SLACK_WEBHOOK_URL is not defined in environment variables.'); +} +const SLACK_WEBHOOK_URL: string = process.env.SLACK_WEBHOOK_URL; + +interface SlackPayload { + text: string; +} + +/** + * Slack으로 메시지를 전송합니다. + * @param message 전송할 메시지 텍스트 + */ +export async function sendSlackMessage(message: string): Promise { + const payload: SlackPayload = { text: message }; + const response = await axios.post(SLACK_WEBHOOK_URL, payload, { + headers: { 'Content-Type': 'application/json' }, + }); + console.log(response); +} \ No newline at end of file diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index ed344d7..0b8255c 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -4,7 +4,7 @@ import { User } from '@/types'; import { DBError } from '@/exception'; export class UserRepository { - constructor(private readonly pool: Pool) {} + constructor(private readonly pool: Pool) { } async findByUserVelogUUID(uuid: string): Promise { try { @@ -17,6 +17,21 @@ export class UserRepository { } } + async findSampleUser(): Promise { + try { + const query = ` + SELECT * FROM "users_user" + WHERE velog_uuid = '8f561807-8304-4006-84a5-ee3fa8b46d23'; + `; + + const result = await this.pool.query(query); + return result.rows[0]; + } catch (error) { + logger.error('User Repo findSampleUser Error : ', error); + throw new DBError('샘플 유저 조회 중 문제가 발생했습니다.'); + } + } + async updateTokens(uuid: string, encryptedAccessToken: string, encryptedRefreshToken: string): Promise { try { const query = ` diff --git a/src/routes/user.router.ts b/src/routes/user.router.ts index c7d79e8..7f30846 100644 --- a/src/routes/user.router.ts +++ b/src/routes/user.router.ts @@ -22,7 +22,7 @@ const userController = new UserController(userService); * summary: 사용자 로그인 * security: [] * requestBody: - * required: false + * required: true * content: * application/json: * schema: @@ -49,6 +49,38 @@ const userController = new UserController(userService); router.post('/login', authMiddleware.login, userController.login); // router.post('/login', authMiddleware.login, validateRequestDto(VelogUserLoginDto, 'user'), userController.login); +/** + * @swagger + * /login-sample: + * post: + * tags: + * - User + * summary: 샘플 사용자 로그인 + * security: [] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * description: 비어있는 request body + * responses: + * '200': + * description: 성공 + * headers: + * Set-Cookie: + * schema: + * type: string + * description: 인증 쿠키 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginResponseDto' + * '500': + * description: 서버 오류 / 데이터 베이스 조회 오류 + */ +router.post('/login-sample', userController.sampleLogin); + /** * @swagger * /logout: diff --git a/src/services/user.service.ts b/src/services/user.service.ts index d7e3113..d0e8dad 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -1,13 +1,14 @@ import logger from '@/configs/logger.config'; -import { TokenError } from '@/exception/'; +import { NotFoundError, TokenError } from '@/exception/'; import { getKeyByGroup } from '@/utils/key.util'; import AESEncryption from '@/modules/token_encryption/aes_encryption'; +import { sendSlackMessage } from '@/modules/slack/slack.notifier'; import { UserRepository } from '@/repositories/user.repository'; -import { UserWithTokenDto, User } from '@/types'; +import { UserWithTokenDto, User, SampleUser } from '@/types'; import { generateRandomGroupId } from '@/utils/generateGroupId.util'; export class UserService { - constructor(private userRepo: UserRepository) {} + constructor(private userRepo: UserRepository) { } private encryptTokens(groupId: number, accessToken: string, refreshToken: string) { const key = getKeyByGroup(groupId); @@ -28,12 +29,24 @@ export class UserService { } } - // 토큰 복호화 처리 - // private decryptTokens(refreshToken: string) { - // return { - // decryptedRefreshToken: this.aesEncryption.decrypt(refreshToken), - // }; - // } + private decryptTokens(groupId: number, accessToken: string, refreshToken: string) { + const key = getKeyByGroup(groupId); + if (!key) { + logger.error('그룹 키 조회 중 실패'); + throw new TokenError('올바르지 않은 그룹 ID로 인해 암호화 키를 찾을 수 없습니다.'); + } + try { + const aes = new AESEncryption(key); + + return { + decryptedAccessToken: aes.decrypt(accessToken), + decryptedRefreshToken: aes.decrypt(refreshToken), + }; + } catch (error) { + logger.error('User Service decryptTokens error : ', error); + throw new TokenError('토큰 복호화 처리에 실패하였습니다.'); + } + } async handleUserTokensByVelogUUID(userData: UserWithTokenDto) { const { id, email, accessToken, refreshToken } = userData; @@ -71,16 +84,39 @@ export class UserService { return await this.userRepo.findByUserVelogUUID(uuid); } + async findSampleUser(): Promise { + const user = await this.userRepo.findSampleUser(); + if (!user) { + throw new NotFoundError('샘플 유저 정보를 찾을 수 없습니다.'); + } + + const { decryptedAccessToken, decryptedRefreshToken } = this.decryptTokens( + user.group_id, + user.access_token, + user.refresh_token + ); + + return { user, decryptedAccessToken, decryptedRefreshToken }; + } + async createUser(userData: UserWithTokenDto) { const groupId = generateRandomGroupId(); - - return await this.userRepo.createUser( + const newUser = await this.userRepo.createUser( userData.id, userData.email, userData.accessToken, userData.refreshToken, groupId, ); + + // 신규 유저 웹훅 알림 + try { + await sendSlackMessage(`새로운 유저 등록: ${userData.id}, ${userData.email}`); + } catch (error) { + // Slack 알림 실패는 사용자 생성에 영향을 주지 않도록 + logger.error('Slack 알림 전송 실패:', error); + } + return newUser; } async updateUserTokens(userData: UserWithTokenDto) { diff --git a/src/types/index.ts b/src/types/index.ts index 3f33a1d..d5cf39d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -export type { User } from '@/types/models/User.type'; +export type { User, SampleUser } from '@/types/models/User.type'; export type { Post } from '@/types/models/Post.type'; export type { PostDailyStatistics } from '@/types/models/PostDailyStatistics.type'; export type { PostStatistics } from '@/types/models/PostStatistics.type'; diff --git a/src/types/models/User.type.ts b/src/types/models/User.type.ts index 04a8bc2..6f855d1 100644 --- a/src/types/models/User.type.ts +++ b/src/types/models/User.type.ts @@ -9,3 +9,10 @@ export interface User { created_at: Date; updated_at: Date; } + + +export interface SampleUser { + user: User; + decryptedAccessToken: string; + decryptedRefreshToken: string; +}