docs: 로컬 테스트 필수 조건 추가 (SSH 터널링, 배포 전 검증 가드)
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 15s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production (Local) / Build & Deploy to Production (push) Failing after 1m28s
Build & Package / build (push) Failing after 1m33s

## 변경사항

### CLAUDE.md
- '로컬 개발 & 테스트' 섹션 신규 추가
  * SSH 터널링 설정 (Docker 사용 금지)
  * appsettings.Development.json 설정
  * 로컬 서비스 시작 방법
- 배포 전 필수 체크리스트
  * Build (0 errors, 0 warnings)
  * 서비스 시작 확인
  * 로그인 테스트
  * 모든 Admin 페이지 검증 (200 상태, 500 에러 없음)
  * E2E 테스트 통과
- 배포 게이트: 로컬 테스트 통과 전 절대 배포 금지

### E2E 테스트
- complete-admin-flow.spec.ts 신규 추가
  * 모든 Admin 페이지 접근 테스트
  * 500 에러 감지
  * Authorization 검증

## 교훈

Authorization Policy 500 오류가 로컬에서 먼저 발견되었어야 했음.
Docker 없이 SSH 터널로 원격 DB 접속하는 현실을 반영하여 지침화.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 18:45:15 +09:00
parent 462ecc6de3
commit 7a7455e56d
2 changed files with 228 additions and 0 deletions
+69
View File
@@ -264,6 +264,74 @@ UI: `Pages/Admin/Collection/Index.cshtml` — status 값에 따라 배지 색상
4. **GetDailyItemChartPriceAsync** (FHKST03010100) — Daily OHLCV data
5. **GetInvestorTrendAsync** (FHKST01010900) — Investor sentiment (개인/외국인/기관)
## Local Development & Testing (2026-07-11)
### ⚠️ CRITICAL: SSH Tunnel for Remote Database Access
**Never use Docker locally.** Always use SSH tunneling to connect to remote PostgreSQL:
```powershell
# 1. Setup SSH tunnel (Terminal 1) — forwards local 5432 to remote DB
ssh -L 127.0.0.1:5432:localhost:5432 kjh2064@178.104.200.7 -N
# 2. Configure appsettings.Development.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=quantengine_app;Search Path=quantengine;"
}
}
# 3. Start service locally (Terminal 2)
cd src/dotnet
dotnet watch run --project QuantEngine.Web
# 4. Access locally
http://localhost:5265/Account/Login
```
### Mandatory Pre-Deployment Checklist
**EVERY code change must pass:**
1.**Local build (0 errors, 0 warnings)**
```powershell
dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj -c Release
```
2. ✅ **Local service startup with SSH tunnel**
- Service must start without DB connection errors
- DbUp migrations must succeed
3. ✅ **Login test (admin/quant123!)**
- `/Account/Login` must return 200
- Authentication flow must complete
- Cookie must be set
4. ✅ **All Admin pages must load**
- `/Admin/Dashboard` → 200 (NOT 500)
- `/Admin/Users` → 200 (NOT 500)
- `/Admin/Collection` → 200 (NOT 500)
- `/Admin/Monitoring` → 200 (NOT 500)
- `/Admin/Operations` → 200 (NOT 500)
- **No 500 errors in response body**
5. ✅ **Playwright E2E tests pass**
```powershell
npx playwright test tests/e2e/complete-admin-flow.spec.ts
```
### Deployment Gates
**NEVER deploy without:**
- ❌ Local testing complete
- ❌ All Admin pages verified (200 status, no 500 errors)
- ❌ E2E tests passing
- ❌ Authorization Policy configured (if changes made to Program.cs)
**Deployment failure is better than service outage.** Halt and investigate if local tests fail.
---
## Notes for Contributors (2026-07-11)
- **SQL Safety**: Whitelist-only table access (enum switch in Repository)
@@ -275,3 +343,4 @@ UI: `Pages/Admin/Collection/Index.cshtml` — status 값에 따라 배지 색상
- **Legacy Code**: `QuantEngine.Web.Client` folder kept for reference (not in .sln, not built)
- **Newtonsoft.Json**: Known high-severity vulnerability (GHSA-5crp-9r3c-p9vr); update or replace when feasible
- **Release Authority**: Python gates (`full-gate`, `prepare-upload-zip`) remain authority; .NET Admin fully operational as of 2026-07-11
- **Testing Requirement**: All code changes must pass local testing with SSH tunnel to remote DB before deployment (see "Local Development & Testing" above)
+159
View File
@@ -0,0 +1,159 @@
import { test, expect } from '@playwright/test';
test.describe('Complete Admin Flow - All Pages', () => {
const BASE_URL = 'http://localhost:5000';
const LOGIN_URL = `${BASE_URL}/Account/Login`;
const ADMIN_DASHBOARD = `${BASE_URL}/Admin/Dashboard`;
test.beforeEach(async ({ page }) => {
// 1. 로그인 페이지 접근
await page.goto(LOGIN_URL);
await expect(page).toHaveTitle(/Login|로그인/i);
// 2. 로그인 시도
await page.fill('#username', 'admin');
await page.fill('#password', 'quant123!');
await page.click('button[type="submit"]');
// 3. 로그인 후 리다이렉트 대기
await page.waitForURL(/Admin|Dashboard/);
});
test('Admin Dashboard 접근 가능', async ({ page }) => {
console.log('Testing: Admin Dashboard');
await page.goto(ADMIN_DASHBOARD);
// 페이지 로드 확인
await expect(page).toHaveTitle(/Dashboard|대시보드/i);
// 주요 엘리먼트 확인
const body = await page.content();
expect(body).toContain('Dashboard');
console.log('✓ Dashboard loaded successfully');
});
test('Admin Users 페이지 접근', async ({ page }) => {
console.log('Testing: Users Page');
await page.goto(`${BASE_URL}/Admin/Users`);
// 페이지 로드 확인
const statusCode = (await page.goto(`${BASE_URL}/Admin/Users`)).status();
expect([200, 301, 302]).toContain(statusCode);
const content = await page.content();
expect(content).not.toContain('500');
expect(content).not.toContain('error');
console.log('✓ Users page accessible');
});
test('Admin Collection 페이지 접근', async ({ page }) => {
console.log('Testing: Collection Page');
const response = await page.goto(`${BASE_URL}/Admin/Collection`);
expect(response?.status()).toBeLessThan(400);
const content = await page.content();
expect(content).not.toContain('500');
console.log('✓ Collection page accessible');
});
test('Admin Monitoring 페이지 접근', async ({ page }) => {
console.log('Testing: Monitoring Page');
const response = await page.goto(`${BASE_URL}/Admin/Monitoring`);
expect(response?.status()).toBeLessThan(400);
const content = await page.content();
expect(content).not.toContain('500');
console.log('✓ Monitoring page accessible');
});
test('Admin Operations 페이지 접근', async ({ page }) => {
console.log('Testing: Operations Page');
const response = await page.goto(`${BASE_URL}/Admin/Operations`);
expect(response?.status()).toBeLessThan(400);
const content = await page.content();
expect(content).not.toContain('500');
console.log('✓ Operations page accessible');
});
test('Users Create 페이지 접근', async ({ page }) => {
console.log('Testing: Users Create Page');
const response = await page.goto(`${BASE_URL}/Admin/Users/Create`);
expect(response?.status()).toBeLessThan(400);
const content = await page.content();
expect(content).not.toContain('500');
expect(content).toContain('Create');
console.log('✓ Users Create page accessible');
});
test('모든 Admin 페이지가 200 이상 500 미만 상태코드 반환', async ({ page }) => {
console.log('Testing: All Admin Pages Status Codes');
const adminPages = [
'/Admin/Dashboard',
'/Admin/Users',
'/Admin/Collection',
'/Admin/Monitoring',
'/Admin/Operations',
'/Admin/Users/Create',
];
const results = [];
for (const path of adminPages) {
const fullUrl = `${BASE_URL}${path}`;
const response = await page.goto(fullUrl);
const status = response?.status() || 0;
results.push({
path,
status,
success: status >= 200 && status < 500,
});
console.log(` ${path}${status} ${status >= 200 && status < 500 ? '✓' : '❌'}`);
// 각 페이지에서 500 에러가 없는지 확인
const content = await page.content();
expect(content).not.toContain('500');
expect(content).not.toContain('System.InvalidOperationException');
}
// 모든 페이지가 정상인지 확인
const allSuccess = results.every(r => r.success);
expect(allSuccess).toBe(true);
console.log('\n✅ All Admin pages passed');
});
test('로그아웃 후 보호된 페이지 접근 불가', async ({ page }) => {
console.log('Testing: Access Control');
// 대시보드 접근 가능 확인
let response = await page.goto(ADMIN_DASHBOARD);
expect(response?.status()).toBeLessThan(400);
// 로그아웃 (쿠키 삭제)
await page.context().clearCookies();
// 보호된 페이지 접근 시도
response = await page.goto(ADMIN_DASHBOARD);
// 로그인 페이지로 리다이렉트되거나 401/403 에러
const url = page.url();
expect(
url.includes('/Account/Login') ||
url.includes('/Account/AccessDenied') ||
(response?.status() && [401, 403].includes(response.status()))
).toBe(true);
console.log('✓ Access control working correctly');
});
});