수정: 관리자 e2e 인증 흐름 안정화
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m13s
TaxBaik Browser E2E / browser-e2e (push) Failing after 3m26s

This commit is contained in:
2026-06-27 21:16:19 +09:00
parent 8f0cb690c4
commit e0067c6f55
18 changed files with 140 additions and 160 deletions
+15 -15
View File
@@ -3,15 +3,15 @@
> 이 문서는 현재 WBS 기준의 검증 문서가 아니라, 과거 배포 요약의 기록이다.
> 최신 상태는 `ROADMAP_WBS.md`와 CI 로그를 기준으로 판단한다.
## 📊 최종 완성 현황
## 📊 과거 기록 현황
### ⚠️ 과거 기준 기록
| 단계 | 항목 | 상태 |
|------|------|------|
| W0 | 프로젝트 기반 구축 | ✅ 완료 |
| W1 | LLM 개발 지침 (CLAUDE.md) | ✅ 완료 |
| W2 | 도메인/인프라/서비스 레이어 | ✅ 완료 |
| W0 | 프로젝트 기반 구축 | 과거 기록 |
| W1 | LLM 개발 지침 (CLAUDE.md) | 과거 기록 |
| W2 | 도메인/인프라/서비스 레이어 | 과거 기록 |
| **W3** | **공개 홈페이지 (Razor Pages SSR)** | 과거 기록 |
| **W4** | **관리자 백오피스 (Blazor Server)** | 과거 기록 |
| **W5** | **스타일링 및 모바일 UX** | 과거 기록 |
@@ -31,7 +31,7 @@
### 관리자
- 🔐 **로그인**: http://178.104.200.7/taxbaik/admin/login
- 📊 **대시보드**: http://178.104.200.7/taxbaik/admin/dashboard
- 👤 **기본 계정**: admin / admin123
- 계정 정보는 문서에 기록하지 않고 Gitea Secrets 또는 서버 환경변수로만 관리한다.
---
@@ -61,9 +61,9 @@
## 📊 과거 데이터베이스 기록
### 초기 데이터
- **5개 카테고리**: 사업자세무, 부동산세금, 종합소득세, 부가가치세, 가족자산증여
- **5개 블로그 포스트**: 초기 콘텐츠 포함
- **1개 관리자 계정**: admin/admin123
- 5개 카테고리: 사업자세무, 부동산세금, 종합소득세, 부가가치세, 가족자산증여
- 5개 블로그 포스트: 초기 콘텐츠 포함
- 관리자 계정: 비밀번호는 문서화하지 않는다.
---
@@ -101,13 +101,13 @@ e7e01d0 마이그레이션 및 보안 수정
## ✨ 주요 특징
- SEO 최적화 (Server-Side Rendering)
- ✅ 무중단 배포 (Shadow Copy)
- 반응형 모바일 UI
- 한국어 완전 지원
- 자동 마이그레이션
- ✅ 안전한 인증 (쿠키 + 인증)
- ✅ 체계적인 레이어 구조
- SEO 항목 (Server-Side Rendering)
- 심링크 기반 배포
- 반응형 모바일 UI
- 한국어 UI
- 자동 마이그레이션
- 인증 항목
- 레이어 구조
- 기록용 요약일 뿐, 현재 완료 판정 기준은 아니다.
---
+60 -61
View File
@@ -8,10 +8,10 @@
## 📌 프로젝트 개요
### 비즈니스 목표
- 온라인 전문성 표현
- 블로그 SEO 유입
- 전국 고객 확보
### 비즈니스 목표 기록
- 온라인 전문성 표현
- 블로그 SEO 유입
- 전국 고객 확보
### 핵심 포지셔닝
> "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너"
@@ -95,24 +95,23 @@ TaxBaik.Admin/ 95 KB (Blazor Server)
## ✨ 주요 기능
### 공개 사이트
- SEO 최적화 블로그 (5개 카테고리)
- 온라인 상담 신청 폼
- 반응형 디자인 (모바일 375px+)
- 성능 최적화 (gzip, lazy load)
- SEO 블로그
- 온라인 상담 신청 폼
- 반응형 디자인
- 성능 최적화 항목
### 관리자 백오피스
- 대시보드 (KPI 카드)
- 블로그 CRUD
- 문의 관리 (상태 변경)
- 사이트 설정
- 대시보드
- 블로그 관리
- 문의 관리
- 사이트 설정
### 보안 & 성능
- SQL Injection 방지 (파라미터화 쿼리)
- ✅ CSRF 보호 ([ValidateAntiForgeryToken])
- ✅ Cookie 기반 인증 (8시간 세션)
- ✅ gzip 응답 압축
- ✅ 이미지 lazy load
- ✅ 폰트 preconnect
- SQL Injection 방지 항목
- 인증/인가 항목
- gzip 응답 압축
- 이미지 lazy load
- 폰트 preconnect
---
@@ -130,7 +129,7 @@ Gitea Actions 트리거
4. 심링크 스왑
5. systemctl restart
배포 완료 (무중단)
배포 기록 생성
```
### 자동 마이그레이션
@@ -143,7 +142,7 @@ schema_migrations 테이블 확인
미실행 마이그레이션 자동 실행
DB 준비 완료
DB 준비 기록 생성
```
---
@@ -164,32 +163,32 @@ DB 준비 완료
## 🎯 과거 수락 기준 기록
### 기술적 요구사항
- [x] ASP.NET Core 8 + C#11 기반
- [x] Dapper + PostgreSQL 사용
- [x] Razor Pages SSR (공개 사이트)
- [x] Blazor Server (관리자)
- [x] 계층화된 아키텍처 (Domain → Infrastructure → Application → Web/Admin)
- [x] 모든 UI 문자열 한국어
- ASP.NET Core 기반
- Dapper + PostgreSQL 사용
- Razor Pages SSR (공개 사이트)
- Blazor Server (관리자)
- 계층화된 아키텍처
- UI 문자열 한국어
### 기능 요구사항
- [x] 블로그 (5개 카테고리, SEO 최적화)
- [x] 온라인 문의 폼
- [x] 관리자 백오피스 (블로그 + 문의 관리)
- [x] 반응형 디자인
- [x] 성능 최적화
- 블로그
- 온라인 문의 폼
- 관리자 백오피스
- 반응형 디자인
- 성능 최적화
### 배포 요구사항
- [x] CI/CD 파이프라인 (Gitea Actions)
- [x] 자동 마이그레이션
- [x] 무중단 배포 (심링크 스왑)
- [x] systemd 서비스 파일
- [x] Nginx 리버스 프록시 설정
- CI/CD 파이프라인
- 자동 마이그레이션
- 심링크 배포
- systemd 서비스 파일
- Nginx 리버스 프록시 설정
### 문서 요구사항
- [x] CLAUDE.md (개발 지침)
- [x] DEPLOYMENT_GUIDE.md (배포 가이드)
- [x] README.md (프로젝트 개요)
- [x] 서버 설치 스크립트
- CLAUDE.md
- DEPLOYMENT_GUIDE.md
- README.md
- 서버 설치 스크립트
---
@@ -231,31 +230,31 @@ b300cd7 완성: 빌드 성공 및 최종 통합 (W0~W6 완료)
## 과거 체크리스트 기록
### 개발 완료
- [x] 코드 작성
- [x] 로컬 빌드 성공
- [x] Git 커밋/푸시
### 개발 기록
- 코드 작성 기록
- 로컬 빌드 기록
- Git 커밋/푸시 기록
### 검증 완료
- [x] 아키텍처 검
- [x] 코드 구조 검
- [x] 보안 검
- [x] 성능 검
- [x] SEO 검
### 검증 기록
- 아키텍처 검토 기록
- 코드 구조 검토 기록
- 보안 검토 기록
- 성능 검토 기록
- SEO 검토 기록
### 배포 준비
- [x] CI/CD 파이프라인
- [x] 자동 마이그레이션
- [x] 배포 스크립트
- [x] 배포 가이드
- [x] 모니터링 설정
- CI/CD 파이프라인
- 자동 마이그레이션
- 배포 스크립트
- 배포 가이드
- 모니터링 설정
### 문서 완성
- [x] README.md
- [x] CLAUDE.md
- [x] DEPLOYMENT_GUIDE.md
- [x] PRODUCTION_CHECKLIST.md
- [x] SERVER_SETUP.sh
### 문서 기록
- README.md
- CLAUDE.md
- DEPLOYMENT_GUIDE.md
- PRODUCTION_CHECKLIST.md
- SERVER_SETUP.sh
---
+6 -6
View File
@@ -98,12 +98,12 @@ Todo:
Todo:
- [x] README 테스트/배포 섹션 갱신
- [x] CLAUDE.md E2E 기준 갱신
- [ ] 오래된 최종 보고 문서의 허위 완료 표현 정정
- [x] 오래된 최종 보고 문서의 허위 완료 표현 정정
### 현재 검증 메모
- 로컬 빌드 성공
- 관리자 smoke 성공
- 공개 smoke 성공
- 블로그 상세 SEO는 원격 배포본 반영 대기
- 문의 제출 E2E는 원격 배포 반영 대기
- `dotnet build TaxBaik.sln` 성공
- 시크릿 없는 로컬 Playwright 전체 실행: 공개 smoke, blog SEO 통과 / 관리자 시나리오는 자격 증명 미설정으로 스킵
- 배포본 `version.txt`: `8f0cb69`
- 배포본 블로그 상세: HTTP 200
- CI Playwright 전체 통과는 최신 커밋 배포 후 재확인 필요
- 비밀번호 변경 E2E는 배포 환경 자격 증명 확인 대기
@@ -1,4 +1,5 @@
@page "/admin"
@attribute [Authorize]
@inject NavigationManager NavigationManager
@code {
@@ -1,4 +1,5 @@
@page "/admin/blog/create"
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@@ -1,4 +1,5 @@
@page "/admin/blog"
@attribute [Authorize]
@inject IApiClient ApiClient
@inject ISnackbar Snackbar
@@ -1,4 +1,5 @@
@page "/admin/dashboard"
@attribute [Authorize]
@using TaxBaik.Application.Services
@inject AdminDashboardService DashboardService
@@ -1,4 +1,5 @@
@page "/admin/inquiries/{InquiryId:int}"
@attribute [Authorize]
@using TaxBaik.Application.Services
@inject InquiryService InquiryService
@inject NavigationManager Navigation
@@ -1,4 +1,5 @@
@page "/admin/inquiries"
@attribute [Authorize]
@using TaxBaik.Domain.Interfaces
@inject IInquiryRepository InquiryRepository
@@ -1,4 +1,5 @@
@page "/admin/settings"
@attribute [Authorize]
@using System.ComponentModel.DataAnnotations
@using System.Collections.Generic
@using TaxBaik.Web.Services
@@ -0,0 +1,9 @@
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(Navigation.ToBaseRelativePath(Navigation.Uri));
Navigation.NavigateTo($"/taxbaik/admin/login?returnUrl={returnUrl}", replace: true);
}
}
+5 -1
View File
@@ -2,7 +2,11 @@
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)" />
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(TaxBaik.Web.Components.Admin.Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
+3 -15
View File
@@ -22,22 +22,10 @@ test.describe('admin authentication', () => {
await expect(page.locator('input[placeholder="사용자명"]')).toBeVisible();
await expect(page.locator('input[placeholder="비밀번호"]')).toBeVisible();
const token = await page.evaluate(async ({ baseUrl, username, password }) => {
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return null;
}
const body = await response.json();
return body?.token ?? null;
}, { baseUrl, username, password });
expect(token, 'login API should return a token').toBeTruthy();
await page.locator('input[placeholder="사용자명"]').fill(username);
await page.locator('input[placeholder="비밀번호"]').fill(password);
await page.getByRole('button', { name: '로그인' }).click();
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
await page.goto(`${baseUrl}/admin/dashboard`);
await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
await expect(page.locator('text=대시보드')).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible();
+4 -16
View File
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { getAdminToken, installAdminToken } from './helpers/admin-auth';
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const currentPassword = process.env.E2E_ADMIN_CURRENT_PASSWORD;
@@ -6,24 +7,11 @@ const newPassword = process.env.E2E_ADMIN_NEW_PASSWORD;
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
test.describe('admin password change', () => {
test('changes password through the real admin UI', async ({ page }) => {
test('changes password through the real admin UI', async ({ page, request }) => {
test.skip(!currentPassword || !newPassword, 'E2E_ADMIN_CURRENT_PASSWORD and E2E_ADMIN_NEW_PASSWORD are required.');
const token = await page.evaluate(async ({ baseUrl, username, password }) => {
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return null;
}
const body = await response.json();
return body?.token ?? null;
}, { baseUrl, username, password: currentPassword });
expect(token, 'login API should return a token').toBeTruthy();
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
const token = await getAdminToken(request, baseUrl, username, currentPassword);
await installAdminToken(page, token);
await page.goto(`${baseUrl}/admin/settings`);
await expect(page.getByRole('heading', { name: /사이트 설정|설정/ })).toBeVisible();
+4 -16
View File
@@ -1,11 +1,12 @@
import { expect, test } from '@playwright/test';
import { getAdminToken, installAdminToken } from './helpers/admin-auth';
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const password = process.env.E2E_ADMIN_PASSWORD;
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
test.describe('admin smoke', () => {
test('navigates the main admin menus without circuit errors', async ({ page }) => {
test('navigates the main admin menus without circuit errors', async ({ page, request }) => {
test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
const consoleErrors: string[] = [];
@@ -18,21 +19,8 @@ test.describe('admin smoke', () => {
consoleErrors.push(error.message);
});
const token = await page.evaluate(async ({ baseUrl, username, password }) => {
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return null;
}
const body = await response.json();
return body?.token ?? null;
}, { baseUrl, username, password });
expect(token, 'login API should return a token').toBeTruthy();
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
const token = await getAdminToken(request, baseUrl, username, password);
await installAdminToken(page, token);
const menuChecks = [
{ path: '/admin/dashboard', heading: /대시보드/ },
+3 -15
View File
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { getAdminToken, installAdminToken } from './helpers/admin-auth';
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const password = process.env.E2E_ADMIN_PASSWORD;
@@ -27,21 +28,8 @@ test.describe('contact submit', () => {
test.skip(!password, 'E2E_ADMIN_PASSWORD is required to verify the admin list.');
const token = await page.evaluate(async ({ baseUrl, username, password }) => {
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return null;
}
const body = await response.json();
return body?.token ?? null;
}, { baseUrl, username, password });
expect(token, 'login API should return a token').toBeTruthy();
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
const token = await getAdminToken(request, baseUrl, username, password);
await installAdminToken(page, token);
await page.goto(`${baseUrl}/admin/inquiries`);
await expect(page.getByText(name)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(phone)).toBeVisible();
+21
View File
@@ -0,0 +1,21 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
export async function getAdminToken(
request: APIRequestContext,
baseUrl: string,
username: string,
password: string,
) {
const response = await request.post(`${baseUrl}/api/auth/login`, {
data: { username, password },
});
expect(response.status(), 'login API should accept the configured admin credentials').toBe(200);
const body = await response.json();
expect(body?.token, 'login API should return a token').toBeTruthy();
return body.token as string;
}
export async function installAdminToken(page: Page, token: string) {
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
}
+3 -15
View File
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { getAdminToken, installAdminToken } from './helpers/admin-auth';
const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const password = process.env.E2E_ADMIN_PASSWORD;
@@ -27,21 +28,8 @@ test.describe('inquiry detail', () => {
test.skip(!password, 'E2E_ADMIN_PASSWORD is required to verify inquiry detail.');
const token = await page.evaluate(async ({ baseUrl, username, password }) => {
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return null;
}
const body = await response.json();
return body?.token ?? null;
}, { baseUrl, username, password });
expect(token, 'login API should return a token').toBeTruthy();
await page.addInitScript(value => localStorage.setItem('auth_token', value), token);
const token = await getAdminToken(request, baseUrl, username, password);
await installAdminToken(page, token);
await page.goto(`${baseUrl}/admin/inquiries`);
const row = page.getByRole('row').filter({ hasText: name }).first();
await expect(row).toBeVisible({ timeout: 20_000 });