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