Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 31 additions & 1 deletion src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +51,36 @@ export class UserController {
}
};

sampleLogin: RequestHandler = async (req: Request, res: Response<LoginResponseDto>, next: NextFunction): Promise<void> => {
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<EmptyResponseDto>) => {
res.clearCookie('access_token');
res.clearCookie('refresh_token');
Expand Down
51 changes: 51 additions & 0 deletions src/modules/__test__/test.slack.notifier.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof axios>;

// 테스트 대상 모듈을 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);
});
});
27 changes: 27 additions & 0 deletions src/modules/slack/slack.notifier.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const payload: SlackPayload = { text: message };
const response = await axios.post(SLACK_WEBHOOK_URL, payload, {
headers: { 'Content-Type': 'application/json' },
});
console.log(response);
}
17 changes: 16 additions & 1 deletion src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User> {
try {
Expand All @@ -17,6 +17,21 @@ export class UserRepository {
}
}

async findSampleUser(): Promise<User> {
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<User> {
try {
const query = `
Expand Down
34 changes: 33 additions & 1 deletion src/routes/user.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const userController = new UserController(userService);
* summary: 사용자 로그인
* security: []
* requestBody:
* required: false
* required: true
* content:
* application/json:
* schema:
Expand All @@ -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:
Expand Down
58 changes: 47 additions & 11 deletions src/services/user.service.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -71,16 +84,39 @@ export class UserService {
return await this.userRepo.findByUserVelogUUID(uuid);
}

async findSampleUser(): Promise<SampleUser> {
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) {
Expand Down
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
7 changes: 7 additions & 0 deletions src/types/models/User.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ export interface User {
created_at: Date;
updated_at: Date;
}


export interface SampleUser {
user: User;
decryptedAccessToken: string;
decryptedRefreshToken: string;
}