From 824e5f4827eb54504178b77158cfdee5d074f642 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 30 Sep 2025 15:59:32 +0900 Subject: [PATCH] feat: Complete Phase 1.5 - AuthService Raw Query migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.5 완료: 인증 서비스 Raw Query 전환 및 테스트 완료 ✅ AuthService 전환 완료 (5개 Prisma 호출 제거): - loginPwdCheck(): Raw Query로 사용자 비밀번호 조회 - insertLoginAccessLog(): Raw Query로 로그인 로그 기록 - getUserInfo(): Raw Query로 사용자/권한/회사 정보 조회 - authority_sub_user ↔ authority_master JOIN (master_objid ↔ objid) - 3개 쿼리로 분리 (사용자, 권한, 회사) - processLogin(): 전체 로그인 플로우 통합 - processLogout(): 로그아웃 로그 기록 🧪 테스트 완료: - 단위 테스트: 30개 테스트 모두 통과 ✅ - 로그인 검증 (6개) - 사용자 정보 조회 (5개) - 로그인 로그 기록 (4개) - 전체 로그인 프로세스 (5개) - 로그아웃 (2개) - 토큰 검증 (3개) - Raw Query 전환 검증 (3개) - 성능 테스트 (2개) - 통합 테스트: 작성 완료 (auth.integration.test.ts) - 로그인 → 토큰 발급 → 인증 → 로그아웃 플로우 🔧 주요 변경사항: - Prisma import 제거 → Raw Query (query from db.ts) - authority 테이블 JOIN 수정 (auth_code → master_objid/objid) - 파라미터 바인딩으로 SQL Injection 방지 - 타입 안전성 유지 (TypeScript Generic 사용) 📊 성능: - 로그인 프로세스: < 1초 - 사용자 정보 조회: < 500ms - 모든 테스트 실행 시간: 2.016초 🎯 다음 단계: - Phase 2: 핵심 서비스 전환 (ScreenManagement, TableManagement 등) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md | 28 +- backend-node/package-lock.json | 4 +- backend-node/package.json | 4 +- backend-node/src/services/authService.ts | 162 +++---- backend-node/src/tests/authService.test.ts | 426 ++++++++++++++++++ .../integration/auth.integration.test.ts | 382 ++++++++++++++++ 6 files changed, 902 insertions(+), 104 deletions(-) create mode 100644 backend-node/src/tests/authService.test.ts create mode 100644 backend-node/src/tests/integration/auth.integration.test.ts diff --git a/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md b/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md index 4c2081c8..d04bdf51 100644 --- a/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md +++ b/PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md @@ -1040,26 +1040,28 @@ describe("Performance Benchmarks", () => { - [x] 단위 테스트 작성 (`backend-node/src/tests/`) - [x] 테스트 성공 확인 (multiConnectionQueryService, externalCallConfigService) -### **Phase 1.5: 인증 및 관리자 서비스 (우선 전환) - 36개 호출** ⚡ **NEW** +### **Phase 1.5: 인증 및 관리자 서비스 (우선 전환) - 36개 호출** ✅ **완료** > **우선순위 변경**: Phase 2 진행 전 인증/관리 시스템을 먼저 전환하여 전체 시스템의 안정적인 기반 구축 -- [ ] **AuthService 전환 (5개)** - 🔐 최우선 - - [ ] 로그인 로직 (JWT 생성) - - [ ] 사용자 인증 및 검증 - - [ ] 비밀번호 암호화 처리 - - [ ] 토큰 관리 - - [ ] 세션 관리 -- [ ] **AdminService 전환 (3개)** - 👤 사용자 관리 - - [ ] 사용자 CRUD - - [ ] 메뉴 관리 (재귀 쿼리) - - [ ] 권한 관리 -- [ ] **AdminController 전환 (28개)** - 📡 관리자 API +- [x] **AuthService 전환 (5개)** - 🔐 최우선 ✅ **완료** + - [x] 로그인 로직 (JWT 생성) - `loginPwdCheck()` Raw Query 전환 + - [x] 사용자 인증 및 검증 - `getUserInfo()` Raw Query 전환 + - [x] 비밀번호 암호화 처리 - EncryptUtil 유지 + - [x] 토큰 관리 - `getUserInfoFromToken()` 정상 동작 + - [x] 로그인 로그 기록 - `insertLoginAccessLog()` Raw Query 전환 +- [ ] **AdminService 확인 (3개)** - 👤 사용자 관리 (이미 Raw Query 사용) + - [x] 사용자 CRUD - Raw Query 사용 확인 + - [x] 메뉴 관리 (재귀 쿼리) - WITH RECURSIVE 사용 확인 + - [x] 권한 관리 - Raw Query 사용 확인 +- [ ] **AdminController 전환 (28개)** - 📡 관리자 API (Phase 2에서 처리) - [ ] 사용자 관리 API - [ ] 메뉴 관리 API - [ ] 권한 관리 API - [ ] 회사 관리 API -- [ ] 통합 테스트 (로그인 → 인증 → API 호출 플로우) +- [x] **테스트** ✅ **완료** + - [x] 단위 테스트 (30개 테스트 모두 통과) + - [x] 통합 테스트 작성 완료 ### **Phase 2: 핵심 서비스 (3주) - 107개 호출** diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index a4bf97ba..2619199d 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -47,7 +47,7 @@ "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", - "@types/supertest": "^6.0.2", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", @@ -55,7 +55,7 @@ "nodemon": "^3.1.10", "prettier": "^3.1.0", "prisma": "^6.16.2", - "supertest": "^6.3.3", + "supertest": "^6.3.4", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "typescript": "^5.3.3" diff --git a/backend-node/package.json b/backend-node/package.json index 2caf0d1c..9d892e3f 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -65,7 +65,7 @@ "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", - "@types/supertest": "^6.0.2", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", @@ -73,7 +73,7 @@ "nodemon": "^3.1.10", "prettier": "^3.1.0", "prisma": "^6.16.2", - "supertest": "^6.3.3", + "supertest": "^6.3.4", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "typescript": "^5.3.3" diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 357a90c7..fee93775 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -1,7 +1,8 @@ // 인증 서비스 // 기존 Java LoginService를 Node.js로 포팅 +// ✅ Prisma → Raw Query 전환 완료 (Phase 1.5) -import prisma from "../config/database"; +import { query } from "../database/db"; import { JwtUtils } from "../utils/jwtUtils"; import { EncryptUtil } from "../utils/encryptUtil"; import { PersonBean, LoginResult, LoginLogData } from "../types/auth"; @@ -17,15 +18,13 @@ export class AuthService { password: string ): Promise { try { - // 사용자 비밀번호 조회 (기존 login.getUserPassword 쿼리 포팅) - const userInfo = await prisma.user_info.findUnique({ - where: { - user_id: userId, - }, - select: { - user_password: true, - }, - }); + // 사용자 비밀번호 조회 (Raw Query 전환) + const result = await query<{ user_password: string }>( + "SELECT user_password FROM user_info WHERE user_id = $1", + [userId] + ); + + const userInfo = result.length > 0 ? result[0] : null; if (userInfo && userInfo.user_password) { const dbPassword = userInfo.user_password; @@ -78,32 +77,26 @@ export class AuthService { */ static async insertLoginAccessLog(logData: LoginLogData): Promise { try { - // 기존 login.insertLoginAccessLog 쿼리 포팅 - await prisma.$executeRaw` - INSERT INTO LOGIN_ACCESS_LOG( - LOG_TIME, - SYSTEM_NAME, - USER_ID, - LOGIN_RESULT, - ERROR_MESSAGE, - REMOTE_ADDR, - RECPTN_DT, - RECPTN_RSLT_DTL, - RECPTN_RSLT, - RECPTN_RSLT_CD + // 로그인 로그 기록 (Raw Query 전환) + await query( + `INSERT INTO LOGIN_ACCESS_LOG( + LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE, + REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD ) VALUES ( - now(), - ${logData.systemName}, - UPPER(${logData.userId}), - ${logData.loginResult}, - ${logData.errorMessage || null}, - ${logData.remoteAddr}, - ${logData.recptnDt || null}, - ${logData.recptnRsltDtl || null}, - ${logData.recptnRslt || null}, - ${logData.recptnRsltCd || null} - ) - `; + now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9 + )`, + [ + logData.systemName, + logData.userId, + logData.loginResult, + logData.errorMessage || null, + logData.remoteAddr, + logData.recptnDt || null, + logData.recptnRsltDtl || null, + logData.recptnRslt || null, + logData.recptnRsltCd || null, + ] + ); logger.info( `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` @@ -122,66 +115,61 @@ export class AuthService { */ static async getUserInfo(userId: string): Promise { try { - // 기존 login.getUserInfo 쿼리 포팅 - const userInfo = await prisma.user_info.findUnique({ - where: { - user_id: userId, - }, - select: { - sabun: true, - user_id: true, - user_name: true, - user_name_eng: true, - user_name_cn: true, - dept_code: true, - dept_name: true, - position_code: true, - position_name: true, - email: true, - tel: true, - cell_phone: true, - user_type: true, - user_type_name: true, - partner_objid: true, - company_code: true, - locale: true, - photo: true, - }, - }); + // 1. 사용자 기본 정보 조회 (Raw Query 전환) + const userResult = await query<{ + sabun: string | null; + user_id: string; + user_name: string; + user_name_eng: string | null; + user_name_cn: string | null; + dept_code: string | null; + dept_name: string | null; + position_code: string | null; + position_name: string | null; + email: string | null; + tel: string | null; + cell_phone: string | null; + user_type: string | null; + user_type_name: string | null; + partner_objid: string | null; + company_code: string | null; + locale: string | null; + photo: Buffer | null; + }>( + `SELECT + sabun, user_id, user_name, user_name_eng, user_name_cn, + dept_code, dept_name, position_code, position_name, + email, tel, cell_phone, user_type, user_type_name, + partner_objid, company_code, locale, photo + FROM user_info + WHERE user_id = $1`, + [userId] + ); + + const userInfo = userResult.length > 0 ? userResult[0] : null; if (!userInfo) { return null; } - // 권한 정보 조회 (Prisma ORM 사용) - const authInfo = await prisma.authority_sub_user.findMany({ - where: { - user_id: userId, - }, - include: { - authority_master: { - select: { - auth_name: true, - }, - }, - }, - }); + // 2. 권한 정보 조회 (Raw Query 전환 - JOIN으로 최적화) + const authResult = await query<{ auth_name: string }>( + `SELECT am.auth_name + FROM authority_sub_user asu + INNER JOIN authority_master am ON asu.master_objid = am.objid + WHERE asu.user_id = $1`, + [userId] + ); // 권한명들을 쉼표로 연결 - const authNames = authInfo - .filter((auth: any) => auth.authority_master?.auth_name) - .map((auth: any) => auth.authority_master!.auth_name!) - .join(","); + const authNames = authResult.map((row) => row.auth_name).join(","); - // 회사 정보 조회 (Prisma ORM 사용으로 변경) - const companyInfo = await prisma.company_mng.findFirst({ - where: { - company_code: userInfo.company_code || "ILSHIN", - }, - select: { - company_name: true, - }, - }); + // 3. 회사 정보 조회 (Raw Query 전환) + // Note: 현재 회사 정보는 PersonBean에 직접 사용되지 않지만 향후 확장을 위해 유지 + const companyResult = await query<{ company_name: string }>( + "SELECT company_name FROM company_mng WHERE company_code = $1", + [userInfo.company_code || "ILSHIN"] + ); // DB에서 조회한 원본 사용자 정보 상세 로그 //console.log("🔍 AuthService - DB 원본 사용자 정보:", { diff --git a/backend-node/src/tests/authService.test.ts b/backend-node/src/tests/authService.test.ts new file mode 100644 index 00000000..dee0e730 --- /dev/null +++ b/backend-node/src/tests/authService.test.ts @@ -0,0 +1,426 @@ +/** + * AuthService Raw Query 전환 단위 테스트 + * Phase 1.5: 인증 서비스 테스트 + */ + +import { AuthService } from "../services/authService"; +import { query } from "../database/db"; +import { EncryptUtil } from "../utils/encryptUtil"; + +// 테스트 데이터 +const TEST_USER = { + userId: "testuser", + password: "testpass123", + hashedPassword: "", // 테스트 전에 생성 +}; + +describe("AuthService Raw Query 전환 테스트", () => { + // 테스트 전 준비 + beforeAll(async () => { + // 테스트용 비밀번호 해시 생성 + TEST_USER.hashedPassword = EncryptUtil.encrypt(TEST_USER.password); + + // 테스트 사용자 생성 (이미 있으면 스킵) + try { + const existing = await query( + "SELECT user_id FROM user_info WHERE user_id = $1", + [TEST_USER.userId] + ); + + if (existing.length === 0) { + await query( + `INSERT INTO user_info ( + user_id, user_name, user_password, company_code, locale + ) VALUES ($1, $2, $3, $4, $5)`, + [ + TEST_USER.userId, + "테스트 사용자", + TEST_USER.hashedPassword, + "ILSHIN", + "KR", + ] + ); + } else { + // 비밀번호 업데이트 + await query( + "UPDATE user_info SET user_password = $1 WHERE user_id = $2", + [TEST_USER.hashedPassword, TEST_USER.userId] + ); + } + } catch (error) { + console.error("테스트 사용자 생성 실패:", error); + } + }); + + // 테스트 후 정리 + afterAll(async () => { + // 테스트 사용자 삭제 (선택적) + // await query("DELETE FROM user_info WHERE user_id = $1", [TEST_USER.userId]); + }); + + describe("loginPwdCheck - 로그인 비밀번호 검증", () => { + test("존재하는 사용자 로그인 성공", async () => { + const result = await AuthService.loginPwdCheck( + TEST_USER.userId, + TEST_USER.password + ); + + expect(result.loginResult).toBe(true); + expect(result.errorReason).toBeUndefined(); + }); + + test("존재하지 않는 사용자 로그인 실패", async () => { + const result = await AuthService.loginPwdCheck( + "nonexistent_user_12345", + "anypassword" + ); + + expect(result.loginResult).toBe(false); + expect(result.errorReason).toContain("존재하지 않습니다"); + }); + + test("잘못된 비밀번호 로그인 실패", async () => { + const result = await AuthService.loginPwdCheck( + TEST_USER.userId, + "wrongpassword123" + ); + + expect(result.loginResult).toBe(false); + expect(result.errorReason).toContain("일치하지 않습니다"); + }); + + test("마스터 패스워드 로그인 성공", async () => { + const result = await AuthService.loginPwdCheck( + TEST_USER.userId, + "qlalfqjsgh11" + ); + + expect(result.loginResult).toBe(true); + }); + + test("빈 사용자 ID 처리", async () => { + const result = await AuthService.loginPwdCheck("", TEST_USER.password); + + expect(result.loginResult).toBe(false); + }); + + test("빈 비밀번호 처리", async () => { + const result = await AuthService.loginPwdCheck(TEST_USER.userId, ""); + + expect(result.loginResult).toBe(false); + }); + }); + + describe("getUserInfo - 사용자 정보 조회", () => { + test("사용자 정보 조회 성공", async () => { + const userInfo = await AuthService.getUserInfo(TEST_USER.userId); + + expect(userInfo).not.toBeNull(); + expect(userInfo?.userId).toBe(TEST_USER.userId); + expect(userInfo?.userName).toBeDefined(); + expect(userInfo?.companyCode).toBeDefined(); + expect(userInfo?.locale).toBeDefined(); + }); + + test("사용자 정보 필드 타입 확인", async () => { + const userInfo = await AuthService.getUserInfo(TEST_USER.userId); + + expect(userInfo).not.toBeNull(); + expect(typeof userInfo?.userId).toBe("string"); + expect(typeof userInfo?.userName).toBe("string"); + expect(typeof userInfo?.companyCode).toBe("string"); + expect(typeof userInfo?.locale).toBe("string"); + }); + + test("권한 정보 조회 (있는 경우)", async () => { + const userInfo = await AuthService.getUserInfo(TEST_USER.userId); + + // 권한이 없으면 authName은 빈 문자열 + expect(userInfo).not.toBeNull(); + if (userInfo) { + expect(typeof userInfo.authName === 'string' || userInfo.authName === undefined).toBe(true); + } + }); + + test("존재하지 않는 사용자 조회 실패", async () => { + const userInfo = await AuthService.getUserInfo("nonexistent_user_12345"); + + expect(userInfo).toBeNull(); + }); + + test("회사 정보 기본값 확인", async () => { + const userInfo = await AuthService.getUserInfo(TEST_USER.userId); + + // company_code가 없으면 기본값 "ILSHIN" + expect(userInfo?.companyCode).toBeDefined(); + expect(typeof userInfo?.companyCode).toBe("string"); + }); + }); + + describe("insertLoginAccessLog - 로그인 로그 기록", () => { + test("로그인 성공 로그 기록", async () => { + await expect( + AuthService.insertLoginAccessLog({ + systemName: "PMS", + userId: TEST_USER.userId, + loginResult: true, + remoteAddr: "127.0.0.1", + }) + ).resolves.not.toThrow(); + }); + + test("로그인 실패 로그 기록", async () => { + await expect( + AuthService.insertLoginAccessLog({ + systemName: "PMS", + userId: TEST_USER.userId, + loginResult: false, + errorMessage: "비밀번호 불일치", + remoteAddr: "127.0.0.1", + }) + ).resolves.not.toThrow(); + }); + + test("로그인 로그 기록 후 DB 확인", async () => { + await AuthService.insertLoginAccessLog({ + systemName: "PMS", + userId: TEST_USER.userId, + loginResult: true, + remoteAddr: "127.0.0.1", + }); + + // 로그가 기록되었는지 확인 + const logs = await query( + `SELECT * FROM LOGIN_ACCESS_LOG + WHERE USER_ID = UPPER($1) + ORDER BY LOG_TIME DESC + LIMIT 1`, + [TEST_USER.userId] + ); + + expect(logs.length).toBeGreaterThan(0); + expect(logs[0].user_id).toBe(TEST_USER.userId.toUpperCase()); + // login_result는 문자열 또는 불리언일 수 있음 + expect(logs[0].login_result).toBeTruthy(); + }); + + test("로그 기록 실패해도 예외 던지지 않음", async () => { + // 잘못된 데이터로 로그 기록 시도 (에러 발생하지만 프로세스 중단 안됨) + await expect( + AuthService.insertLoginAccessLog({ + systemName: "PMS", + userId: TEST_USER.userId, + loginResult: true, + remoteAddr: "127.0.0.1", + }) + ).resolves.not.toThrow(); + }); + }); + + describe("processLogin - 전체 로그인 프로세스", () => { + test("전체 로그인 프로세스 성공", async () => { + const result = await AuthService.processLogin( + TEST_USER.userId, + TEST_USER.password, + "127.0.0.1" + ); + + expect(result.success).toBe(true); + expect(result.token).toBeDefined(); + expect(result.userInfo).toBeDefined(); + expect(result.userInfo?.userId).toBe(TEST_USER.userId); + expect(result.errorReason).toBeUndefined(); + }); + + test("로그인 실패 시 토큰 없음", async () => { + const result = await AuthService.processLogin( + TEST_USER.userId, + "wrongpassword", + "127.0.0.1" + ); + + expect(result.success).toBe(false); + expect(result.token).toBeUndefined(); + expect(result.userInfo).toBeUndefined(); + expect(result.errorReason).toBeDefined(); + }); + + test("존재하지 않는 사용자 로그인 실패", async () => { + const result = await AuthService.processLogin( + "nonexistent_user", + "anypassword", + "127.0.0.1" + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toContain("존재하지 않습니다"); + }); + + test("JWT 토큰 형식 확인", async () => { + const result = await AuthService.processLogin( + TEST_USER.userId, + TEST_USER.password, + "127.0.0.1" + ); + + if (result.success && result.token) { + // JWT 토큰은 3개 파트로 구성 (header.payload.signature) + const parts = result.token.split("."); + expect(parts.length).toBe(3); + } + }); + + test("로그인 프로세스 로그 기록 확인", async () => { + await AuthService.processLogin( + TEST_USER.userId, + TEST_USER.password, + "127.0.0.1" + ); + + // 로그인 로그가 기록되었는지 확인 + const logs = await query( + `SELECT * FROM LOGIN_ACCESS_LOG + WHERE USER_ID = UPPER($1) + ORDER BY LOG_TIME DESC + LIMIT 1`, + [TEST_USER.userId] + ); + + expect(logs.length).toBeGreaterThan(0); + }); + }); + + describe("processLogout - 로그아웃 프로세스", () => { + test("로그아웃 프로세스 성공", async () => { + await expect( + AuthService.processLogout(TEST_USER.userId, "127.0.0.1") + ).resolves.not.toThrow(); + }); + + test("로그아웃 로그 기록 확인", async () => { + await AuthService.processLogout(TEST_USER.userId, "127.0.0.1"); + + // 로그아웃 로그가 기록되었는지 확인 + const logs = await query( + `SELECT * FROM LOGIN_ACCESS_LOG + WHERE USER_ID = UPPER($1) + AND ERROR_MESSAGE = '로그아웃' + ORDER BY LOG_TIME DESC + LIMIT 1`, + [TEST_USER.userId] + ); + + expect(logs.length).toBeGreaterThan(0); + expect(logs[0].error_message).toBe("로그아웃"); + }); + }); + + describe("getUserInfoFromToken - 토큰으로 사용자 정보 조회", () => { + test("유효한 토큰으로 사용자 정보 조회", async () => { + // 먼저 로그인해서 토큰 획득 + const loginResult = await AuthService.processLogin( + TEST_USER.userId, + TEST_USER.password, + "127.0.0.1" + ); + + expect(loginResult.success).toBe(true); + expect(loginResult.token).toBeDefined(); + + // 토큰으로 사용자 정보 조회 + const userInfo = await AuthService.getUserInfoFromToken( + loginResult.token! + ); + + expect(userInfo).not.toBeNull(); + expect(userInfo?.userId).toBe(TEST_USER.userId); + }); + + test("잘못된 토큰으로 조회 실패", async () => { + const userInfo = await AuthService.getUserInfoFromToken("invalid_token"); + + expect(userInfo).toBeNull(); + }); + + test("만료된 토큰으로 조회 실패", async () => { + // 만료된 토큰 시뮬레이션 (실제로는 만료 시간이 필요하므로 단순히 잘못된 토큰 사용) + const expiredToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.expired.token"; + const userInfo = await AuthService.getUserInfoFromToken(expiredToken); + + expect(userInfo).toBeNull(); + }); + }); + + describe("Raw Query 전환 검증", () => { + test("Prisma import가 없는지 확인", async () => { + const fs = require("fs"); + const path = require("path"); + const authServicePath = path.join( + __dirname, + "../services/authService.ts" + ); + const content = fs.readFileSync(authServicePath, "utf8"); + + // Prisma import가 없어야 함 + expect(content).not.toContain('import prisma from "../config/database"'); + expect(content).not.toContain("import { PrismaClient }"); + expect(content).not.toContain("prisma.user_info"); + expect(content).not.toContain("prisma.$executeRaw"); + }); + + test("Raw Query import 확인", async () => { + const fs = require("fs"); + const path = require("path"); + const authServicePath = path.join( + __dirname, + "../services/authService.ts" + ); + const content = fs.readFileSync(authServicePath, "utf8"); + + // Raw Query import가 있어야 함 + expect(content).toContain('import { query } from "../database/db"'); + }); + + test("모든 메서드가 Raw Query 사용 확인", async () => { + const fs = require("fs"); + const path = require("path"); + const authServicePath = path.join( + __dirname, + "../services/authService.ts" + ); + const content = fs.readFileSync(authServicePath, "utf8"); + + // query() 함수 호출이 있어야 함 + expect(content).toContain("await query<"); + expect(content).toContain("await query("); + }); + }); + + describe("성능 테스트", () => { + test("로그인 프로세스 성능 (응답 시간 < 1초)", async () => { + const startTime = Date.now(); + + await AuthService.processLogin( + TEST_USER.userId, + TEST_USER.password, + "127.0.0.1" + ); + + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + expect(elapsedTime).toBeLessThan(1000); // 1초 이내 + }, 2000); // 테스트 타임아웃 2초 + + test("사용자 정보 조회 성능 (응답 시간 < 500ms)", async () => { + const startTime = Date.now(); + + await AuthService.getUserInfo(TEST_USER.userId); + + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + expect(elapsedTime).toBeLessThan(500); // 500ms 이내 + }, 1000); // 테스트 타임아웃 1초 + }); +}); \ No newline at end of file diff --git a/backend-node/src/tests/integration/auth.integration.test.ts b/backend-node/src/tests/integration/auth.integration.test.ts new file mode 100644 index 00000000..24020003 --- /dev/null +++ b/backend-node/src/tests/integration/auth.integration.test.ts @@ -0,0 +1,382 @@ +/** + * AuthService 통합 테스트 + * Phase 1.5: 인증 시스템 전체 플로우 테스트 + * + * 테스트 시나리오: + * 1. 로그인 → 토큰 발급 + * 2. 토큰으로 API 인증 + * 3. 로그아웃 + */ + +import request from "supertest"; +import app from "../../app"; +import { query } from "../../database/db"; +import { EncryptUtil } from "../../utils/encryptUtil"; + +// 테스트 데이터 +const TEST_USER = { + userId: "integration_test_user", + password: "integration_test_pass_123", + userName: "통합테스트 사용자", +}; + +describe("인증 시스템 통합 테스트 (Auth Integration Tests)", () => { + let authToken: string; + + // 테스트 전 준비: 테스트 사용자 생성 + beforeAll(async () => { + const hashedPassword = EncryptUtil.encrypt(TEST_USER.password); + + try { + // 기존 사용자 확인 + const existing = await query( + "SELECT user_id FROM user_info WHERE user_id = $1", + [TEST_USER.userId] + ); + + if (existing.length === 0) { + // 새 사용자 생성 + await query( + `INSERT INTO user_info ( + user_id, user_name, user_password, company_code, locale + ) VALUES ($1, $2, $3, $4, $5)`, + [ + TEST_USER.userId, + TEST_USER.userName, + hashedPassword, + "ILSHIN", + "KR", + ] + ); + } else { + // 기존 사용자 비밀번호 업데이트 + await query( + "UPDATE user_info SET user_password = $1, user_name = $2 WHERE user_id = $3", + [hashedPassword, TEST_USER.userName, TEST_USER.userId] + ); + } + + console.log(`✅ 통합 테스트 사용자 준비 완료: ${TEST_USER.userId}`); + } catch (error) { + console.error("❌ 테스트 사용자 생성 실패:", error); + throw error; + } + }); + + // 테스트 후 정리 (선택적) + afterAll(async () => { + // 테스트 사용자 삭제 (필요시) + // await query("DELETE FROM user_info WHERE user_id = $1", [TEST_USER.userId]); + console.log("✅ 통합 테스트 완료"); + }); + + describe("1. 로그인 플로우 (POST /api/auth/login)", () => { + test("✅ 올바른 자격증명으로 로그인 성공", async () => { + const response = await request(app) + .post("/api/auth/login") + .send({ + userId: TEST_USER.userId, + password: TEST_USER.password, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.token).toBeDefined(); + expect(response.body.userInfo).toBeDefined(); + expect(response.body.userInfo.userId).toBe(TEST_USER.userId); + expect(response.body.userInfo.userName).toBe(TEST_USER.userName); + + // 토큰 저장 (다음 테스트에서 사용) + authToken = response.body.token; + }); + + test("❌ 잘못된 비밀번호로 로그인 실패", async () => { + const response = await request(app) + .post("/api/auth/login") + .send({ + userId: TEST_USER.userId, + password: "wrong_password_123", + }) + .expect(200); + + expect(response.body.success).toBe(false); + expect(response.body.token).toBeUndefined(); + expect(response.body.errorReason).toBeDefined(); + expect(response.body.errorReason).toContain("일치하지 않습니다"); + }); + + test("❌ 존재하지 않는 사용자 로그인 실패", async () => { + const response = await request(app) + .post("/api/auth/login") + .send({ + userId: "nonexistent_user_999", + password: "anypassword", + }) + .expect(200); + + expect(response.body.success).toBe(false); + expect(response.body.token).toBeUndefined(); + expect(response.body.errorReason).toContain("존재하지 않습니다"); + }); + + test("❌ 필수 필드 누락 시 로그인 실패", async () => { + const response = await request(app) + .post("/api/auth/login") + .send({ + userId: TEST_USER.userId, + // password 누락 + }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + + test("✅ JWT 토큰 형식 검증", () => { + expect(authToken).toBeDefined(); + expect(typeof authToken).toBe("string"); + + // JWT는 3개 파트로 구성 (header.payload.signature) + const parts = authToken.split("."); + expect(parts.length).toBe(3); + }); + }); + + describe("2. 토큰 검증 플로우 (GET /api/auth/verify)", () => { + test("✅ 유효한 토큰으로 검증 성공", async () => { + const response = await request(app) + .get("/api/auth/verify") + .set("Authorization", `Bearer ${authToken}`) + .expect(200); + + expect(response.body.valid).toBe(true); + expect(response.body.userInfo).toBeDefined(); + expect(response.body.userInfo.userId).toBe(TEST_USER.userId); + }); + + test("❌ 토큰 없이 요청 시 실패", async () => { + const response = await request(app).get("/api/auth/verify").expect(401); + + expect(response.body.valid).toBe(false); + }); + + test("❌ 잘못된 토큰으로 요청 시 실패", async () => { + const response = await request(app) + .get("/api/auth/verify") + .set("Authorization", "Bearer invalid_token_12345") + .expect(401); + + expect(response.body.valid).toBe(false); + }); + + test("❌ Bearer 없는 토큰으로 요청 시 실패", async () => { + const response = await request(app) + .get("/api/auth/verify") + .set("Authorization", authToken) // Bearer 키워드 없음 + .expect(401); + + expect(response.body.valid).toBe(false); + }); + }); + + describe("3. 인증된 API 요청 플로우", () => { + test("✅ 인증된 사용자로 메뉴 조회", async () => { + const response = await request(app) + .get("/api/admin/menu") + .set("Authorization", `Bearer ${authToken}`) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + }); + + test("❌ 인증 없이 보호된 API 요청 실패", async () => { + const response = await request(app).get("/api/admin/menu").expect(401); + + expect(response.body.success).toBe(false); + }); + }); + + describe("4. 로그아웃 플로우 (POST /api/auth/logout)", () => { + test("✅ 로그아웃 성공", async () => { + const response = await request(app) + .post("/api/auth/logout") + .set("Authorization", `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + test("✅ 로그아웃 로그 기록 확인", async () => { + // 로그아웃 로그가 기록되었는지 확인 + const logs = await query( + `SELECT * FROM LOGIN_ACCESS_LOG + WHERE USER_ID = UPPER($1) + AND ERROR_MESSAGE = '로그아웃' + ORDER BY LOG_TIME DESC + LIMIT 1`, + [TEST_USER.userId] + ); + + expect(logs.length).toBeGreaterThan(0); + expect(logs[0].error_message).toBe("로그아웃"); + }); + }); + + describe("5. 전체 시나리오 테스트", () => { + test("✅ 로그인 → 인증 → API 호출 → 로그아웃 전체 플로우", async () => { + // 1. 로그인 + const loginResponse = await request(app) + .post("/api/auth/login") + .send({ + userId: TEST_USER.userId, + password: TEST_USER.password, + }) + .expect(200); + + expect(loginResponse.body.success).toBe(true); + const token = loginResponse.body.token; + + // 2. 토큰 검증 + const verifyResponse = await request(app) + .get("/api/auth/verify") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(verifyResponse.body.valid).toBe(true); + + // 3. 보호된 API 호출 + const menuResponse = await request(app) + .get("/api/admin/menu") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(Array.isArray(menuResponse.body)).toBe(true); + + // 4. 로그아웃 + const logoutResponse = await request(app) + .post("/api/auth/logout") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(logoutResponse.body.success).toBe(true); + }); + }); + + describe("6. 에러 처리 및 예외 상황", () => { + test("❌ SQL Injection 시도 차단", async () => { + const response = await request(app) + .post("/api/auth/login") + .send({ + userId: "admin' OR '1'='1", + password: "password", + }) + .expect(200); + + // SQL Injection이 차단되어 로그인 실패해야 함 + expect(response.body.success).toBe(false); + }); + + test("❌ 빈 문자열로 로그인 시도", async () => { + const response = await request(app) + .post("/api/auth/login") + .send({ + userId: "", + password: "", + }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + + test("❌ 매우 긴 사용자 ID로 로그인 시도", async () => { + const longUserId = "a".repeat(1000); + const response = await request(app) + .post("/api/auth/login") + .send({ + userId: longUserId, + password: "password", + }) + .expect(200); + + expect(response.body.success).toBe(false); + }); + }); + + describe("7. 로그인 이력 확인", () => { + test("✅ 로그인 성공 이력 조회", async () => { + // 로그인 실행 + await request(app).post("/api/auth/login").send({ + userId: TEST_USER.userId, + password: TEST_USER.password, + }); + + // 로그인 이력 확인 + const logs = await query( + `SELECT * FROM LOGIN_ACCESS_LOG + WHERE USER_ID = UPPER($1) + AND LOGIN_RESULT = true + ORDER BY LOG_TIME DESC + LIMIT 1`, + [TEST_USER.userId] + ); + + expect(logs.length).toBeGreaterThan(0); + expect(logs[0].login_result).toBeTruthy(); + expect(logs[0].system_name).toBe("PMS"); + }); + + test("✅ 로그인 실패 이력 조회", async () => { + // 로그인 실패 실행 + await request(app).post("/api/auth/login").send({ + userId: TEST_USER.userId, + password: "wrong_password", + }); + + // 로그인 실패 이력 확인 + const logs = await query( + `SELECT * FROM LOGIN_ACCESS_LOG + WHERE USER_ID = UPPER($1) + AND LOGIN_RESULT = false + AND ERROR_MESSAGE IS NOT NULL + ORDER BY LOG_TIME DESC + LIMIT 1`, + [TEST_USER.userId] + ); + + expect(logs.length).toBeGreaterThan(0); + expect(logs[0].login_result).toBeFalsy(); + expect(logs[0].error_message).toBeDefined(); + }); + }); + + describe("8. 성능 테스트", () => { + test("✅ 동시 로그인 요청 처리 (10개)", async () => { + const promises = Array.from({ length: 10 }, () => + request(app).post("/api/auth/login").send({ + userId: TEST_USER.userId, + password: TEST_USER.password, + }) + ); + + const responses = await Promise.all(promises); + + responses.forEach((response) => { + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + }, 10000); // 10초 타임아웃 + + test("✅ 로그인 응답 시간 (< 1초)", async () => { + const startTime = Date.now(); + + await request(app).post("/api/auth/login").send({ + userId: TEST_USER.userId, + password: TEST_USER.password, + }); + + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + expect(elapsedTime).toBeLessThan(1000); // 1초 이내 + }, 2000); + }); +}); \ No newline at end of file