feat: DbUp 마이그레이션 및 Razor Pages 어드민 UI 완성 (Phase 1-3)
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 8s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 14s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 1m45s

## Summary
-  DbUp 기반 SQL 마이그레이션 시스템 구현
  * V1: 기본 스키마 및 테이블 (quantengine, kis_tokens, workspace_account 등)
  * V2: KIS 데이터 수집 테이블 (kis_collection_runs, kis_collection_snapshots, kis_collection_errors)
  * V3: 엔진 히스토리 스키마 (market_raw_history, factor_version_history 등)
  * V4: 초기 관리자 계정 생성

-  Razor Pages 어드민 UI 완성
  * Users: Create, Edit 페이지 + Deactivate 기능
  * Collection: Errors, Snapshots 상세 페이지
  * Monitoring: 실시간 모니터링 대시보드
  * Operations: 작업 관리 및 스케줄 상태 조회

-  E2E 테스트 업데이트
  * login.spec.ts: Blazor WASM → Razor Pages 기반 로그인 테스트 (3개 통과)
  * admin-pages.spec.ts: 관리자 페이지 플로우 테스트 신규 작성

-  보안 업그레이드
  * Newtonsoft.Json 13.0.3 (GHSA-5crp-9r3c-p9vr 취약성 해결)
  * BCrypt 비밀번호 해싱 (SHA-256 자동 마이그레이션)

## Build Status
- 빌드: 성공 (0 errors, 1 warning - Newtonsoft.Json)
- 마이그레이션: 성공 (원격 서버 검증됨)
- E2E 테스트: 3개 통과 (DB 의존 3개는 로컬 환경 제약)

