Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6d3b0ec
feat: qrcode app ์ปจํŠธ๋กค๋Ÿฌ, ์„œ๋น„์Šค, ๋ ˆํฌ์ง€ํ† ๋ฆฌ, ํƒ€์ž… ์ถ”๊ฐ€, ๊ด€๋ จ API ๊ตฌํ˜„
Jihyun3478 Apr 20, 2025
3b6adaa
hotfix: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ ๋ฐ˜์˜
Jihyun3478 Apr 20, 2025
b70a582
modify: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ํด๋ž˜์Šค ์‚ญ์ œ
Jihyun3478 Apr 21, 2025
56c2283
refactor: ๋ถˆํ•„์š”ํ•œ ์˜์กด์„ฑ ์‚ญ์ œ
Jihyun3478 Apr 25, 2025
ac39619
refactor: ๊ณต๋ฐฑ ์ œ๊ฑฐ
Jihyun3478 Apr 25, 2025
d118b16
refactor: ํ™œ์šฉ๋„ ๋‚ฎ์€ ํ•„๋“œ ์ˆœ์„œ ์ •๋ฆฌ
Jihyun3478 Apr 25, 2025
f6f3936
modify: token ๊ณ ์ • ๊ธธ์ด ๊ฐ’ ๋ช…์‹œ & exception ์žฌ์‚ฌ์šฉ
Jihyun3478 Apr 25, 2025
065879c
refactor: repository ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ๋ฉ”์„œ๋“œ ๋ถ„๋ฆฌ
Jihyun3478 Apr 25, 2025
3faf173
hotfix: uuid ์‚ญ์ œ
Jihyun3478 Apr 25, 2025
a007870
hotfix: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ๋ฆ„์— ๋งž๊ฒŒ ์ˆ˜์ •
Jihyun3478 Apr 25, 2025
a95635e
refactor: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ ๋ฐ˜์˜
Jihyun3478 Apr 25, 2025
245a721
hotfix: token 36์ž๊ฐ€ ์•„๋‹Œ 10์ž๋กœ ์ˆ˜์ •
Jihyun3478 Apr 29, 2025
4fd26ac
hotfix: ์ฟผ๋ฆฌ ์ž‘์„ฑ ์‹œ qr_login_tokens๊ฐ€ ์•„๋‹Œ users_qrlogintoken์œผ๋กœ ์ˆ˜์ •
Jihyun3478 Apr 29, 2025
c7e9019
test: ๋ ˆํฌ์ง€ํ† ๋ฆฌ ํ†ตํ•ฉํ…Œ์ŠคํŠธ์ฝ”๋“œ ๊ตฌํ˜„
Jihyun3478 Apr 29, 2025
51a0162
Merge branch 'main' into feature/qrcode-app
Jihyun3478 Apr 29, 2025
5932dac
hotfix: ์ค‘๋ณต ํ† ํฐ ํ…Œ์ŠคํŠธ ํ›„ DB ์—ฐ๊ฒฐ ์ข…๋ฃŒ
Jihyun3478 Apr 29, 2025
85b502c
hotfix: ์ค‘๋ณต ํ† ํฐ ์‚ฝ์ž… ํ…Œ์ŠคํŠธ ์‚ญ์ œ
Jihyun3478 Apr 29, 2025
37b9633
modify: QRLoginToken ๋ผ์šฐํ„ฐ, ์„œ๋น„์Šค, ๋ ˆํฌ User์ชฝ์œผ๋กœ ํ•ฉ์น˜๊ธฐ
Jihyun3478 Apr 29, 2025
3440fa2
modify: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ 1์ฐจ ๋ฐ˜์˜
Jihyun3478 Apr 29, 2025
d2d127a
hotfix: process.env ๋Œ€์‹  ์ž„์˜์˜ ๋‚œ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ์ˆ˜์ •
Jihyun3478 Apr 30, 2025
fe68599
refactor: lint ์ ์šฉ
Jihyun3478 Apr 30, 2025
f8801e0
refactor: ๋“ค์—ฌ์“ฐ๊ธฐ ์ •๋ฆฌ
Jihyun3478 Apr 30, 2025
5613dbb
hotfix: ์Šฌ๋ž™๋„ mockingํ•˜๋„๋ก ์ˆ˜์ •
Jihyun3478 Apr 30, 2025
31b1a43
hotfix: ํŠน์ • ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ํ…Œ์ŠคํŠธ๊ฐ€ ์™„๋ฃŒ๋œ ํ›„ ์ง€์šฐ๋„๋ก ์ˆ˜์ •
Jihyun3478 May 1, 2025
6d6f918
docs: Swagger ์ฃผ์„ ์ˆ˜์ • ๋ฐ ์ถ”๊ฐ€
Jihyun3478 May 1, 2025
3ce8994
hotfix: ํ† ํฐ ์ƒ์„ฑ ๋กœ์ง ์ˆ˜์ •
Jihyun3478 May 1, 2025
5c8f0a9
hotfix: ์‹ค์ œ ํด๋ผ์ด์–ธํŠธ์˜ IP์— ์ ‘๊ทผํ•˜๋„๋ก ์ˆ˜์ • & logger ๊ตฌ์ฒดํ™”
Jihyun3478 May 1, 2025
0769497
refactor: ์ฝ”๋“œ๋ž˜๋น— ๋ฆฌ๋ทฐ ๋ฐ˜์˜
Jihyun3478 May 1, 2025
624be89
docs: swagger ๋ฌธ์„œ ์ˆ˜์ •
Jihyun3478 May 2, 2025
c20e48e
modify: service์˜ getByToken์ด ์•„๋‹Œ repo์˜ findQRLoginToken์„ ์‚ฌ์šฉํ•˜๋„๋ก ์ˆ˜์ •
Jihyun3478 May 2, 2025
b08d834
refactor: findByVelogUUID & getDecryptedTokens ๋ฉ”์„œ๋“œ ๋ณ‘ํ•ฉ
Jihyun3478 May 3, 2025
21764ee
refactor: ๋ ˆํฌ ๊ณ„์ธต๊ณผ์˜ ์ค‘๋ณต ํ…Œ์ŠคํŠธ์ฝ”๋“œ ์ œ๊ฑฐ
Jihyun3478 May 3, 2025
c2dd34a
refactor: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import & ์ฝ”๋“œ ์ œ๊ฑฐ
Jihyun3478 May 3, 2025
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { NotFoundError } from './exception';
dotenv.config();

