From 89b29ca7f7d7c12373b4f281612ab062fc95d522 Mon Sep 17 00:00:00 2001 From: Nuung Date: Mon, 5 May 2025 00:20:49 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feature:=20=EC=9D=B8=EA=B0=80=20=EB=AF=B8?= =?UTF-8?q?=EB=93=A4=20=EC=9B=A8=EC=96=B4=20=EB=B6=84=EB=A6=AC,=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=EC=9D=80=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A1=9C=20=ED=95=A9=EB=B3=91,=20=EA=B7=B8?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=83=80=EC=9E=85=EC=9D=84=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=ED=95=9C=20=EC=A0=84=EC=B2=B4=20=EB=A6=AC?= =?UTF-8?q?=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/user.controller.ts | 35 ++-- src/exception/index.ts | 2 +- src/exception/token.exception.ts | 17 ++ src/middlewares/auth.middleware.ts | 77 ++------- .../token_encryption/aes_encryption.ts | 7 +- src/modules/velog/velog.api.ts | 52 ++++++ .../velog}/velog.constans.ts | 0 src/modules/velog/velog.type.ts | 24 +++ .../__test__/qr.repo.integration.test.ts | 26 +-- src/repositories/__test__/qr.repo.test.ts | 66 ++++++-- src/repositories/user.repository.ts | 19 ++- src/routes/leaderboard.router.ts | 3 + src/routes/noti.router.ts | 2 +- src/routes/user.router.ts | 11 +- src/services/__test__/qr.service.test.ts | 150 +++++++++++------- src/services/user.service.ts | 58 ++++--- src/types/dto/userWithToken.type.ts | 11 +- src/types/express.d.ts | 4 +- src/types/index.ts | 3 +- src/types/models/QRLoginToken.type.ts | 5 +- src/types/models/User.type.ts | 2 +- src/types/velog.type.ts | 8 - 22 files changed, 365 insertions(+), 217 deletions(-) create mode 100644 src/modules/velog/velog.api.ts rename src/{constants => modules/velog}/velog.constans.ts (100%) create mode 100644 src/modules/velog/velog.type.ts delete mode 100644 src/types/velog.type.ts diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index dab0d9b..dfbb291 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,9 +1,10 @@ import { NextFunction, Request, Response, RequestHandler, CookieOptions } from 'express'; import logger from '@/configs/logger.config'; -import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types'; +import { EmptyResponseDto, LoginResponseDto } from '@/types'; import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type'; import { UserService } from '@/services/user.service'; -import { InvalidTokenError, TokenExpiredError } from '@/exception/token.exception'; +import { QRTokenExpiredError, QRTokenInvalidError } from '@/exception/token.exception'; +import { fetchVelogApi } from '@/modules/velog/velog.api'; type Token10 = string & { __lengthBrand: 10 }; @@ -30,12 +31,15 @@ export class UserController { login: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const { id, email, profile, username } = req.user; - const { accessToken, refreshToken } = req.tokens; - const userWithToken: UserWithTokenDto = { id, email, accessToken, refreshToken }; - const isExistUser = await this.userService.handleUserTokensByVelogUUID(userWithToken); + // 1. 외부 API (velog) 호출로 실존 하는 토큰 & 사용자 인지 검증 + const { accessToken, refreshToken } = req.body; + const velogUser = await fetchVelogApi(accessToken, refreshToken); + // 2. 우리쪽 DB에 사용자 존재 여부 체크 후 로그인 바로 진행 또는 사용자 생성 후 로그인 진행 + const user = await this.userService.handleUserTokensByVelogUUID(velogUser, accessToken, refreshToken); + + // 3. 로그이 완료 후 쿠키 세팅 res.clearCookie('access_token', this.cookieOption()); res.clearCookie('refresh_token', this.cookieOption()); @@ -45,7 +49,7 @@ export class UserController { const response = new LoginResponseDto( true, '로그인에 성공하였습니다.', - { id: isExistUser.id, username, profile }, + { id: user.id, username: velogUser.username, profile: velogUser.profile }, null, ); @@ -118,7 +122,7 @@ export class UserController { const ip = typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'].split(',')[0].trim() : req.ip ?? ''; const userAgent = req.headers['user-agent'] || ''; - const token = await this.userService.create(user.id, ip, userAgent); + const token = await this.userService.createUserQRToken(user.id, ip, userAgent); const typedToken = token as Token10; const response = new QRLoginTokenResponseDto( @@ -138,22 +142,19 @@ export class UserController { try { const token = req.query.token as string; if (!token) { - throw new InvalidTokenError('토큰이 필요합니다.'); + throw new QRTokenInvalidError('토큰이 필요합니다.'); } - const found = await this.userService.useToken(token); - if (!found) { - throw new TokenExpiredError(); + const userLoginToken = await this.userService.useToken(token); + if (!userLoginToken) { + throw new QRTokenExpiredError(); } - - const { decryptedAccessToken, decryptedRefreshToken } = - await this.userService.findUserAndTokensByVelogUUID(found.user.toString()); res.clearCookie('access_token', this.cookieOption()); res.clearCookie('refresh_token', this.cookieOption()); - res.cookie('access_token', decryptedAccessToken, this.cookieOption()); - res.cookie('refresh_token', decryptedRefreshToken, this.cookieOption()); + res.cookie('access_token', userLoginToken.decryptedAccessToken, this.cookieOption()); + res.cookie('refresh_token', userLoginToken.decryptedRefreshToken, this.cookieOption()); res.redirect('/main'); } catch (error) { diff --git a/src/exception/index.ts b/src/exception/index.ts index 782f5d3..82f83f2 100644 --- a/src/exception/index.ts +++ b/src/exception/index.ts @@ -1,6 +1,6 @@ export { CustomError } from './custom.exception'; export { DBError } from './db.exception'; -export { TokenError, TokenExpiredError, InvalidTokenError } from './token.exception'; +export { TokenError, TokenExpiredError, InvalidTokenError, QRTokenExpiredError, QRTokenInvalidError } from './token.exception'; export { UnauthorizedError } from './unauthorized.exception'; export { BadRequestError } from './badRequest.exception'; export { NotFoundError } from './notFound.exception'; diff --git a/src/exception/token.exception.ts b/src/exception/token.exception.ts index 80d0d56..550dca2 100644 --- a/src/exception/token.exception.ts +++ b/src/exception/token.exception.ts @@ -1,4 +1,5 @@ import { CustomError } from './custom.exception'; +import { BadRequestError } from './badRequest.exception'; import { UnauthorizedError } from './unauthorized.exception'; export class TokenError extends CustomError { @@ -18,3 +19,19 @@ export class InvalidTokenError extends UnauthorizedError { super(message, 'INVALID_TOKEN'); } } + +/* =================================================== +아래 부터는 QRToken 에 관한 에러 +=================================================== */ + +export class QRTokenExpiredError extends BadRequestError { + constructor(message = 'QR 토큰이 만료되었습니다') { + super(message, 'TOKEN_EXPIRED'); + } +} + +export class QRTokenInvalidError extends BadRequestError { + constructor(message = '유효하지 않은 QR 토큰입니다') { + super(message, 'INVALID_TOKEN'); + } +} diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index fdc63aa..7fb8927 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -1,10 +1,9 @@ import { NextFunction, Request, Response } from 'express'; -import axios from 'axios'; import { isUUID } from 'class-validator'; import logger from '@/configs/logger.config'; import pool from '@/configs/db.config'; -import { DBError, InvalidTokenError } from '@/exception'; -import { VELOG_API_URL, VELOG_QUERIES } from '@/constants/velog.constans'; +import { InvalidTokenError } from '@/exception'; +import { VelogJWTPayload, User } from '@/types'; /** * 요청에서 토큰을 추출하는 함수 @@ -22,60 +21,23 @@ const extractTokens = (req: Request): { accessToken: string; refreshToken: strin return { accessToken, refreshToken }; }; -/** - * Velog API를 통해 사용자 정보를 조회합니다. - * @param query - GraphQL 쿼리 문자열 - * @param accessToken - Velog access token - * @throws {Error} API 호출 실패 시 - * @returns Promise - */ -const fetchVelogApi = async (query: string, accessToken: string, refreshToken: string) => { - try { - const response = await axios.post( - VELOG_API_URL, - { query, variables: {} }, - { - headers: { - authority: 'v3.velog.io', - origin: 'https://velog.io', - 'content-type': 'application/json', - cookie: `access_token=${accessToken}; refresh_token=${refreshToken}`, - }, - }, - ); - - const result = response.data; - - if (result.errors) { - logger.error('GraphQL Errors : ', result.errors); - throw new InvalidTokenError('Velog API 인증에 실패했습니다.'); - } - - return result.data.currentUser || null; - } catch (error) { - logger.error('Velog API 호출 중 오류 : ', error); - throw new InvalidTokenError('Velog API 인증에 실패했습니다.'); - } -}; - /** * JWT 토큰에서 페이로드를 추출하고 디코딩하는 함수 + * 이건 진짜 velog 에서 사용하는 걸 그대로 가져온 함수임! * @param token - 디코딩할 JWT 토큰 문자열 - * @returns JSON 객체로 디코딩된 페이로드 + * @returns {VelogJWTPayload} * @throws {Error} 토큰이 잘못되었거나 디코딩할 수 없는 경우 * @example * const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; * const payload = extractPayload(token); * // 반환값: { sub: "1234567890" } */ -const extractPayload = (token: string) => JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); +const extractPayload = (token: string): VelogJWTPayload => JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); /** - * Bearer 토큰을 검증한뒤 최초 로그인이라면 Velog 사용자를 인증을, 아니라면 기존 사용자를 인증하여 user정보를 Request 객체에 담는 함수 - * @param query - 사용자 정보를 조회할 GraphQL 쿼리 - * @returns + * Bearer 토큰을 검증한뒤 user정보를 Request 객체에 담는 인가 함수 */ -const verifyBearerTokens = (query?: string) => { +const verifyBearerTokens = () => { return async (req: Request, res: Response, next: NextFunction) => { try { const { accessToken, refreshToken } = extractTokens(req); @@ -84,23 +46,12 @@ const verifyBearerTokens = (query?: string) => { throw new InvalidTokenError('accessToken과 refreshToken의 입력이 올바르지 않습니다'); } - let user = null; - if (query) { - user = await fetchVelogApi(query, accessToken, refreshToken); - if (!user) { - throw new InvalidTokenError('유효하지 않은 토큰입니다.'); - } - } else { - const payload = extractPayload(accessToken); - if (!payload.user_id || !isUUID(payload.user_id)) { - throw new InvalidTokenError('유효하지 않은 토큰 페이로드 입니다.'); - } - - user = (await pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [payload.user_id])).rows[0]; - if (!user) { - throw new DBError('사용자를 찾을 수 없습니다.'); - } + const payload = extractPayload(accessToken); + if (!payload.user_id || !isUUID(payload.user_id)) { + throw new InvalidTokenError('유효하지 않은 토큰 페이로드 입니다.'); } + + const user = (await pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [payload.user_id])).rows[0] as User; req.user = user; req.tokens = { accessToken, refreshToken }; @@ -114,10 +65,8 @@ const verifyBearerTokens = (query?: string) => { /** * 사용자 인증을 위한 미들웨어 모음 - * @property {Function} login - 최초 로그인 시 Velog API를 호출하는 인증 미들웨어 - * @property {Function} verify - 기존 유저를 인증하는 미들웨어 + * @property {Function} verify */ export const authMiddleware = { - login: verifyBearerTokens(VELOG_QUERIES.LOGIN), verify: verifyBearerTokens(), }; diff --git a/src/modules/token_encryption/aes_encryption.ts b/src/modules/token_encryption/aes_encryption.ts index 905b519..4c325c4 100644 --- a/src/modules/token_encryption/aes_encryption.ts +++ b/src/modules/token_encryption/aes_encryption.ts @@ -1,10 +1,15 @@ import crypto from 'crypto'; +export interface TokenEncryptionService { + encrypt(plaintext: string): string; + decrypt(encryptedData: string): string; +} + /** * AES 암호화/복호화 유틸리티 클래스 * AES-256-CBC 알고리즘을 사용하여 데이터를 암호화하고 복호화합니다. */ -class AESEncryption { +class AESEncryption implements TokenEncryptionService { private readonly key: Buffer; /** diff --git a/src/modules/velog/velog.api.ts b/src/modules/velog/velog.api.ts new file mode 100644 index 0000000..21bb6f3 --- /dev/null +++ b/src/modules/velog/velog.api.ts @@ -0,0 +1,52 @@ +import axios from 'axios'; + +import logger from '@/configs/logger.config'; +import { InvalidTokenError } from '@/exception'; +import { VELOG_API_URL, VELOG_QUERIES } from '@/modules/velog/velog.constans'; +import { VelogUserCurrentResponse } from './velog.type'; + +/** + * Velog API를 통해 사용자 정보를 조회합니다. + * @param accessToken - Velog access token + * @param refreshToken - Velog refresh token + * @throws {Error} API 호출 실패 시 + * @returns Promise + */ +export const fetchVelogApi = async (accessToken: string, refreshToken: string): Promise => { + try { + const response = await axios.post( + VELOG_API_URL, + { VELOG_QUERIES, variables: {} }, + { + headers: { + authority: 'v3.velog.io', + origin: 'https://velog.io', + 'content-type': 'application/json', + cookie: `access_token=${accessToken}; refresh_token=${refreshToken}`, + }, + }, + ); + + const result = response.data; + + if (result.errors) { + logger.error('GraphQL Errors : ', result.errors); + throw new InvalidTokenError('Velog API 인증에 실패했습니다.'); + } + + if (!result.data.currentUser) { + logger.error('Velog API 응답에 currentUser 정보가 없습니다.'); + throw new InvalidTokenError('Velog 사용자 정보를 가져오지 못했습니다.'); + } + + // email이 undefined인 경우 null로 변환 + const currentUser = result.data.currentUser; + return { + ...currentUser, + email: currentUser.email ?? null + }; + } catch (error) { + logger.error('Velog API 호출 중 오류 : ', error); + throw new InvalidTokenError('Velog API 인증에 실패했습니다.'); + } +}; \ No newline at end of file diff --git a/src/constants/velog.constans.ts b/src/modules/velog/velog.constans.ts similarity index 100% rename from src/constants/velog.constans.ts rename to src/modules/velog/velog.constans.ts diff --git a/src/modules/velog/velog.type.ts b/src/modules/velog/velog.type.ts new file mode 100644 index 0000000..497906e --- /dev/null +++ b/src/modules/velog/velog.type.ts @@ -0,0 +1,24 @@ + +/** + * Velog 쪽에서 사용하는 토큰을 사용할때 실제 payload 값 + */ +export interface VelogJWTPayload { + user_id: string; // UUID 값, key를 바꿀 순 없음 + iat: number; // issued at timestamp + exp: number; // expiration timestamp + iss: string; // issuer + sub: string; // subject +} + + +/** + * Velog 쪽에서 사용하는, 실제 currentUser API 호출시 주는 값 중 일부분 + */ +export interface VelogUserCurrentResponse { + id: string; // 이는 실제로 uuid를 줌 + username: string; + email: string | null; + profile: { + thumbnail: string; + }; +} diff --git a/src/repositories/__test__/qr.repo.integration.test.ts b/src/repositories/__test__/qr.repo.integration.test.ts index d18a048..733f460 100644 --- a/src/repositories/__test__/qr.repo.integration.test.ts +++ b/src/repositories/__test__/qr.repo.integration.test.ts @@ -8,7 +8,7 @@ import logger from '@/configs/logger.config'; dotenv.config(); jest.setTimeout(5000); -describe('QRLoginTokenRepository 통합 테스트', () => { +describe('UserRepository QR 토큰 통합 테스트', () => { let testPool: Pool; let repo: UserRepository; @@ -53,13 +53,13 @@ describe('QRLoginTokenRepository 통합 테스트', () => { `, [TEST_DATA.USER_ID] ); - + await new Promise(resolve => setTimeout(resolve, 1000)); - + if (testPool) { await testPool.end(); } - + await new Promise(resolve => setTimeout(resolve, 1000)); logger.info('테스트 DB 연결 종료 및 테스트 데이터 정리 완료'); } catch (error) { @@ -79,9 +79,12 @@ describe('QRLoginTokenRepository 통합 테스트', () => { // 토큰이 존재함을 확인하고 타입 단언 expect(foundToken).not.toBeNull(); const nonNullToken = foundToken as NonNullable; - + expect(nonNullToken.token).toBe(token); + expect(Number(nonNullToken.user_id)).toBe(TEST_DATA.USER_ID); expect(nonNullToken.is_used).toBe(false); + expect(nonNullToken.ip_address).toBe(ip); + expect(nonNullToken.user_agent).toBe(userAgent); expect(new Date(nonNullToken.expires_at).getTime()).toBeGreaterThan(new Date(nonNullToken.created_at).getTime()); }); @@ -100,11 +103,16 @@ describe('QRLoginTokenRepository 통합 테스트', () => { const userAgent = 'test-agent'; await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent); - await repo.markTokenUsed(token); - const found = await repo.findQRLoginToken(token); + // 토큰 조회 후 user_id를 얻어 updateQRLoginTokenToUse 호출 + const foundToken = await repo.findQRLoginToken(token); + expect(foundToken).not.toBeNull(); - expect(found).toBeNull(); + await repo.updateQRLoginTokenToUse(TEST_DATA.USER_ID); + + // 토큰이 is_used=true로 변경되었으므로 findQRLoginToken에서 null 반환 예상 + const afterUpdate = await repo.findQRLoginToken(token); + expect(afterUpdate).toBeNull(); }); }); @@ -145,4 +153,4 @@ describe('QRLoginTokenRepository 통합 테스트', () => { expect(found).toBeNull(); }); }); -}); +}); \ No newline at end of file diff --git a/src/repositories/__test__/qr.repo.test.ts b/src/repositories/__test__/qr.repo.test.ts index 184fb85..6cd703c 100644 --- a/src/repositories/__test__/qr.repo.test.ts +++ b/src/repositories/__test__/qr.repo.test.ts @@ -1,12 +1,13 @@ import { UserRepository } from '@/repositories/user.repository'; import { DBError } from '@/exception'; import { Pool } from 'pg'; +import { QRLoginToken } from "@/types/models/QRLoginToken.type"; const mockPool: Partial = { query: jest.fn(), }; -describe('QRLoginTokenRepository', () => { +describe('UserRepository - QR Login Token', () => { let repo: UserRepository; beforeEach(() => { @@ -25,7 +26,12 @@ describe('QRLoginTokenRepository', () => { repo.createQRLoginToken('token', 1, 'ip', 'agent') ).resolves.not.toThrow(); - expect(mockPool.query).toHaveBeenCalled(); + // 가장 중요한 검증 포인트만 확인 + expect(mockPool.query).toHaveBeenCalledTimes(1); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO users_qrlogintoken'), + expect.arrayContaining(['token', 1, 'ip', 'agent']) + ); }); it('삽입 중 오류 발생 시 DBError를 던져야 한다', async () => { @@ -38,43 +44,81 @@ describe('QRLoginTokenRepository', () => { }); describe('findQRLoginToken', () => { - it('토큰이 존재할 경우 반환해야 한다', async () => { - const mockTokenData = { token: 'token', user: 1 }; + it('유효한 토큰이 존재할 경우 반환해야 한다', async () => { + const mockTokenData: QRLoginToken = { + id: 1, + token: 'token', + user_id: 1, + is_used: false, + ip_address: 'ip', + user_agent: 'agent', + created_at: new Date(), + expires_at: new Date(Date.now() + 5 * 60 * 1000) // 5분 후 + }; + (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [mockTokenData] }); const result = await repo.findQRLoginToken('token'); + + // 결과 값 검증 expect(result).toEqual(mockTokenData); + + // 쿼리 호출 검증 - 필수 요소만 검증 + expect(mockPool.query).toHaveBeenCalledTimes(1); + expect(mockPool.query).toHaveBeenCalledWith( + expect.any(String), // 쿼리 내용은 구현 세부사항으로 간주 + ['token'] // 매개변수는 중요하므로 정확히 검증 + ); }); it('토큰이 존재하지 않으면 null을 반환해야 한다', async () => { (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] }); const result = await repo.findQRLoginToken('token'); + + // null 반환 검증 expect(result).toBeNull(); + + // 쿼리 호출 검증 + expect(mockPool.query).toHaveBeenCalledTimes(1); + expect(mockPool.query).toHaveBeenCalledWith( + expect.any(String), + ['token'] + ); }); it('조회 중 오류 발생 시 DBError를 던져야 한다', async () => { (mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail')); await expect(repo.findQRLoginToken('token')).rejects.toThrow(DBError); + + expect(mockPool.query).toHaveBeenCalledTimes(1); + expect(mockPool.query).toHaveBeenCalledWith( + expect.any(String), + ['token'] + ); }); }); - describe('markTokenUsed', () => { - it('토큰을 사용 처리해야 한다', async () => { + describe('updateQRLoginTokenToUse', () => { + it('유저 ID로 토큰을 사용 처리해야 한다', async () => { (mockPool.query as jest.Mock).mockResolvedValueOnce(undefined); - await expect(repo.markTokenUsed('token')).resolves.not.toThrow(); + await expect(repo.updateQRLoginTokenToUse(1)).resolves.not.toThrow(); + + expect(mockPool.query).toHaveBeenCalledTimes(1); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('UPDATE users_qrlogintoken SET is_used = true'), - ['token'] + expect.stringContaining('UPDATE users_qrlogintoken'), + [1] ); }); it('토큰 사용 처리 중 오류 발생 시 DBError를 던져야 한다', async () => { (mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail')); - await expect(repo.markTokenUsed('token')).rejects.toThrow(DBError); + await expect(repo.updateQRLoginTokenToUse(1)).rejects.toThrow(DBError); + + expect(mockPool.query).toHaveBeenCalledTimes(1); }); }); -}); +}); \ No newline at end of file diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 76c4655..2615f46 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -7,6 +7,17 @@ import { DBError } from '@/exception'; export class UserRepository { constructor(private readonly pool: Pool) {} + async findByUserId(id: number): Promise { + try { + const user = await this.pool.query('SELECT * FROM "users_user" WHERE id = $1', [id]); + + return user.rows[0] || null; + } catch (error) { + logger.error('Id로 유저를 조회 중 오류 : ', error); + throw new DBError('유저 조회 중 문제가 발생했습니다.'); + } + } + async findByUserVelogUUID(uuid: string): Promise { try { const user = await this.pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [uuid]); @@ -57,7 +68,7 @@ export class UserRepository { async createUser( uuid: string, - email: string | undefined, + email: string | null, encryptedAccessToken: string, encryptedRefreshToken: string, groupId: number, @@ -121,12 +132,12 @@ export class UserRepository { } } - async markTokenUsed(token: string): Promise { + async updateQRLoginTokenToUse(user_id: number): Promise { try { const query = ` - UPDATE users_qrlogintoken SET is_used = true WHERE token = $1; + UPDATE users_qrlogintoken SET is_used = true WHERE user_id = $1; `; - await this.pool.query(query, [token]); + await this.pool.query(query, [user_id]); } catch (error) { logger.error('QRLoginToken Repo mark as used Error : ', error); throw new DBError('QR 코드 사용 처리 중 문제가 발생했습니다.'); diff --git a/src/routes/leaderboard.router.ts b/src/routes/leaderboard.router.ts index 64a6cb4..6f259ac 100644 --- a/src/routes/leaderboard.router.ts +++ b/src/routes/leaderboard.router.ts @@ -1,5 +1,6 @@ import pool from '@/configs/db.config'; import express, { Router } from 'express'; +import { authMiddleware } from '@/middlewares/auth.middleware'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { LeaderboardService } from '@/services/leaderboard.service'; import { LeaderboardController } from '@/controllers/leaderboard.controller'; @@ -44,6 +45,7 @@ const leaderboardController = new LeaderboardController(leaderboardService); */ router.get( '/leaderboard/user', + authMiddleware.verify, validateRequestDto(GetUserLeaderboardQueryDto, 'query'), leaderboardController.getUserLeaderboard, ); @@ -80,6 +82,7 @@ router.get( */ router.get( '/leaderboard/post', + authMiddleware.verify, validateRequestDto(GetPostLeaderboardQueryDto, 'query'), leaderboardController.getPostLeaderboard, ); diff --git a/src/routes/noti.router.ts b/src/routes/noti.router.ts index 81ca796..9a6b9dc 100644 --- a/src/routes/noti.router.ts +++ b/src/routes/noti.router.ts @@ -39,6 +39,6 @@ const notiController = new NotiController(notiService); * 500: * description: 서버 에러 */ -router.get('/notis', authMiddleware.login, notiController.getAllNotiPosts); +router.get('/notis', authMiddleware.verify, notiController.getAllNotiPosts); export default router; diff --git a/src/routes/user.router.ts b/src/routes/user.router.ts index a841540..8850f66 100644 --- a/src/routes/user.router.ts +++ b/src/routes/user.router.ts @@ -4,8 +4,8 @@ import { UserController } from '@/controllers/user.controller'; import { UserRepository } from '@/repositories/user.repository'; import { UserService } from '@/services/user.service'; import { authMiddleware } from '@/middlewares/auth.middleware'; -// import { validateRequestDto } from '@/middlewares/validation.middleware'; -// import { VelogUserLoginDto } from '@/types'; +import { validateRequestDto } from '@/middlewares/validation.middleware'; +import { LoginRequestDto } from '@/types'; const router: Router = express.Router(); @@ -46,8 +46,7 @@ const userController = new UserController(userService); * '500': * description: 서버 오류 / 데이터 베이스 조회 오류 */ -router.post('/login', authMiddleware.login, userController.login); -// router.post('/login', authMiddleware.login, validateRequestDto(VelogUserLoginDto, 'user'), userController.login); +router.post('/login', validateRequestDto(LoginRequestDto, 'body'), userController.login); /** * @swagger @@ -113,7 +112,7 @@ router.post('/logout', authMiddleware.verify, userController.logout); * schema: * $ref: '#/components/schemas/LoginResponseDto' */ -router.get('/me', authMiddleware.login, userController.fetchCurrentUser); +router.get('/me', authMiddleware.verify, userController.fetchCurrentUser); /** * @swagger @@ -127,7 +126,7 @@ router.get('/me', authMiddleware.login, userController.fetchCurrentUser); * 200: * description: QR 로그인 토큰 생성 성공 */ -router.post('/qr-login', authMiddleware.login, userController.createToken); +router.post('/qr-login', authMiddleware.verify, userController.createToken); /** * @swagger diff --git a/src/services/__test__/qr.service.test.ts b/src/services/__test__/qr.service.test.ts index 9315fac..ac2a81b 100644 --- a/src/services/__test__/qr.service.test.ts +++ b/src/services/__test__/qr.service.test.ts @@ -2,96 +2,140 @@ import { UserService } from '@/services/user.service'; import { UserRepository } from '@/repositories/user.repository'; import { DBError } from '@/exception'; import { QRLoginToken } from '@/types/models/QRLoginToken.type'; +import { User } from '@/types'; import { Pool } from 'pg'; -import crypto from 'crypto'; -const validKey = crypto.randomBytes(32).toString('utf8'); +// AESEncryption 클래스 모킹 +jest.mock('@/modules/token_encryption/aes_encryption', () => { + // TokenEncryptionService 인터페이스를 구현한 모의 객체 반환 + return jest.fn().mockImplementation(() => ({ + encrypt: jest.fn().mockReturnValue('encrypted-token'), + decrypt: jest.fn().mockImplementation(token => { + // useToken 메서드에서 필요한 반환값 제공 + if (token === 'encrypted-access-token') return 'decrypted-access-token'; + if (token === 'encrypted-refresh-token') return 'decrypted-refresh-token'; + return 'unknown-token'; + }) + })); +}); +// getKeyByGroup 모킹 - 키를 항상 리턴하도록 jest.mock('@/utils/key.util', () => ({ - getKeyByGroup: () => validKey, -})); - -jest.mock('@/modules/slack/slack.notifier', () => ({ - sendSlackMessage: jest.fn(), + getKeyByGroup: jest.fn().mockReturnValue('01234567890123456789012345678901') // 32바이트 키 })); +// UserRepository 모킹 jest.mock('@/repositories/user.repository'); -describe('UserService 의 QRService', () => { - let service: UserService; - let repo: jest.Mocked; +describe('UserService의 QR 로그인 기능', () => { + let userService: UserService; + let userRepo: jest.Mocked; + let mockPool: jest.Mocked; beforeEach(() => { - const mockPool = {} as jest.Mocked; + // DB Pool 목 설정 + const mockPoolObj = {}; + mockPool = mockPoolObj as jest.Mocked; + const repoInstance = new UserRepository(mockPool); - repo = repoInstance as jest.Mocked; - service = new UserService(repo); + userRepo = repoInstance as jest.Mocked; + userService = new UserService(userRepo); }); afterEach(() => { jest.clearAllMocks(); }); - describe('create', () => { + describe('createUserQRToken', () => { it('QR 토큰을 생성하고 반환해야 한다', async () => { const userId = 1; const ip = '127.0.0.1'; const userAgent = 'Chrome'; - - const token = await service.create(userId, ip, userAgent); - + + const token = await userService.createUserQRToken(userId, ip, userAgent); + expect(typeof token).toBe('string'); expect(token.length).toBe(10); expect(/^[A-Za-z0-9\-_.~!]{10}$/.test(token)).toBe(true); - expect(repo.createQRLoginToken).toHaveBeenCalledWith(token, userId, ip, userAgent); + expect(userRepo.createQRLoginToken).toHaveBeenCalledWith(token, userId, ip, userAgent); }); it('QR 토큰 생성 중 오류 발생 시 예외 발생', async () => { - repo.createQRLoginToken.mockRejectedValueOnce(new DBError('생성 실패')); + userRepo.createQRLoginToken.mockRejectedValueOnce(new DBError('생성 실패')); - await expect(service.create(1, 'ip', 'agent')).rejects.toThrow('생성 실패'); + await expect(userService.createUserQRToken(1, 'ip', 'agent')).rejects.toThrow('생성 실패'); }); }); describe('useToken', () => { - it('유효한 토큰 사용 처리 후 반환', async () => { - const mockToken: QRLoginToken = { - token: 'token', - user: 1, - created_at: new Date(), - expires_at: new Date(), - is_used: false, - ip_address: '127.0.0.1', - user_agent: 'Chrome', - }; - repo.findQRLoginToken.mockResolvedValue(mockToken); - - const result = await service.useToken('token'); - - expect(result).toEqual(mockToken); - expect(repo.markTokenUsed).toHaveBeenCalledWith('token'); + const mockUser: User = { + id: 1, + velog_uuid: 'uuid-1', + access_token: 'encrypted-access-token', + refresh_token: 'encrypted-refresh-token', + email: 'test@example.com', + group_id: 1, + is_active: true, + created_at: new Date(), + updated_at: new Date() + }; + + const mockQRToken: QRLoginToken = { + id: 1, + token: 'token', + user_id: 1, + created_at: new Date(), + expires_at: new Date(), + is_used: false, + ip_address: '127.0.0.1', + user_agent: 'Chrome' + }; + + it('유효한 토큰 사용 시 복호화된 토큰 정보 반환', async () => { + userRepo.findQRLoginToken.mockResolvedValue(mockQRToken); + userRepo.findByUserId.mockResolvedValue(mockUser); + + const result = await userService.useToken('token'); + + expect(result).toEqual({ + decryptedAccessToken: 'decrypted-access-token', + decryptedRefreshToken: 'decrypted-refresh-token' + }); + expect(userRepo.findQRLoginToken).toHaveBeenCalledWith('token'); + expect(userRepo.findByUserId).toHaveBeenCalledWith(mockQRToken.user_id); + expect(userRepo.updateQRLoginTokenToUse).toHaveBeenCalledWith(mockQRToken.user_id); }); it('토큰이 존재하지 않으면 null 반환', async () => { - repo.findQRLoginToken.mockResolvedValue(null); - const result = await service.useToken('token'); + userRepo.findQRLoginToken.mockResolvedValue(null); + + const result = await userService.useToken('token'); + expect(result).toBeNull(); + expect(userRepo.findQRLoginToken).toHaveBeenCalledWith('token'); + expect(userRepo.findByUserId).not.toHaveBeenCalled(); + expect(userRepo.updateQRLoginTokenToUse).not.toHaveBeenCalled(); + }); + + it('findByUserId 호출 시 예외가 발생하면 전파', async () => { + userRepo.findQRLoginToken.mockResolvedValue(mockQRToken); + userRepo.findByUserId.mockRejectedValueOnce(new DBError('사용자 조회 실패')); + + await expect(userService.useToken('token')).rejects.toThrow('사용자 조회 실패'); + expect(userRepo.findQRLoginToken).toHaveBeenCalledWith('token'); + expect(userRepo.findByUserId).toHaveBeenCalledWith(mockQRToken.user_id); + expect(userRepo.updateQRLoginTokenToUse).not.toHaveBeenCalled(); }); - it('markTokenUsed 호출 시 예외 발생하면 전파', async () => { - const mockToken: QRLoginToken = { - token: 'token', - user: 1, - created_at: new Date(), - expires_at: new Date(), - is_used: false, - ip_address: '127.0.0.1', - user_agent: 'Chrome', - }; - repo.findQRLoginToken.mockResolvedValue(mockToken); - repo.markTokenUsed.mockRejectedValueOnce(new DBError('사용 처리 실패')); - - await expect(service.useToken('token')).rejects.toThrow('사용 처리 실패'); + it('updateQRLoginTokenToUse 호출 시 예외가 발생하면 전파', async () => { + userRepo.findQRLoginToken.mockResolvedValue(mockQRToken); + userRepo.findByUserId.mockResolvedValue(mockUser); + userRepo.updateQRLoginTokenToUse.mockRejectedValueOnce(new DBError('토큰 사용 처리 실패')); + + await expect(userService.useToken('token')).rejects.toThrow('토큰 사용 처리 실패'); + expect(userRepo.findQRLoginToken).toHaveBeenCalledWith('token'); + expect(userRepo.findByUserId).toHaveBeenCalledWith(mockQRToken.user_id); + expect(userRepo.updateQRLoginTokenToUse).toHaveBeenCalledWith(mockQRToken.user_id); }); }); -}); +}); \ No newline at end of file diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 13e4073..723da4e 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -6,8 +6,8 @@ import { sendSlackMessage } from '@/modules/slack/slack.notifier'; import { UserRepository } from '@/repositories/user.repository'; import { UserWithTokenDto, User, SampleUser } from '@/types'; import { generateRandomGroupId } from '@/utils/generateGroupId.util'; -import { QRLoginToken } from "@/types/models/QRLoginToken.type"; import { generateRandomToken } from '@/utils/generateRandomToken.util'; +import { VelogUserCurrentResponse } from '@/modules/velog/velog.type'; export class UserService { constructor(private userRepo: UserRepository) { } @@ -50,20 +50,24 @@ export class UserService { } } - async handleUserTokensByVelogUUID(userData: UserWithTokenDto) { - const { id, email, accessToken, refreshToken } = userData; + async handleUserTokensByVelogUUID(userData: VelogUserCurrentResponse, accessToken: string, refreshToken: string): Promise { + // velog response 에서 주는 응답 혼용 방지를 위한 변경 id -> uuid + const { id: uuid, email = null } = userData; try { - let user = await this.userRepo.findByUserVelogUUID(id); + let user = await this.userRepo.findByUserVelogUUID(uuid); + // 신규 유저라면 암호화가 안된 token 으로 사용자를 우선 바로 생성 if (!user) { user = await this.createUser({ - id, - email, // undefined도 그대로 전달 + uuid, + email, accessToken, refreshToken, }); } + // 이제 부터는 모든 유저는 기유저로 판단, 로그인 할때마다 토큰을 업데이트 해줌 + // 대신 토큰은 무조건 암호화 된 상태로만 저장되게 업데이트 로직 const { encryptedAccessToken, encryptedRefreshToken } = this.encryptTokens( user.group_id, accessToken, @@ -71,7 +75,7 @@ export class UserService { ); return await this.updateUserTokens({ - id, + uuid, email, accessToken: encryptedAccessToken, refreshToken: encryptedRefreshToken, @@ -100,7 +104,7 @@ export class UserService { async createUser(userData: UserWithTokenDto) { const groupId = generateRandomGroupId(); const newUser = await this.userRepo.createUser( - userData.id, + userData.uuid, userData.email, userData.accessToken, userData.refreshToken, @@ -109,7 +113,7 @@ export class UserService { // 신규 유저 웹훅 알림 try { - await sendSlackMessage(`새로운 유저 등록: ${userData.id}${userData.email ? `, ${userData.email}` : ''}`); + await sendSlackMessage(`새로운 유저 등록: ${userData.uuid}${userData.email ? `, ${userData.email}` : ''}`); } catch (error) { // Slack 알림 실패는 사용자 생성에 영향을 주지 않도록 logger.error('Slack 알림 전송 실패:', error); @@ -118,38 +122,30 @@ export class UserService { } async updateUserTokens(userData: UserWithTokenDto) { - return await this.userRepo.updateTokens(userData.id, userData.accessToken, userData.refreshToken); + return await this.userRepo.updateTokens(userData.uuid, userData.accessToken, userData.refreshToken); } - async findUserAndTokensByVelogUUID(uuid: string): Promise<{ user: User; decryptedAccessToken: string; decryptedRefreshToken: string }> { - const user = await this.userRepo.findByUserVelogUUID(uuid); - if (!user) { - throw new NotFoundError('유저를 찾을 수 없습니다.'); - } - - const { decryptedAccessToken, decryptedRefreshToken } = this.decryptTokens( - user.group_id, - user.access_token, - user.refresh_token, - ); - - return { user, decryptedAccessToken, decryptedRefreshToken }; - } - - async create(userId: number, ip: string, userAgent: string): Promise { + async createUserQRToken(userId: number, ip: string, userAgent: string): Promise { const token = generateRandomToken(10); await this.userRepo.createQRLoginToken(token, userId, ip, userAgent); return token; } - async useToken(token: string): Promise { + async useToken(token: string): Promise<{ decryptedAccessToken: string; decryptedRefreshToken: string } | null> { + // 1. 사용자의 토큰을 찾는다. const qrToken = await this.userRepo.findQRLoginToken(token); - if (!qrToken) { return null; } - - await this.userRepo.markTokenUsed(token); - return qrToken; + + // 2. 찾은 토큰을 기반으로 사용자를 다시 찾는다. (토큰 획득 및 사용자의 QR토큰 모두 비활성화) + const qrTokenUser = await this.userRepo.findByUserId(qrToken.user_id) + await this.userRepo.updateQRLoginTokenToUse(qrToken.user_id); + const { decryptedAccessToken, decryptedRefreshToken } = this.decryptTokens( + qrTokenUser.group_id, + qrTokenUser.access_token, + qrTokenUser.refresh_token, + ); + return { decryptedAccessToken, decryptedRefreshToken }; } } diff --git a/src/types/dto/userWithToken.type.ts b/src/types/dto/userWithToken.type.ts index 7b0e6b2..c03a8b7 100644 --- a/src/types/dto/userWithToken.type.ts +++ b/src/types/dto/userWithToken.type.ts @@ -1,12 +1,13 @@ -import { IsEmail, IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { IsEmail, IsOptional, IsNotEmpty, IsString, IsUUID } from 'class-validator'; export class UserWithTokenDto { @IsNotEmpty() @IsUUID() - id: string; + uuid: string; + @IsOptional() @IsEmail() - email: string; + email: string | null = null; // undefined 가능성 없애고 null 로 고정 @IsNotEmpty() @IsString() @@ -16,8 +17,8 @@ export class UserWithTokenDto { @IsString() refreshToken: string; - constructor(id: string, email: string, accessToken: string, refreshToken: string) { - this.id = id; + constructor(uuid: string, email: string | null, accessToken: string, refreshToken: string) { + this.uuid = uuid; this.email = email; this.accessToken = accessToken; this.refreshToken = refreshToken; diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 03a95c3..53e83d3 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,9 +1,9 @@ -import { VelogUserLoginResponse } from '@/velog.type'; +import { User } from '@/velog.type'; declare global { namespace Express { interface Request { - user: VelogUserLoginResponse; + user: User; tokens: { accessToken: string; refreshToken: string; diff --git a/src/types/index.ts b/src/types/index.ts index d399334..b10ca69 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,10 +2,11 @@ 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'; -export type { VelogUserLoginResponse } from '@/types/velog.type'; +export type { VelogJWTPayload, VelogUserCurrentResponse } from '@/modules/velog/velog.type'; export type { GetAllPostsQuery } from '@/types/dto/requests/getAllPostsQuery.type'; export type { GetPostQuery, PostParam } from '@/types/dto/requests/getPostQuery.type'; +export { LoginRequestDto } from '@/types/dto/requests/loginRequest.type'; export { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type'; export { GetPostQueryDto } from '@/types/dto/requests/getPostQuery.type'; export { diff --git a/src/types/models/QRLoginToken.type.ts b/src/types/models/QRLoginToken.type.ts index 1e3930b..f63e8eb 100644 --- a/src/types/models/QRLoginToken.type.ts +++ b/src/types/models/QRLoginToken.type.ts @@ -1,9 +1,10 @@ export interface QRLoginToken { + id: number; token: string; - user: number; + user_id: number; is_used: boolean; ip_address: string; user_agent: string; - created_at: Date; expires_at: Date; + created_at: Date; } diff --git a/src/types/models/User.type.ts b/src/types/models/User.type.ts index a1d1a37..2b173fd 100644 --- a/src/types/models/User.type.ts +++ b/src/types/models/User.type.ts @@ -4,7 +4,7 @@ export interface User { access_token: string; refresh_token: string; group_id: number; - email?: string; + email: string | null; is_active: boolean; created_at: Date; updated_at: Date; diff --git a/src/types/velog.type.ts b/src/types/velog.type.ts deleted file mode 100644 index 2f2760d..0000000 --- a/src/types/velog.type.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface VelogUserLoginResponse { - id: string; - username: string; - email?: string; - profile: { - thumbnail: string; - }; -} From 865026330889adb4d57f6dd9d0921e8790da0d06 Mon Sep 17 00:00:00 2001 From: Nuung Date: Mon, 5 May 2025 00:37:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feature:=20=EB=AF=B8=EB=93=A4=EC=9B=A8?= =?UTF-8?q?=EC=96=B4=20=EC=9E=90=EC=B2=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80,=20=EA=B7=B8=EC=97=90?= =?UTF-8?q?=20=EB=94=B0=EB=A5=B8=20velog=20api=20=EC=9C=A0=EB=8B=9B=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/auth.middleware.test.ts | 240 ++++++++++++++++++ src/modules/__test__/velog.api.test.ts | 155 +++++++++++ src/modules/velog/velog.api.ts | 5 + 3 files changed, 400 insertions(+) create mode 100644 src/middlewares/__test__/auth.middleware.test.ts create mode 100644 src/modules/__test__/velog.api.test.ts diff --git a/src/middlewares/__test__/auth.middleware.test.ts b/src/middlewares/__test__/auth.middleware.test.ts new file mode 100644 index 0000000..9f48636 --- /dev/null +++ b/src/middlewares/__test__/auth.middleware.test.ts @@ -0,0 +1,240 @@ +import { Request, Response } from 'express'; +import { authMiddleware } from '@/middlewares/auth.middleware'; +import pool from '@/configs/db.config'; + +// pool.query 모킹 +jest.mock('@/configs/db.config', () => ({ + query: jest.fn(), +})); + +// logger 모킹 +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +describe('인증 미들웨어', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: jest.Mock; + + beforeEach(() => { + // 테스트마다 request, response, next 함수 초기화 + mockRequest = { + body: {}, + headers: {}, + cookies: {}, + }; + mockResponse = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + nextFunction = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('verify', () => { + const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYzc1MDcyNDAtMDkzYi0xMWVhLTlhYWUtYTU4YTg2YmIwNTIwIiwiaWF0IjoxNjAzOTM0NTI5LCJleHAiOjE2MDM5MzgxMjksImlzcyI6InZlbG9nLmlvIiwic3ViIjoiYWNjZXNzX3Rva2VuIn0.Q_I4PMBeeZSU-HbPZt7z9OW-tQjE0NI0I0DLF2qpZjY'; + + it('유효한 토큰으로 사용자 정보를 Request에 추가해야 한다', async () => { + // 유효한 토큰 준비 + mockRequest.cookies = { + 'access_token': validToken, + 'refresh_token': 'refresh-token' + }; + + // 사용자 정보 mock + const mockUser = { + id: 1, + username: 'testuser', + email: 'test@example.com', + velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520' + }; + + // DB 쿼리 결과 모킹 + (pool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockUser] + }); + + // 미들웨어 실행 + await authMiddleware.verify( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // 검증 + expect(nextFunction).toHaveBeenCalledTimes(1); + expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error)); + expect(mockRequest.user).toEqual(mockUser); + expect(mockRequest.tokens).toEqual({ + accessToken: validToken, + refreshToken: 'refresh-token' + }); + expect(pool.query).toHaveBeenCalledWith( + 'SELECT * FROM "users_user" WHERE velog_uuid = $1', + ['c7507240-093b-11ea-9aae-a58a86bb0520'] + ); + }); + + it('토큰이 없으면 InvalidTokenError를 전달해야 한다', async () => { + // 토큰 없음 + mockRequest.cookies = {}; + + // 미들웨어 실행 + await authMiddleware.verify( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // 검증 + expect(nextFunction).toHaveBeenCalledTimes(1); + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'InvalidTokenError', + message: 'accessToken과 refreshToken의 입력이 올바르지 않습니다' + }) + ); + }); + + it('유효하지 않은 토큰으로 InvalidTokenError를 전달해야 한다', async () => { + // 유효하지 않은 토큰 (JWT 형식은 맞지만 내용이 잘못됨) + const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnZhbGlkIjoidG9rZW4ifQ.invalidSignature'; + mockRequest.cookies = { + 'access_token': invalidToken, + 'refresh_token': 'refresh-token' + }; + + // 미들웨어 실행 + await authMiddleware.verify( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // 검증 + expect(nextFunction).toHaveBeenCalledTimes(1); + expect(nextFunction).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('UUID가 없는 페이로드로 InvalidTokenError를 전달해야 한다', async () => { + // UUID가 없는 토큰 (페이로드를 임의로 조작) + const tokenWithoutUUID = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MDM5MzQ1MjksImV4cCI6MTYwMzkzODEyOSwiaXNzIjoidmVsb2cuaW8iLCJzdWIiOiJhY2Nlc3NfdG9rZW4ifQ.2fLHQ3yKs9UmBQUa2oat9UOLiXzXvrhv_XHU2qwLBs8'; + + mockRequest.cookies = { + 'access_token': tokenWithoutUUID, + 'refresh_token': 'refresh-token' + }; + + // 미들웨어 실행 + await authMiddleware.verify( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // 검증 + expect(nextFunction).toHaveBeenCalledTimes(1); + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'InvalidTokenError', + message: '유효하지 않은 토큰 페이로드 입니다.' + }) + ); + }); + + it('사용자를 찾을 수 없으면 next를 호출해야 한다', async () => { + // 유효한 토큰 준비 + mockRequest.cookies = { + 'access_token': validToken, + 'refresh_token': 'refresh-token' + }; + + // 사용자가 없음 모킹 + (pool.query as jest.Mock).mockResolvedValueOnce({ + rows: [] + }); + + // 미들웨어 실행 + await authMiddleware.verify( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // 검증 + expect(nextFunction).toHaveBeenCalledTimes(1); + expect(mockRequest.user).toBeUndefined(); + }); + + it('쿠키 대신 요청 본문에서 토큰을 추출해야 한다', async () => { + // 요청 본문에 토큰 설정 + mockRequest.body = { + accessToken: validToken, + refreshToken: 'refresh-token' + }; + + // 사용자 정보 mock + const mockUser = { + id: 1, + username: 'testuser', + email: 'test@example.com', + velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520' + }; + + // DB 쿼리 결과 모킹 + (pool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockUser] + }); + + // 미들웨어 실행 + await authMiddleware.verify( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // 검증 + expect(nextFunction).toHaveBeenCalledTimes(1); + expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error)); + expect(mockRequest.user).toEqual(mockUser); + }); + + it('쿠키와 요청 본문 대신 헤더에서 토큰을 추출해야 한다', async () => { + // 헤더에 토큰 설정 + mockRequest.headers = { + 'access_token': validToken, + 'refresh_token': 'refresh-token' + }; + + // 사용자 정보 mock + const mockUser = { + id: 1, + username: 'testuser', + email: 'test@example.com', + velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520' + }; + + // DB 쿼리 결과 모킹 + (pool.query as jest.Mock).mockResolvedValueOnce({ + rows: [mockUser] + }); + + // 미들웨어 실행 + await authMiddleware.verify( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // 검증 + expect(nextFunction).toHaveBeenCalledTimes(1); + expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error)); + expect(mockRequest.user).toEqual(mockUser); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/__test__/velog.api.test.ts b/src/modules/__test__/velog.api.test.ts new file mode 100644 index 0000000..92b66a1 --- /dev/null +++ b/src/modules/__test__/velog.api.test.ts @@ -0,0 +1,155 @@ +import axios from 'axios'; +import { fetchVelogApi } from '@/modules/velog/velog.api'; +import { VELOG_API_URL, VELOG_QUERIES } from '@/modules/velog/velog.constans'; + +// axios 모킹 +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +// logger 모킹 (콘솔 출력 방지) +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +describe('Velog API', () => { + const mockAccessToken = 'test-access-token'; + const mockRefreshToken = 'test-refresh-token'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchVelogApi', () => { + it('유효한 토큰으로 사용자 정보를 성공적으로 가져와야 한다', async () => { + // API 응답 모킹 + const mockResponse = { + data: { + data: { + currentUser: { + id: 'user-uuid', + username: 'testuser', + email: 'test@example.com', + profile: { + thumbnail: 'https://example.com/avatar.png' + } + } + } + } + }; + + mockedAxios.post.mockResolvedValueOnce(mockResponse); + + const result = await fetchVelogApi(mockAccessToken, mockRefreshToken); + + // 결과 검증 + expect(result).toEqual({ + id: 'user-uuid', + username: 'testuser', + email: 'test@example.com', + profile: { + thumbnail: 'https://example.com/avatar.png' + } + }); + + // axios 호출 검증 + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith( + VELOG_API_URL, + { VELOG_QUERIES, variables: {} }, + { + headers: { + authority: 'v3.velog.io', + origin: 'https://velog.io', + 'content-type': 'application/json', + cookie: `access_token=${mockAccessToken}; refresh_token=${mockRefreshToken}`, + }, + } + ); + }); + + it('이메일이 없는 사용자 정보도 성공적으로 처리해야 한다', async () => { + // 이메일이 없는 API 응답 모킹 + const mockResponse = { + data: { + data: { + currentUser: { + id: 'user-uuid', + username: 'testuser', + // email 필드 없음 + profile: { + thumbnail: 'https://example.com/avatar.png' + } + } + } + } + }; + + mockedAxios.post.mockResolvedValueOnce(mockResponse); + + const result = await fetchVelogApi(mockAccessToken, mockRefreshToken); + + // 결과 검증 - email이 null로 설정되었는지 확인 + expect(result).toEqual({ + id: 'user-uuid', + username: 'testuser', + email: null, + profile: { + thumbnail: 'https://example.com/avatar.png' + } + }); + }); + + it('API 응답에 오류가 있으면 InvalidTokenError를 던져야 한다', async () => { + // 오류가 포함된 API 응답 모킹 + const mockResponse = { + data: { + errors: [{ message: '인증 실패' }], + data: { currentUser: null } + } + }; + + mockedAxios.post.mockResolvedValueOnce(mockResponse); + + // 함수 호출 시 예외 발생 검증 + await expect(fetchVelogApi(mockAccessToken, mockRefreshToken)) + .rejects.toThrow(expect.objectContaining({ + name: 'InvalidTokenError', + message: 'Velog API 인증에 실패했습니다.' + })); + }); + + it('currentUser가 null이면 InvalidTokenError를 던져야 한다', async () => { + // currentUser가 null인 API 응답 모킹 + const mockResponse = { + data: { + data: { + currentUser: null + } + } + }; + + mockedAxios.post.mockResolvedValueOnce(mockResponse); + + // 함수 호출 시 예외 발생 검증 + await expect(fetchVelogApi(mockAccessToken, mockRefreshToken)) + .rejects.toThrow(expect.objectContaining({ + name: 'InvalidTokenError', + message: 'Velog 사용자 정보를 가져오지 못했습니다.' + })); + + }); + + it('API 호출 자체가 실패하면 InvalidTokenError를 던져야 한다', async () => { + // axios 호출 실패 모킹 + mockedAxios.post.mockRejectedValueOnce(new Error('네트워크 오류')); + + // 함수 호출 시 예외 발생 검증 + await expect(fetchVelogApi(mockAccessToken, mockRefreshToken)) + .rejects.toThrow(expect.objectContaining({ + name: 'InvalidTokenError', + message: 'Velog API 인증에 실패했습니다.' + })); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/velog/velog.api.ts b/src/modules/velog/velog.api.ts index 21bb6f3..ccf1586 100644 --- a/src/modules/velog/velog.api.ts +++ b/src/modules/velog/velog.api.ts @@ -46,6 +46,11 @@ export const fetchVelogApi = async (accessToken: string, refreshToken: string): email: currentUser.email ?? null }; } catch (error) { + // 이미 InvalidTokenError인 경우 그대로 다시 던지기 + if (error instanceof InvalidTokenError) { + throw error; + } + logger.error('Velog API 호출 중 오류 : ', error); throw new InvalidTokenError('Velog API 인증에 실패했습니다.'); } From 60a44469b4826e1df004383bae04f1f3ce452bc1 Mon Sep 17 00:00:00 2001 From: Nuung Date: Mon, 5 May 2025 01:23:51 +0900 Subject: [PATCH 3/6] =?UTF-8?q?modify:=20=EA=B2=80=EC=A6=9D=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/post.controller.ts | 1 - src/middlewares/auth.middleware.ts | 5 +++-- src/modules/__test__/velog.api.test.ts | 3 ++- src/modules/velog/velog.api.ts | 5 +++-- src/types/express.d.ts | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/controllers/post.controller.ts b/src/controllers/post.controller.ts index 8c8a20f..a92de32 100644 --- a/src/controllers/post.controller.ts +++ b/src/controllers/post.controller.ts @@ -90,7 +90,6 @@ export class PostController { ) => { try { const postId = req.params.postId; - const post = await this.postService.getPostByPostUUID(postId); const response = new PostResponseDto(true, 'uuid로 post 조회에 성공하였습니다.', { post }, null); diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 7fb8927..53c2ad1 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from 'express'; import { isUUID } from 'class-validator'; import logger from '@/configs/logger.config'; import pool from '@/configs/db.config'; -import { InvalidTokenError } from '@/exception'; +import { DBError, InvalidTokenError } from '@/exception'; import { VelogJWTPayload, User } from '@/types'; /** @@ -52,9 +52,10 @@ const verifyBearerTokens = () => { } const user = (await pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [payload.user_id])).rows[0] as User; + if (!user) throw new DBError('사용자를 찾을 수 없습니다.'); + req.user = user; req.tokens = { accessToken, refreshToken }; - next(); } catch (error) { logger.error('인증 처리중 오류가 발생하였습니다. : ', error); diff --git a/src/modules/__test__/velog.api.test.ts b/src/modules/__test__/velog.api.test.ts index 92b66a1..e0f810b 100644 --- a/src/modules/__test__/velog.api.test.ts +++ b/src/modules/__test__/velog.api.test.ts @@ -41,6 +41,7 @@ describe('Velog API', () => { mockedAxios.post.mockResolvedValueOnce(mockResponse); const result = await fetchVelogApi(mockAccessToken, mockRefreshToken); + const query = VELOG_QUERIES.LOGIN; // 결과 검증 expect(result).toEqual({ @@ -56,7 +57,7 @@ describe('Velog API', () => { expect(mockedAxios.post).toHaveBeenCalledTimes(1); expect(mockedAxios.post).toHaveBeenCalledWith( VELOG_API_URL, - { VELOG_QUERIES, variables: {} }, + { query, variables: {} }, { headers: { authority: 'v3.velog.io', diff --git a/src/modules/velog/velog.api.ts b/src/modules/velog/velog.api.ts index ccf1586..76831df 100644 --- a/src/modules/velog/velog.api.ts +++ b/src/modules/velog/velog.api.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import logger from '@/configs/logger.config'; import { InvalidTokenError } from '@/exception'; import { VELOG_API_URL, VELOG_QUERIES } from '@/modules/velog/velog.constans'; -import { VelogUserCurrentResponse } from './velog.type'; +import { VelogUserCurrentResponse } from '@/types'; /** * Velog API를 통해 사용자 정보를 조회합니다. @@ -14,9 +14,10 @@ import { VelogUserCurrentResponse } from './velog.type'; */ export const fetchVelogApi = async (accessToken: string, refreshToken: string): Promise => { try { + const query = VELOG_QUERIES.LOGIN; const response = await axios.post( VELOG_API_URL, - { VELOG_QUERIES, variables: {} }, + { query, variables: {} }, { headers: { authority: 'v3.velog.io', diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 53e83d3..8ac4345 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,4 +1,4 @@ -import { User } from '@/velog.type'; +import { User } from '@/types'; declare global { namespace Express { From 4a37b629f7bcfc78e3222ad16ce0d16dc938786d Mon Sep 17 00:00:00 2001 From: Nuung Date: Mon, 5 May 2025 01:40:30 +0900 Subject: [PATCH 4/6] =?UTF-8?q?modify:=20user=20me=20api=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20username=20=EB=95=8C=EB=AC=B8=EC=97=90=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20API=20=ED=98=B8=EC=B6=9C=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/user.controller.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index dfbb291..71eb3e2 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -99,13 +99,15 @@ export class UserController { res.status(200).json(response); }; - fetchCurrentUser: RequestHandler = (req: Request, res: Response) => { - const { user } = req; + fetchCurrentUser: RequestHandler = async (req: Request, res: Response) => { + // 외부 API (velog) 호출로 username 을 가져와야 함, 게시글 바로가기 때문에 (username) + const { accessToken, refreshToken } = req.tokens; + const velogUser = await fetchVelogApi(accessToken, refreshToken); const response = new LoginResponseDto( true, '유저 정보 조회에 성공하였습니다.', - { id: user.id, username: user.username, profile: user.profile }, + { id: req.user.id, username: velogUser.username, profile: velogUser.profile }, null, ); From e6a1881e9c7247b89cbbbe486c0d1eddcb421b26 Mon Sep 17 00:00:00 2001 From: Nuung Date: Mon, 5 May 2025 01:56:29 +0900 Subject: [PATCH 5/6] =?UTF-8?q?modify:=20SLACK=5FWEBHOOK=5FURL=20=EC=9D=B4?= =?UTF-8?q?=20=EC=99=9C=20=EB=B9=A0=EC=A1=8C=EC=A7=80=3F=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-ci.yaml | 1 + src/repositories/user.repository.ts | 2 -- src/services/user.service.ts | 6 +++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-ci.yaml b/.github/workflows/test-ci.yaml index df5e24b..e89e0f6 100644 --- a/.github/workflows/test-ci.yaml +++ b/.github/workflows/test-ci.yaml @@ -52,6 +52,7 @@ jobs: - name: Create .env file run: | + echo "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" >> .env echo "DATABASE_NAME=${{ secrets.DATABASE_NAME }}" >> .env echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 2615f46..c3b3ebe 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -10,7 +10,6 @@ export class UserRepository { async findByUserId(id: number): Promise { try { const user = await this.pool.query('SELECT * FROM "users_user" WHERE id = $1', [id]); - return user.rows[0] || null; } catch (error) { logger.error('Id로 유저를 조회 중 오류 : ', error); @@ -21,7 +20,6 @@ export class UserRepository { async findByUserVelogUUID(uuid: string): Promise { try { const user = await this.pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [uuid]); - return user.rows[0] || null; } catch (error) { logger.error('Velog UUID로 유저를 조회 중 오류 : ', error); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 723da4e..dd208d2 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -139,7 +139,11 @@ export class UserService { } // 2. 찾은 토큰을 기반으로 사용자를 다시 찾는다. (토큰 획득 및 사용자의 QR토큰 모두 비활성화) - const qrTokenUser = await this.userRepo.findByUserId(qrToken.user_id) + const qrTokenUser = await this.userRepo.findByUserId(qrToken.user_id); + if (!qrTokenUser) { + return null; + } + await this.userRepo.updateQRLoginTokenToUse(qrToken.user_id); const { decryptedAccessToken, decryptedRefreshToken } = this.decryptTokens( qrTokenUser.group_id, From 1052e20298c6b0faa8817c605f188ce22055c7ab Mon Sep 17 00:00:00 2001 From: Nuung Date: Wed, 7 May 2025 15:50:00 +0900 Subject: [PATCH 6/6] =?UTF-8?q?modify:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81,=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20(=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1,=20=EA=B0=80=EC=8B=9C=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/auth.middleware.test.ts | 43 ++++--------------- src/repositories/__test__/qr.repo.test.ts | 8 ++-- 2 files changed, 13 insertions(+), 38 deletions(-) diff --git a/src/middlewares/__test__/auth.middleware.test.ts b/src/middlewares/__test__/auth.middleware.test.ts index 9f48636..98f7556 100644 --- a/src/middlewares/__test__/auth.middleware.test.ts +++ b/src/middlewares/__test__/auth.middleware.test.ts @@ -147,7 +147,7 @@ describe('인증 미들웨어', () => { ); }); - it('사용자를 찾을 수 없으면 next를 호출해야 한다', async () => { + it('사용자를 찾을 수 없으면 DBError가 발생해야 한다', async () => { // 유효한 토큰 준비 mockRequest.cookies = { 'access_token': validToken, @@ -169,9 +169,15 @@ describe('인증 미들웨어', () => { // 검증 expect(nextFunction).toHaveBeenCalledTimes(1); expect(mockRequest.user).toBeUndefined(); + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'DBError', + message: '사용자를 찾을 수 없습니다.' + }) + ); }); - it('쿠키 대신 요청 본문에서 토큰을 추출해야 한다', async () => { + it('쿠키에 토큰이 없으면 헤더에서 토큰을 가져와야 한다', async () => { // 요청 본문에 토큰 설정 mockRequest.body = { accessToken: validToken, @@ -203,38 +209,5 @@ describe('인증 미들웨어', () => { expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error)); expect(mockRequest.user).toEqual(mockUser); }); - - it('쿠키와 요청 본문 대신 헤더에서 토큰을 추출해야 한다', async () => { - // 헤더에 토큰 설정 - mockRequest.headers = { - 'access_token': validToken, - 'refresh_token': 'refresh-token' - }; - - // 사용자 정보 mock - const mockUser = { - id: 1, - username: 'testuser', - email: 'test@example.com', - velog_uuid: 'c7507240-093b-11ea-9aae-a58a86bb0520' - }; - - // DB 쿼리 결과 모킹 - (pool.query as jest.Mock).mockResolvedValueOnce({ - rows: [mockUser] - }); - - // 미들웨어 실행 - await authMiddleware.verify( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - // 검증 - expect(nextFunction).toHaveBeenCalledTimes(1); - expect(nextFunction).not.toHaveBeenCalledWith(expect.any(Error)); - expect(mockRequest.user).toEqual(mockUser); - }); }); }); \ No newline at end of file diff --git a/src/repositories/__test__/qr.repo.test.ts b/src/repositories/__test__/qr.repo.test.ts index 6cd703c..dedb5c9 100644 --- a/src/repositories/__test__/qr.repo.test.ts +++ b/src/repositories/__test__/qr.repo.test.ts @@ -72,6 +72,7 @@ describe('UserRepository - QR Login Token', () => { }); it('토큰이 존재하지 않으면 null을 반환해야 한다', async () => { + // 참고로 존재하지 않거나, 만료된 토큰 밖에 없거나, 이미 사용된 토큰은 모두 "null 이 되는 것임" (mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] }); const result = await repo.findQRLoginToken('token'); @@ -102,14 +103,15 @@ describe('UserRepository - QR Login Token', () => { describe('updateQRLoginTokenToUse', () => { it('유저 ID로 토큰을 사용 처리해야 한다', async () => { + const targetUserId = 1; (mockPool.query as jest.Mock).mockResolvedValueOnce(undefined); - await expect(repo.updateQRLoginTokenToUse(1)).resolves.not.toThrow(); + await expect(repo.updateQRLoginTokenToUse(targetUserId)).resolves.not.toThrow(); - expect(mockPool.query).toHaveBeenCalledTimes(1); + expect(mockPool.query).toHaveBeenCalledTimes(targetUserId); expect(mockPool.query).toHaveBeenCalledWith( expect.stringContaining('UPDATE users_qrlogintoken'), - [1] + [targetUserId] ); });