Skip to content

Commit 408bfcf

Browse files
authored
[25.04.21 / TASK-148] Feature - qrcode app & QRCode API (#26)
* feat: qrcode app 컨트롤러, 서비스, 레포지토리, 타입 추가, 관련 API 구현 * hotfix: 코드래빗 리뷰 반영 * modify: 사용하지 않는 클래스 삭제 * refactor: 불필요한 의존성 삭제 * refactor: 공백 제거 * refactor: 활용도 낮은 필드 순서 정리 * modify: token 고정 길이 값 명시 & exception 재사용 * refactor: repository 테스트 코드 메서드 분리 * hotfix: uuid 삭제 * hotfix: 비즈니스 로직 흐름에 맞게 수정 비즈니스 로직 흐름에 맞게 수정 - 쿠키 값 확인 및 유효성 체크 - 쿠키 설정 및 자동 로그인 - 메인 페이지 리다이렉션 * refactor: 코드래빗 리뷰 반영 * hotfix: token 36자가 아닌 10자로 수정 * hotfix: 쿼리 작성 시 qr_login_tokens가 아닌 users_qrlogintoken으로 수정 * test: 레포지토리 통합테스트코드 구현 * hotfix: 중복 토큰 테스트 후 DB 연결 종료 * hotfix: 중복 토큰 삽입 테스트 삭제 * modify: QRLoginToken 라우터, 서비스, 레포 User쪽으로 합치기 * modify: 코드래빗 리뷰 1차 반영 * hotfix: process.env 대신 임의의 난수를 사용하도록 수정 * refactor: lint 적용 * refactor: 들여쓰기 정리 * hotfix: 슬랙도 mocking하도록 수정 * hotfix: 특정 테스트 데이터 테스트가 완료된 후 지우도록 수정 * docs: Swagger 주석 수정 및 추가 * hotfix: 토큰 생성 로직 수정 * hotfix: 실제 클라이언트의 IP에 접근하도록 수정 & logger 구체화 * refactor: 코드래빗 리뷰 반영 * docs: swagger 문서 수정 * modify: service의 getByToken이 아닌 repo의 findQRLoginToken을 사용하도록 수정 * refactor: findByVelogUUID & getDecryptedTokens 메서드 병합 * refactor: 레포 계층과의 중복 테스트코드 제거 * refactor: 사용하지 않는 import & 코드 제거
1 parent 42472b9 commit 408bfcf

File tree

12 files changed

+560
-6
lines changed

12 files changed

+560
-6
lines changed

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { NotFoundError } from './exception';
1313
dotenv.config();
1414

1515
const app: Application = express();
16+
// 실제 클라이언트 IP를 알기 위한 trust proxy 설정
17+
app.set('trust proxy', true);
1618
const swaggerSpec = swaggerJSDoc(options);
1719

1820
app.use(cookieParser());

src/controllers/user.controller.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { NextFunction, Request, Response, RequestHandler, CookieOptions } from 'express';
22
import logger from '@/configs/logger.config';
33
import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types';
4+
import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type';
45
import { UserService } from '@/services/user.service';
6+
import { InvalidTokenError, TokenExpiredError } from '@/exception/token.exception';
7+
8+
type Token10 = string & { __lengthBrand: 10 };
9+
510
export class UserController {
611
constructor(private userService: UserService) { }
712

@@ -102,4 +107,58 @@ export class UserController {
102107

103108
res.status(200).json(response);
104109
};
110+
111+
createToken: RequestHandler = async (
112+
req: Request,
113+
res: Response<QRLoginTokenResponseDto>,
114+
next: NextFunction,
115+
) => {
116+
try {
117+
const user = req.user;
118+
const ip = typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'].split(',')[0].trim() : req.ip ?? '';
119+
const userAgent = req.headers['user-agent'] || '';
120+
121+
const token = await this.userService.create(user.id, ip, userAgent);
122+
const typedToken = token as Token10;
123+
124+
const response = new QRLoginTokenResponseDto(
125+
true,
126+
'QR 토큰 생성 완료',
127+
{ token: typedToken },
128+
null
129+
);
130+
res.status(200).json(response);
131+
} catch (error) {
132+
logger.error(`QR 토큰 생성 실패: [userId: ${req.user?.id || 'anonymous'}]`, error);
133+
next(error);
134+
}
135+
};
136+
137+
getToken: RequestHandler = async (req: Request, res: Response, next: NextFunction) => {
138+
try {
139+
const token = req.query.token as string;
140+
if (!token) {
141+
throw new InvalidTokenError('토큰이 필요합니다.');
142+
}
143+
144+
const found = await this.userService.useToken(token);
145+
if (!found) {
146+
throw new TokenExpiredError();
147+
}
148+
149+
const { decryptedAccessToken, decryptedRefreshToken } =
150+
await this.userService.findUserAndTokensByVelogUUID(found.user.toString());
151+
152+
res.clearCookie('access_token', this.cookieOption());
153+
res.clearCookie('refresh_token', this.cookieOption());
154+
155+
res.cookie('access_token', decryptedAccessToken, this.cookieOption());
156+
res.cookie('refresh_token', decryptedRefreshToken, this.cookieOption());
157+
158+
res.redirect('/main');
159+
} catch (error) {
160+
logger.error(`QR 토큰 로그인 처리 실패: [userId: ${req.user?.id || 'anonymous'}]`, error);
161+
next(error);
162+
}
163+
};
105164
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import dotenv from 'dotenv';
2+
import { Pool } from 'pg';
3+
import pg from 'pg';
4+
import { UserRepository } from '@/repositories/user.repository';
5+
import { generateRandomToken } from '@/utils/generateRandomToken.util';
6+
import logger from '@/configs/logger.config';
7+
8+
dotenv.config();
9+
jest.setTimeout(5000);
10+
11+
describe('QRLoginTokenRepository 통합 테스트', () => {
12+
let testPool: Pool;
13+
let repo: UserRepository;
14+
15+
const TEST_DATA = {
16+
USER_ID: 1,
17+
};
18+
19+
beforeAll(async () => {
20+
const testPoolConfig: pg.PoolConfig = {
21+
database: process.env.DATABASE_NAME,
22+
user: process.env.POSTGRES_USER,
23+
host: process.env.POSTGRES_HOST,
24+
password: process.env.POSTGRES_PASSWORD,
25+
port: Number(process.env.POSTGRES_PORT),
26+
max: 1,
27+
idleTimeoutMillis: 30000,
28+
connectionTimeoutMillis: 5000,
29+
allowExitOnIdle: false,
30+
statement_timeout: 30000,
31+
};
32+
33+
if (process.env.POSTGRES_HOST !== 'localhost') {
34+
testPoolConfig.ssl = { rejectUnauthorized: false };
35+
}
36+
37+
testPool = new Pool(testPoolConfig);
38+
39+
await testPool.query('SELECT 1');
40+
logger.info('테스트 DB 연결 성공');
41+
42+
repo = new UserRepository(testPool);
43+
});
44+
45+
afterAll(async () => {
46+
try {
47+
await testPool.query(
48+
`
49+
DELETE FROM users_qrlogintoken
50+
WHERE ip_address = '127.0.0.1'
51+
AND user_agent = 'test-agent'
52+
AND user_id = $1
53+
`,
54+
[TEST_DATA.USER_ID]
55+
);
56+
57+
await new Promise(resolve => setTimeout(resolve, 1000));
58+
59+
if (testPool) {
60+
await testPool.end();
61+
}
62+
63+
await new Promise(resolve => setTimeout(resolve, 1000));
64+
logger.info('테스트 DB 연결 종료 및 테스트 데이터 정리 완료');
65+
} catch (error) {
66+
logger.error('테스트 종료 중 오류:', error);
67+
}
68+
});
69+
70+
describe('QR 토큰 생성 및 조회', () => {
71+
it('QR 토큰을 생성하고 정상 조회할 수 있어야 한다', async () => {
72+
const token = generateRandomToken();
73+
const ip = '127.0.0.1';
74+
const userAgent = 'test-agent';
75+
76+
await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent);
77+
const foundToken = await repo.findQRLoginToken(token);
78+
79+
// 토큰이 존재함을 확인하고 타입 단언
80+
expect(foundToken).not.toBeNull();
81+
const nonNullToken = foundToken as NonNullable<typeof foundToken>;
82+
83+
expect(nonNullToken.token).toBe(token);
84+
expect(nonNullToken.is_used).toBe(false);
85+
expect(new Date(nonNullToken.expires_at).getTime()).toBeGreaterThan(new Date(nonNullToken.created_at).getTime());
86+
});
87+
88+
it('존재하지 않는 토큰 조회 시 null을 반환해야 한다', async () => {
89+
const invalidToken = generateRandomToken();
90+
const result = await repo.findQRLoginToken(invalidToken);
91+
92+
expect(result).toBeNull();
93+
});
94+
});
95+
96+
describe('QR 토큰 사용 처리', () => {
97+
it('QR 토큰을 사용 처리한 후 조회되지 않아야 한다', async () => {
98+
const token = generateRandomToken();
99+
const ip = '127.0.0.1';
100+
const userAgent = 'test-agent';
101+
102+
await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent);
103+
await repo.markTokenUsed(token);
104+
105+
const found = await repo.findQRLoginToken(token);
106+
107+
expect(found).toBeNull();
108+
});
109+
});
110+
111+
describe('QR 토큰 만료 처리', () => {
112+
it('만료된 토큰은 조회되지 않아야 한다', async () => {
113+
const token = generateRandomToken();
114+
const ip = '127.0.0.1';
115+
const userAgent = 'test-agent';
116+
117+
await testPool.query(
118+
`
119+
INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent)
120+
VALUES ($1, $2, NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '5 minutes', false, $3, $4)
121+
`,
122+
[token, TEST_DATA.USER_ID, ip, userAgent]
123+
);
124+
125+
const found = await repo.findQRLoginToken(token);
126+
127+
expect(found).toBeNull();
128+
});
129+
130+
it('만료되고 사용된 토큰도 조회되지 않아야 한다', async () => {
131+
const token = generateRandomToken();
132+
const ip = '127.0.0.1';
133+
const userAgent = 'test-agent';
134+
135+
await testPool.query(
136+
`
137+
INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent)
138+
VALUES ($1, $2, NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '5 minutes', true, $3, $4)
139+
`,
140+
[token, TEST_DATA.USER_ID, ip, userAgent]
141+
);
142+
143+
const found = await repo.findQRLoginToken(token);
144+
145+
expect(found).toBeNull();
146+
});
147+
});
148+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { UserRepository } from '@/repositories/user.repository';
2+
import { DBError } from '@/exception';
3+
import { Pool } from 'pg';
4+
5+
const mockPool: Partial<Pool> = {
6+
query: jest.fn(),
7+
};
8+
9+
describe('QRLoginTokenRepository', () => {
10+
let repo: UserRepository;
11+
12+
beforeEach(() => {
13+
repo = new UserRepository(mockPool as Pool);
14+
});
15+
16+
afterEach(() => {
17+
jest.clearAllMocks();
18+
});
19+
20+
describe('createQRLoginToken', () => {
21+
it('QR 토큰을 성공적으로 삽입해야 한다', async () => {
22+
(mockPool.query as jest.Mock).mockResolvedValueOnce(undefined);
23+
24+
await expect(
25+
repo.createQRLoginToken('token', 1, 'ip', 'agent')
26+
).resolves.not.toThrow();
27+
28+
expect(mockPool.query).toHaveBeenCalled();
29+
});
30+
31+
it('삽입 중 오류 발생 시 DBError를 던져야 한다', async () => {
32+
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));
33+
34+
await expect(
35+
repo.createQRLoginToken('token', 1, 'ip', 'agent')
36+
).rejects.toThrow(DBError);
37+
});
38+
});
39+
40+
describe('findQRLoginToken', () => {
41+
it('토큰이 존재할 경우 반환해야 한다', async () => {
42+
const mockTokenData = { token: 'token', user: 1 };
43+
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [mockTokenData] });
44+
45+
const result = await repo.findQRLoginToken('token');
46+
expect(result).toEqual(mockTokenData);
47+
});
48+
49+
it('토큰이 존재하지 않으면 null을 반환해야 한다', async () => {
50+
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });
51+
52+
const result = await repo.findQRLoginToken('token');
53+
expect(result).toBeNull();
54+
});
55+
56+
it('조회 중 오류 발생 시 DBError를 던져야 한다', async () => {
57+
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));
58+
59+
await expect(repo.findQRLoginToken('token')).rejects.toThrow(DBError);
60+
});
61+
});
62+
63+
describe('markTokenUsed', () => {
64+
it('토큰을 사용 처리해야 한다', async () => {
65+
(mockPool.query as jest.Mock).mockResolvedValueOnce(undefined);
66+
67+
await expect(repo.markTokenUsed('token')).resolves.not.toThrow();
68+
expect(mockPool.query).toHaveBeenCalledWith(
69+
expect.stringContaining('UPDATE users_qrlogintoken SET is_used = true'),
70+
['token']
71+
);
72+
});
73+
74+
it('토큰 사용 처리 중 오류 발생 시 DBError를 던져야 한다', async () => {
75+
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));
76+
77+
await expect(repo.markTokenUsed('token')).rejects.toThrow(DBError);
78+
});
79+
});
80+
});