const app: Application = express();
// ์‹ค์ œ ํด๋ผ์ด์–ธํŠธ IP๋ฅผ ์•Œ๊ธฐ ์œ„ํ•œ trust proxy ์„ค์ •
app.set('trust proxy', true);
const swaggerSpec = swaggerJSDoc(options);

app.use(cookieParser());
Expand Down
66 changes: 66 additions & 0 deletions src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { NextFunction, Request, Response, RequestHandler, CookieOptions } from 'express';
import logger from '@/configs/logger.config';
import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types';
import { QRLoginTokenResponseDto } from '@/types/dto/responses/qrResponse.type';
import { UserService } from '@/services/user.service';
import { InvalidTokenError, TokenExpiredError } from '@/exception/token.exception';
import { NotFoundError } from '@/exception';

type Token10 = string & { __lengthBrand: 10 };

export class UserController {
constructor(private userService: UserService) { }

Expand Down Expand Up @@ -102,4 +108,64 @@ export class UserController {

res.status(200).json(response);
};

createToken: RequestHandler = async (
req: Request,
res: Response<QRLoginTokenResponseDto>,
next: NextFunction,
) => {
try {
const user = req.user;
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 typedToken = token as Token10;

const response = new QRLoginTokenResponseDto(
true,
'QR ํ† ํฐ ์ƒ์„ฑ ์™„๋ฃŒ',
{ token: typedToken },
null
);
res.status(200).json(response);
} catch (error) {
logger.error(`QR ํ† ํฐ ์ƒ์„ฑ ์‹คํŒจ: [userId: ${req.user?.id || 'anonymous'}]`, error);
next(error);
}
};

getToken: RequestHandler = async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.query.token as string;
if (!token) {
throw new InvalidTokenError('ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.');
}

const found = await this.userService.useToken(token);
if (!found) {
throw new TokenExpiredError();
}

const user = await this.userService.findByVelogUUID(found.user.toString());
if (!user) throw new NotFoundError('์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');

const { decryptedAccessToken, decryptedRefreshToken } = this.userService.getDecryptedTokens(
user.group_id,
user.access_token,
user.refresh_token
);

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.redirect('/main');
} catch (error) {
logger.error(`QR ํ† ํฐ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์‹คํŒจ: [userId: ${req.user?.id || 'anonymous'}]`, error);
next(error);
}
};
}
147 changes: 147 additions & 0 deletions src/repositories/__test__/qr.repo.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import dotenv from 'dotenv';
import { Pool } from 'pg';
import pg from 'pg';
import { UserRepository } from '@/repositories/user.repository';
import { generateRandomToken } from '@/utils/generateRandomToken.util';
import logger from '@/configs/logger.config';