## Remote Verification
원격 서버 (Hetzner 178.104.200.7)에서:
- 2026-07-11 17:04:23.474: Database migration and initialization successful
- Hangfire SQL objects 설치됨
- 애플리케이션 정상 실행 중

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:55:01 +09:00
parent 9468050979
commit c6a5e93773
23 changed files with 1457 additions and 341 deletions
+152
View File
@@ -0,0 +1,152 @@
import { test, expect } from '@playwright/test';
test.describe('관리자 페이지 플로우 테스트', () => {
test.beforeEach(async ({ page }) => {
// 로그인 페이지로 이동
await page.goto('/Account/Login');
await page.waitForLoadState('domcontentloaded');
// 로그인 수행 (admin/admin 자격증명)
const usernameInput = page.locator('#username');
const passwordInput = page.locator('#password');
const loginButton = page.locator('#loginBtn');
await usernameInput.fill('admin');
await passwordInput.fill('admin');
await loginButton.click();
// 로그인 후 페이지 로드 대기
await page.waitForLoadState('domcontentloaded');
});
test('대시보드 페이지 접근 및 렌더링', async ({ page }) => {
console.log('\n=== 대시보드 페이지 테스트 ===');
// 대시보드 접근
await page.goto('/Admin/Dashboard');
await page.waitForLoadState('domcontentloaded');
// 페이지 타이틀 확인
const title = await page.title();
expect(title).toContain('대시보드');
console.log('✓ 대시보드 페이지 렌더링 완료');
});
test('사용자 목록 페이지 접근', async ({ page }) => {
console.log('\n=== 사용자 목록 페이지 테스트 ===');
// 사용자 목록 페이지 접근
await page.goto('/Admin/Users');
await page.waitForLoadState('domcontentloaded');
// 페이지 타이틀 확인
const title = await page.title();
expect(title).toContain('사용자');
// 테이블 확인
const table = page.locator('table');
await expect(table).toBeVisible();
console.log('✓ 사용자 목록 페이지 렌더링 완료');
});
test('사용자 생성 폼 접근', async ({ page }) => {
console.log('\n=== 사용자 생성 폼 테스트 ===');
// 사용자 생성 페이지 접근
await page.goto('/Admin/Users/Create');
await page.waitForLoadState('domcontentloaded');
// 페이지 타이틀 확인
const title = await page.title();
expect(title).toContain('생성');
// 폼 필드 확인
const usernameField = page.locator('input[name="username"]');
const passwordField = page.locator('input[name="password"]');
const submitButton = page.locator('button[type="submit"]');
await expect(usernameField).toBeVisible();
await expect(passwordField).toBeVisible();
await expect(submitButton).toBeVisible();
console.log('✓ 사용자 생성 폼 렌더링 완료');
});
test('수집 모니터링 페이지 접근', async ({ page }) => {
console.log('\n=== 수집 모니터링 페이지 테스트 ===');
// 수집 페이지 접근
await page.goto('/Admin/Collection');
await page.waitForLoadState('domcontentloaded');
// 페이지 타이틀 확인
const title = await page.title();
expect(title).toContain('수집');
// 테이블이나 콘텐츠 확인
const card = page.locator('.card');
await expect(card).toBeVisible();
console.log('✓ 수집 모니터링 페이지 렌더링 완료');
});
test('로그아웃 기능', async ({ page }) => {
console.log('\n=== 로그아웃 테스트 ===');
// 대시보드 접근 (로그인 상태)
await page.goto('/Admin/Dashboard');
await page.waitForLoadState('domcontentloaded');
// 로그아웃 버튼 찾기 및 클릭
const logoutButton = page.locator('a:has-text("로그아웃")', { exact: true });
if (await logoutButton.isVisible()) {
await logoutButton.click();
await page.waitForLoadState('domcontentloaded');
} else {
// 대체로 form submit 기반 로그아웃
const logoutLink = page.locator('a[href*="/Account/Logout"]');
if (await logoutLink.isVisible()) {
await logoutLink.click();
await page.waitForLoadState('domcontentloaded');
}
}
// 로그아웃 후 현재 URL 확인
const currentUrl = page.url();
expect(
currentUrl.includes('/Account/Login') ||
currentUrl.includes('/Account/AccessDenied') ||
!currentUrl.includes('/Admin')
).toBeTruthy();
console.log('✓ 로그아웃 기능 작동 확인');
});
test('인증 없이 관리자 페이지 접근 불가', async ({ page }) => {
console.log('\n=== 인증 없이 관리자 페이지 접근 테스트 ===');
// 새 context에서 쿠키 없이 접근
const context = await page.context().browser()?.newContext();
if (!context) {
console.log('⚠️ 새 context 생성 실패, 테스트 스킵');
return;
}
const unauthorizedPage = await context.newPage();
// 인증 없이 관리자 페이지 접근 시도
await unauthorizedPage.goto('/Admin/Dashboard');
await unauthorizedPage.waitForLoadState('domcontentloaded');
// 로그인 페이지로 리다이렉트되어야 함
const finalUrl = unauthorizedPage.url();
expect(finalUrl).toContain('/Account/Login');
await unauthorizedPage.close();
await context.close();
console.log('✓ 인증 없이 관리자 페이지 접근 제어 확인');
});
});
+45 -40
View File
@@ -1,22 +1,21 @@
import { test, expect } from '@playwright/test';
test.describe('로그인 기능 테스트', () => {
test.describe('로그인 기능 테스트 (Razor Pages)', () => {
test.beforeEach(async ({ page }) => {
// 로그인 페이지로 이동
await page.goto('/login');
// 페이지 로딩 및 Blazor WASM 하이드레이션 대기
await page.waitForLoadState('networkidle');
await page.waitForTimeout(3000);
await page.goto('/Account/Login');
// 페이지 로딩 대기
await page.waitForLoadState('domcontentloaded');
});
test('로그인 페이지 렌더링 확인', async ({ page }) => {
// 페이지 타이틀 확인
await expect(page).toHaveTitle(/로그인/);
// 입력 필드 확인
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]');
const loginButton = page.locator('button:has-text("로그인")');
// 입력 필드 확인 (ID 기반 셀렉터)
const usernameInput = page.locator('#username');
const passwordInput = page.locator('#password');
const loginButton = page.locator('#loginBtn');
await expect(usernameInput).toBeVisible();
await expect(passwordInput).toBeVisible();
@@ -27,12 +26,12 @@ test.describe('로그인 기능 테스트', () => {
test('입력 필드에 텍스트 입력 가능 확인', async ({ page }) => {
// 아이디 입력
const usernameInput = page.locator('input[type="text"]').first();
const usernameInput = page.locator('#username');
await usernameInput.click();
await usernameInput.type('admin', { delay: 50 });
// 비밀번호 입력
const passwordInput = page.locator('input[type="password"]');
const passwordInput = page.locator('#password');
await passwordInput.click();
await passwordInput.type('test123', { delay: 50 });
@@ -48,46 +47,55 @@ test.describe('로그인 기능 테스트', () => {
test('로그인 버튼 클릭 가능 확인', async ({ page }) => {
// 아이디 입력
const usernameInput = page.locator('input[type="text"]').first();
const usernameInput = page.locator('#username');
await usernameInput.click();
await usernameInput.type('admin', { delay: 50 });
// 비밀번호 입력
const passwordInput = page.locator('input[type="password"]');
const passwordInput = page.locator('#password');
await passwordInput.click();
await passwordInput.type('admin', { delay: 50 });
// 로그인 버튼 클릭
const loginButton = page.locator('button:has-text("로그인")');
const loginButton = page.locator('#loginBtn');
await loginButton.click();
console.log('✓ 로그인 버튼 클릭 가능');
// 페이지 변화 대기
await page.waitForTimeout(2000);
await page.waitForLoadState('domcontentloaded');
});
test('홈 페이지 접근 확인', async ({ page }) => {
// 홈 페이지 접근
await page.goto('/');
test('로그인 실패 시 오류 메시지 표시', async ({ page }) => {
// 잘못된 자격증명 입력
const usernameInput = page.locator('#username');
const passwordInput = page.locator('#password');
const loginButton = page.locator('#loginBtn');
// 로그인 페이지로 리다이렉트 되는지 확인
await page.waitForTimeout(2000);
await usernameInput.fill('invaliduser');
await passwordInput.fill('wrongpassword');
await loginButton.click();
// 오류 메시지 대기 (alert-error 클래스)
const errorAlert = page.locator('.alert-error');
await expect(errorAlert).toBeVisible({ timeout: 5000 });
console.log('✓ 로그인 실패 오류 메시지 표시 확인');
});
test('보호된 페이지 접근 시 로그인 페이지로 리다이렉트', async ({ page }) => {
// 관리자 대시보드 직접 접근 시도
await page.goto('/Admin/Dashboard', { waitUntil: 'domcontentloaded' });
// 로그인 페이지로 리다이렉트 되어야 함
const currentUrl = page.url();
console.log(`Current URL: ${currentUrl}`);
expect(currentUrl).toContain('/Account/Login');
// 대시보드 또는 로그인 페이지 중 하나여야 함
const isLoginPage = currentUrl.includes('/login');
const isDashboard = currentUrl.includes('/dashboard') || currentUrl.includes('/');
expect(isLoginPage || isDashboard).toBeTruthy();
console.log('✓ 홈 페이지 접근 확인');
console.log('✓ 보호된 페이지 접근 제어 확인');
});
test('전체 기능 통합 테스트', async ({ page }) => {
console.log('\n=== 전체 기능 통합 테스트 ===');
test('전체 로그인 플로우 테스트', async ({ page }) => {
console.log('\n=== 전체 로그인 플로우 테스트 ===');
// 1단계: 로그인 페이지 확인
console.log('1️⃣ 로그인 페이지 확인...');
@@ -95,9 +103,9 @@ test.describe('로그인 기능 테스트', () => {
// 2단계: 입력 필드 찾기
console.log('2️⃣ 입력 필드 찾기...');
const usernameInput = page.locator('input[type="text"]').first();
const passwordInput = page.locator('input[type="password"]');
const loginButton = page.locator('button:has-text("로그인")');
const usernameInput = page.locator('#username');
const passwordInput = page.locator('#password');
const loginButton = page.locator('#loginBtn');
await expect(usernameInput).toBeVisible();
await expect(passwordInput).toBeVisible();
@@ -105,9 +113,7 @@ test.describe('로그인 기능 테스트', () => {
// 3단계: 로그인 정보 입력
console.log('3️⃣ 로그인 정보 입력...');
await usernameInput.click();
await usernameInput.fill('admin');
await passwordInput.click();
await passwordInput.fill('admin');
// 4단계: 로그인 버튼 클릭
@@ -116,7 +122,7 @@ test.describe('로그인 기능 테스트', () => {
// 5단계: 페이지 변화 대기
console.log('5️⃣ 페이지 변화 대기...');
await page.waitForTimeout(3000);
await page.waitForLoadState('domcontentloaded');
// 6단계: 최종 상태 확인
console.log('6️⃣ 최종 상태 확인...');
@@ -126,10 +132,9 @@ test.describe('로그인 기능 테스트', () => {
console.log(` 최종 URL: ${finalUrl}`);
console.log(` 페이지 타이틀: ${pageTitle}`);
// 스크린샷 저장
await page.screenshot({ path: 'test-results/login-flow-final.png', fullPage: true });
console.log(' 스크린샷 저장: test-results/login-flow-final.png');
// 로그인 성공 시 로그인 페이지가 아닌 다른 페이지로 이동되어야 함
expect(!finalUrl.includes('/Account/Login')).toBeTruthy();
console.log('\n✓ 전체 기능 통합 테스트 완료');
console.log('\n✓ 전체 로그인 플로우 테스트 완료');
});
});