src/repositories/user.repository.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Pool } from 'pg';
22
import logger from '@/configs/logger.config';
33
import { User } from '@/types';
4+
import { QRLoginToken } from "@/types/models/QRLoginToken.type";
45
import { DBError } from '@/exception';
56

67
export class UserRepository {
7-
constructor(private readonly pool: Pool) { }
8+
constructor(private readonly pool: Pool) {}
89

910
async findByUserVelogUUID(uuid: string): Promise<User> {
1011
try {
@@ -91,4 +92,44 @@ export class UserRepository {
9192
throw new DBError('유저 생성 중 문제가 발생했습니다.');
9293
}
9394
}
95+
96+
async createQRLoginToken(token: string, userId: number, ip: string, userAgent: string): Promise<void> {
97+
try {
98+
const query = `
99+
INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent)
100+
VALUES ($1, $2, NOW(), NOW() + INTERVAL '5 minutes', false, $3, $4);
101+
`;
102+
await this.pool.query(query, [token, userId, ip, userAgent]);
103+
} catch (error) {
104+
logger.error('QRLoginToken Repo Create Error : ', error);
105+
throw new DBError('QR 코드 토큰 생성 중 문제가 발생했습니다.');
106+
}
107+
}
108+
109+
async findQRLoginToken(token: string): Promise<QRLoginToken | null> {
110+
try {
111+
const query = `
112+
SELECT *
113+
FROM users_qrlogintoken
114+
WHERE token = $1 AND is_used = false AND expires_at > NOW();
115+
`;
116+
const result = await this.pool.query(query, [token]);
117+
return result.rows[0] ?? null;
118+
} catch (error) {
119+
logger.error('QRLoginToken Repo find QR Code Error : ', error);
120+
throw new DBError('QR 코드 토큰 조회 중 문제가 발생했습니다.');
121+
}
122+
}
123+
124+
async markTokenUsed(token: string): Promise<void> {
125+
try {
126+
const query = `
127+
UPDATE users_qrlogintoken SET is_used = true WHERE token = $1;
128+
`;
129+
await this.pool.query(query, [token]);
130+
} catch (error) {
131+
logger.error('QRLoginToken Repo mark as used Error : ', error);
132+
throw new DBError('QR 코드 사용 처리 중 문제가 발생했습니다.');
133+
}
134+
}
94135
}

src/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ router.use('/', UserRouter);
1414
router.use('/', PostRouter);
1515
router.use('/', NotiRouter);
1616
router.use('/', LeaderboardRouter);
17+
1718
export default router;

0 commit comments

Comments
 (0)