dotenv.config();
jest.setTimeout(5000);

describe('QRLoginTokenRepository ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ', () => {
let testPool: Pool;
let repo: UserRepository;

const TEST_DATA = {
USER_ID: 1,
};

beforeAll(async () => {
const testPoolConfig: pg.PoolConfig = {
database: process.env.DATABASE_NAME,
user: process.env.POSTGRES_USER,
host: process.env.POSTGRES_HOST,
password: process.env.POSTGRES_PASSWORD,
port: Number(process.env.POSTGRES_PORT),
max: 1,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
allowExitOnIdle: false,
statement_timeout: 30000,
};

if (process.env.POSTGRES_HOST !== 'localhost') {
testPoolConfig.ssl = { rejectUnauthorized: false };
}

testPool = new Pool(testPoolConfig);

await testPool.query('SELECT 1');
logger.info('ํ…Œ์ŠคํŠธ DB ์—ฐ๊ฒฐ ์„ฑ๊ณต');

repo = new UserRepository(testPool);
});

afterAll(async () => {
try {
await testPool.query(
`
DELETE FROM users_qrlogintoken
WHERE ip_address = '127.0.0.1'
AND user_agent = 'test-agent'
AND user_id = $1
`,
[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) {
logger.error('ํ…Œ์ŠคํŠธ ์ข…๋ฃŒ ์ค‘ ์˜ค๋ฅ˜:', error);
}
});

describe('QR ํ† ํฐ ์ƒ์„ฑ ๋ฐ ์กฐํšŒ', () => {
it('QR ํ† ํฐ์„ ์ƒ์„ฑํ•˜๊ณ  ์ •์ƒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค', async () => {
const token = generateRandomToken();
const ip = '127.0.0.1';
const userAgent = 'test-agent';

await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent);
const foundToken = await repo.findQRLoginToken(token);

expect(foundToken).not.toBeNull();
expect(foundToken?.token).toBe(token);
expect(foundToken?.is_used).toBe(false);
if (foundToken) {
expect(new Date(foundToken.expires_at).getTime()).toBeGreaterThan(new Date(foundToken.created_at).getTime());
}
});

it('์กด์žฌํ•˜์ง€ ์•Š๋Š” ํ† ํฐ ์กฐํšŒ ์‹œ null์„ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค', async () => {
const invalidToken = generateRandomToken();
const result = await repo.findQRLoginToken(invalidToken);

expect(result).toBeNull();
});
});

describe('QR ํ† ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ', () => {
it('QR ํ† ํฐ์„ ์‚ฌ์šฉ ์ฒ˜๋ฆฌํ•œ ํ›„ ์กฐํšŒ๋˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค', async () => {
const token = generateRandomToken();
const ip = '127.0.0.1';
const userAgent = 'test-agent';

await repo.createQRLoginToken(token, TEST_DATA.USER_ID, ip, userAgent);
await repo.markTokenUsed(token);

const found = await repo.findQRLoginToken(token);

expect(found).toBeNull();
});
});

describe('QR ํ† ํฐ ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ', () => {
it('๋งŒ๋ฃŒ๋œ ํ† ํฐ์€ ์กฐํšŒ๋˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค', async () => {
const token = generateRandomToken();
const ip = '127.0.0.1';
const userAgent = 'test-agent';

await testPool.query(
`
INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent)
VALUES ($1, $2, NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '5 minutes', false, $3, $4)
`,
[token, TEST_DATA.USER_ID, ip, userAgent]
);

const found = await repo.findQRLoginToken(token);

expect(found).toBeNull();
});

it('๋งŒ๋ฃŒ๋˜๊ณ  ์‚ฌ์šฉ๋œ ํ† ํฐ๋„ ์กฐํšŒ๋˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค', async () => {
const token = generateRandomToken();
const ip = '127.0.0.1';
const userAgent = 'test-agent';

await testPool.query(
`
INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent)
VALUES ($1, $2, NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '5 minutes', true, $3, $4)
`,
[token, TEST_DATA.USER_ID, ip, userAgent]
);

const found = await repo.findQRLoginToken(token);

expect(found).toBeNull();
});
});
});
80 changes: 80 additions & 0 deletions src/repositories/__test__/qr.repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { UserRepository } from '@/repositories/user.repository';
import { DBError } from '@/exception';
import { Pool } from 'pg';

const mockPool: Partial<Pool> = {
query: jest.fn(),
};

describe('QRLoginTokenRepository', () => {
let repo: UserRepository;

beforeEach(() => {
repo = new UserRepository(mockPool as Pool);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('createQRLoginToken', () => {
it('QR ํ† ํฐ์„ ์„ฑ๊ณต์ ์œผ๋กœ ์‚ฝ์ž…ํ•ด์•ผ ํ•œ๋‹ค', async () => {
(mockPool.query as jest.Mock).mockResolvedValueOnce(undefined);

await expect(
repo.createQRLoginToken('token', 1, 'ip', 'agent')
).resolves.not.toThrow();

expect(mockPool.query).toHaveBeenCalled();
});

it('์‚ฝ์ž… ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ DBError๋ฅผ ๋˜์ ธ์•ผ ํ•œ๋‹ค', async () => {
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));

await expect(
repo.createQRLoginToken('token', 1, 'ip', 'agent')
).rejects.toThrow(DBError);
});
});

describe('findQRLoginToken', () => {
it('ํ† ํฐ์ด ์กด์žฌํ•  ๊ฒฝ์šฐ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค', async () => {
const mockTokenData = { token: 'token', user: 1 };
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [mockTokenData] });

const result = await repo.findQRLoginToken('token');
expect(result).toEqual(mockTokenData);
});

it('ํ† ํฐ์ด ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด null์„ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค', async () => {
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });

const result = await repo.findQRLoginToken('token');
expect(result).toBeNull();
});

it('์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ DBError๋ฅผ ๋˜์ ธ์•ผ ํ•œ๋‹ค', async () => {
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));

await expect(repo.findQRLoginToken('token')).rejects.toThrow(DBError);
});
});

describe('markTokenUsed', () => {
it('ํ† ํฐ์„ ์‚ฌ์šฉ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•œ๋‹ค', async () => {
(mockPool.query as jest.Mock).mockResolvedValueOnce(undefined);

await expect(repo.markTokenUsed('token')).resolves.not.toThrow();
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE users_qrlogintoken SET is_used = true'),
['token']
);
});

it('ํ† ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ DBError๋ฅผ ๋˜์ ธ์•ผ ํ•œ๋‹ค', async () => {
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));

await expect(repo.markTokenUsed('token')).rejects.toThrow(DBError);
});
});
});
43 changes: 42 additions & 1 deletion src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Pool } from 'pg';
import logger from '@/configs/logger.config';
import { User } from '@/types';
import { QRLoginToken } from "@/types/models/QRLoginToken.type";
import { DBError } from '@/exception';

export class UserRepository {
constructor(private readonly pool: Pool) { }
constructor(private readonly pool: Pool) {}

async findByUserVelogUUID(uuid: string): Promise<User> {
try {
Expand Down Expand Up @@ -91,4 +92,44 @@ export class UserRepository {
throw new DBError('์œ ์ € ์ƒ์„ฑ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.');
}
}

async createQRLoginToken(token: string, userId: number, ip: string, userAgent: string): Promise<void> {
try {
const query = `
INSERT INTO users_qrlogintoken (token, user_id, created_at, expires_at, is_used, ip_address, user_agent)
VALUES ($1, $2, NOW(), NOW() + INTERVAL '5 minutes', false, $3, $4);
`;
await this.pool.query(query, [token, userId, ip, userAgent]);
} catch (error) {
logger.error('QRLoginToken Repo Create Error : ', error);
throw new DBError('QR ์ฝ”๋“œ ํ† ํฐ ์ƒ์„ฑ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.');
}
}

async findQRLoginToken(token: string): Promise<QRLoginToken | null> {
try {
const query = `
SELECT *
FROM users_qrlogintoken
WHERE token = $1 AND is_used = false AND expires_at > NOW();
`;
const result = await this.pool.query(query, [token]);
return result.rows[0] ?? null;
} catch (error) {
logger.error('QRLoginToken Repo find QR Code Error : ', error);
throw new DBError('QR ์ฝ”๋“œ ํ† ํฐ ์กฐํšŒ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.');
}
}

async markTokenUsed(token: string): Promise<void> {
try {
const query = `
UPDATE users_qrlogintoken SET is_used = true WHERE token = $1;
`;
await this.pool.query(query, [token]);
} catch (error) {
logger.error('QRLoginToken Repo mark as used Error : ', error);
throw new DBError('QR ์ฝ”๋“œ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.');
}
}
}
1 change: 1 addition & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ router.use('/', UserRouter);
router.use('/', PostRouter);
router.use('/', NotiRouter);
router.use('/', LeaderboardRouter);

export default router;
Loading