diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 71672809..18bd0473 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -8,9 +8,36 @@ on: branches: - dev workflow_dispatch: + inputs: + run_lint: + description: "Run linters for Go and Flutter" + required: true + type: boolean + default: true + run_backend_tests: + description: "Run backend Go tests" + required: true + type: boolean + default: true + run_userfront_tests: + description: "Run userfront Flutter tests" + required: true + type: boolean + default: true + run_adminfront_tests: + description: "Run adminfront Playwright tests" + required: true + type: boolean + default: true + run_devfront_tests: + description: "Run devfront Playwright tests" + required: true + type: boolean + default: true jobs: lint: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }} runs-on: ubuntu-latest steps: - name: Checkout code @@ -54,7 +81,7 @@ jobs: backend-tests: needs: lint - if: ${{ always() }} + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true) }} runs-on: ubuntu-latest services: redis: @@ -83,12 +110,53 @@ jobs: - name: Run backend tests run: | + mkdir -p reports + set +e cd backend - go test -v ./... + go test -v ./... 2>&1 | tee ../reports/backend-test.log + test_exit_code=${PIPESTATUS[0]} + cd .. + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Backend Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`backend-tests\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Command" + echo "\`go test -v ./...\`" + echo + echo "## Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/backend-test.log + echo '```' + } > reports/backend-test-failure-report.md + fi + + exit "$test_exit_code" + + - name: Publish backend failure summary + if: ${{ failure() }} + run: | + if [ -f reports/backend-test-failure-report.md ]; then + cat reports/backend-test-failure-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload backend failure report artifact + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: backend-test-failure-report + path: | + reports/backend-test-failure-report.md + reports/backend-test.log + if-no-files-found: ignore userfront-tests: needs: lint - if: ${{ always() }} + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true) }} runs-on: ubuntu-latest steps: - name: Checkout code @@ -100,12 +168,271 @@ jobs: channel: "stable" cache: true + - name: Ensure browser for Flutter web tests + run: | + has_runnable_browser=false + for candidate in google-chrome google-chrome-stable chromium chromium-browser; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then + has_runnable_browser=true + break + fi + done + + if [ "$has_runnable_browser" = true ]; then + echo "Runnable Chrome/Chromium already installed." + exit 0 + fi + + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y chromium || sudo apt-get install -y chromium-browser + else + apt-get update + apt-get install -y chromium || apt-get install -y chromium-browser + fi + + for candidate in google-chrome google-chrome-stable chromium chromium-browser; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then + echo "Installed browser: $candidate" + exit 0 + fi + done + + echo "No runnable Chrome/Chromium found after install." + exit 1 + - name: Run userfront tests run: | cd userfront if [ -d test ]; then - flutter test - # flutter test --platform chrome test/locale_storage_platform_test.dart + CHROME_BIN="" + for candidate in google-chrome google-chrome-stable chromium chromium-browser; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then + CHROME_BIN="$(command -v "$candidate")" + break + fi + done + if [ -z "$CHROME_BIN" ]; then + echo "Chrome/Chromium not found for web tests." + exit 1 + fi + + echo "Using browser: $CHROME_BIN" + export CHROME_EXECUTABLE="$CHROME_BIN" + mkdir -p ../reports + set +e + flutter test 2>&1 | tee ../reports/userfront-test-vm.log + vm_test_exit_code=${PIPESTATUS[0]} + + web_test_exit_code=0 + if [ "$vm_test_exit_code" -eq 0 ]; then + flutter test --platform chrome test/locale_storage_platform_test.dart 2>&1 | tee ../reports/userfront-test-web.log + web_test_exit_code=${PIPESTATUS[0]} + fi + set -e + + if [ "$vm_test_exit_code" -ne 0 ] || [ "$web_test_exit_code" -ne 0 ]; then + { + echo "# Userfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-tests\`" + echo "- Browser: \`$CHROME_BIN\`" + echo "- VM Test Exit Code: \`$vm_test_exit_code\`" + echo "- Web Test Exit Code: \`$web_test_exit_code\`" + echo + echo "## Commands" + echo "1. \`flutter test\`" + echo "2. \`flutter test --platform chrome test/locale_storage_platform_test.dart\`" + echo + if [ -f ../reports/userfront-test-vm.log ]; then + echo "## VM Test Log Tail (last 200 lines)" + echo '```text' + tail -n 200 ../reports/userfront-test-vm.log + echo '```' + echo + fi + if [ -f ../reports/userfront-test-web.log ]; then + echo "## Web Test Log Tail (last 200 lines)" + echo '```text' + tail -n 200 ../reports/userfront-test-web.log + echo '```' + fi + } > ../reports/userfront-test-failure-report.md + exit 1 + fi else echo "No userfront tests: skipping (test/ directory not found)." fi + + - name: Publish userfront failure summary + if: ${{ failure() }} + run: | + if [ -f reports/userfront-test-failure-report.md ]; then + cat reports/userfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload userfront failure report artifact + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: userfront-test-failure-report + path: | + reports/userfront-test-failure-report.md + reports/userfront-test-vm.log + reports/userfront-test-web.log + if-no-files-found: ignore + + adminfront-tests: + needs: lint + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: adminfront/package-lock.json + + - name: Install adminfront dependencies + run: | + cd adminfront + npm ci + + - name: Provision browsers for adminfront tests + run: | + cd adminfront + npx playwright install --with-deps + + - name: Run adminfront tests + run: | + mkdir -p reports + set +e + cd adminfront + npm test 2>&1 | tee ../reports/adminfront-test.log + test_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Adminfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`adminfront-tests\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Commands" + echo "1. \`cd adminfront\`" + echo "2. \`npm ci\`" + echo "3. \`npx playwright install --with-deps\`" + echo "4. \`npm test\`" + echo + echo "## Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/adminfront-test.log + echo '```' + } > reports/adminfront-test-failure-report.md + fi + + exit "$test_exit_code" + + - name: Publish adminfront failure summary + if: ${{ failure() }} + run: | + if [ -f reports/adminfront-test-failure-report.md ]; then + cat reports/adminfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload adminfront failure report artifact + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: adminfront-test-failure-report + path: | + reports/adminfront-test-failure-report.md + reports/adminfront-test.log + adminfront/playwright-report + adminfront/test-results + if-no-files-found: ignore + + devfront-tests: + needs: lint + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: devfront/package-lock.json + + - name: Install devfront dependencies + run: | + cd devfront + npm ci + + - name: Provision browsers for devfront tests + run: | + cd devfront + npx playwright install --with-deps + + - name: Run devfront tests + run: | + mkdir -p reports + set +e + cd devfront + npm test 2>&1 | tee ../reports/devfront-test.log + test_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Devfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`devfront-tests\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Commands" + echo "1. \`cd devfront\`" + echo "2. \`npm ci\`" + echo "3. \`npx playwright install --with-deps\`" + echo "4. \`npm test\`" + echo + echo "## Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/devfront-test.log + echo '```' + } > reports/devfront-test-failure-report.md + fi + + exit "$test_exit_code" + + - name: Publish devfront failure summary + if: ${{ failure() }} + run: | + if [ -f reports/devfront-test-failure-report.md ]; then + cat reports/devfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload devfront failure report artifact + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: devfront-test-failure-report + path: | + reports/devfront-test-failure-report.md + reports/devfront-test.log + devfront/playwright-report + devfront/test-results + if-no-files-found: ignore diff --git a/README.md b/README.md index f0ed1c14..8d08715a 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,51 @@ KETO_WRITE_URL = "http://keto:4467" - **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다. - **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다. +## 🧪 Code Check CI +워크플로우 파일: `.gitea/workflows/code_check.yml` + +### 트리거 +- `push` (`dev` 브랜치) +- `pull_request` (`dev` 대상) +- `workflow_dispatch` (수동 실행) + +### workflow_dispatch 입력값 +- `run_lint`: Go/Flutter lint 실행 여부 +- `run_backend_tests`: backend 테스트 실행 여부 +- `run_userfront_tests`: userfront 테스트 실행 여부 +- `run_adminfront_tests`: adminfront 테스트 실행 여부 +- `run_devfront_tests`: devfront 테스트 실행 여부 + +### 실행 잡 +- `lint` +- `backend-tests` +- `userfront-tests` +- `adminfront-tests` +- `devfront-tests` + +### 프런트 테스트 브라우저 프리비저닝 정책 +- `userfront-tests` + - 실행 가능한 Chrome/Chromium을 탐지하고, 없으면 설치 후 테스트 실행 + - `CHROME_EXECUTABLE` 지정 후 웹 테스트를 실행 +- `adminfront-tests`, `devfront-tests` + - Playwright 기반 테스트 + - `npx playwright install --with-deps`로 브라우저/OS 의존성을 사전 설치 + +### 실패 보고서 확인 방법 +테스트가 실패하면 다음이 자동 생성됩니다. +- Job Summary: 실패 원인 요약(Markdown) 즉시 확인 +- Artifact: 상세 로그/리포트 다운로드 + - `backend-test-failure-report` + - `userfront-test-failure-report` + - `adminfront-test-failure-report` + - `devfront-test-failure-report` + +### userfront의 `skipped` 테스트 안내 +- `locale_storage_platform_test.dart`는 웹 전용 테스트(`kIsWeb`)를 포함합니다. +- 따라서 일반 `flutter test`(VM)에서는 일부 테스트가 `skipped` 되는 것이 정상입니다. +- 실제 웹 실행 검증은 별도 단계에서 수행합니다. + - `flutter test --platform chrome test/locale_storage_platform_test.dart` + ### 로컬 개발 (Manual) Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능. 백그라운드로 infra 및 ory stack이 구동중이라는 가정 diff --git a/docs/organization-chart-policy.md b/docs/organization-chart-policy.md new file mode 100644 index 00000000..d3fa77bf --- /dev/null +++ b/docs/organization-chart-policy.md @@ -0,0 +1,88 @@ +# Organization Chart Architecture & Implementation Policy (ADR) + +## 1. Overview (개요) +본 문서는 Baron SSO 내 `adminfront`에서 사용될 **조직도(Organization Chart) 및 다중 테넌시(Multi-Tenancy) 대응 기능**에 대한 아키텍처 결정 사항(Architecture Decision Record)과 세부 구현 방향을 정의합니다. + +이 정책은 기존 B2B 테넌트 모델(`Tenant`)과 사내 사용자 그룹 모델(`UserGroup`), 그리고 Ory Keto 기반의 권한 제어(ReBAC) 시스템 간의 일관성을 유지하면서, 복잡하고 다양한 형태의 고객사별 조직 구조(N-Depth)를 지원하기 위해 작성되었습니다. + +--- + +## 2. Core Architectural Decisions (핵심 아키텍처 결정) + +### 2.1 B2B Tenant vs. Internal UserGroup Hierarchy (테넌트 vs. 유저그룹 계층화) +조직의 계층(Hierarchy)을 표현하기 위해 `Tenant` 자체를 중첩(Nested)시킬 것인지, 아니면 단일 `Tenant` 내의 `UserGroup`을 중첩시킬 것인지에 대한 결정입니다. + +* **Decision (결정):** 조직도는 **`UserGroup` 내부의 자기 참조(`parent_id`)를 통해 계층화**합니다. +* **Rationale (이유):** + * **관심사 분리 (Separation of Concerns):** `Tenant` 모델은 결제, 도메인 매핑, B2B 고객사(Company) 격리 등 무거운 비즈니스 로직을 담고 있습니다. "개발팀", "인사부"와 같은 단순한 사내 조직 단위까지 `Tenant` 테이블에 저장하면 시스템 복잡도가 기하급수적으로 증가합니다. + * **조회 성능 (Performance):** 특정 고객사(Company)의 전체 조직도를 그릴 때 `SELECT * FROM user_groups WHERE tenant_id = ?` 단일 쿼리로 모든 노드를 가져와 애플리케이션 메모리에서 트리를 구성할 수 있어 성능상 매우 유리합니다. + * **단일 진실 공급원 (SoT):** 회사(Company) 단위의 물리적 격리는 `Tenant`가, 논리적인 사내 부서/팀 구조는 `UserGroup`이 담당하도록 역할을 명확히 분리합니다. + +### 2.2 Flexible N-Depth Organizational Structure (유연한 N-Depth 조직 구조) +고객사마다 조직 단계(부, 국, 실, 본부, 파트, 반, 셀 등)의 명칭과 깊이(Depth)가 다릅니다. 이를 하드코딩된 Enum으로 제한해서는 안 됩니다. + +* **Decision (결정):** 조직의 단계나 명칭을 시스템(DB 스키마)에서 강제하지 않으며, **N-Depth 인접 목록(Adjacency List) 모델을 사용**합니다. +* **Implementation (구현):** + * `UserGroup` 모델에 `parent_id` (UUID, Nullable) 컬럼을 추가하여 부모-자식 관계를 형성합니다. + * 조직 타입(`unit_type`) 필드는 고정된 Enum(예: `TEAM`, `GROUP`) 대신, 고객사가 자유롭게 입력할 수 있는 **동적 문자열(`String`)**로 관리하거나, 계층의 상대적 깊이(Depth)만을 의미 단위로 사용합니다. + * 프론트엔드의 Checkbox Tree 컴포넌트는 재귀적(Recursive)으로 설계되어 데이터의 깊이에 상관없이 무한한 N-Depth를 렌더링할 수 있어야 합니다. + +--- + +## 3. Data Structure & Schema Updates (데이터 구조 및 스키마 업데이트) + +새로운 테이블을 추가하는 대신, 기존 모델을 확장하여 중복을 방지합니다. + +### 3.1 `user_groups` 테이블 확장 +조직 계층 및 부서 단위 표현을 위해 필드를 추가합니다. +* `id`, `tenant_id`, `name`, `description` (기존 유지) +* **`parent_id` (UUID, Nullable FK):** 상위 `UserGroup` 참조 (조직 트리 구성). +* **`unit_type` (String, Optional):** 조직 단위 명칭 (예: "본부", "실", "팀"). 시스템이 강제하지 않으며 프론트엔드 라벨링 용도로 사용됩니다. + +### 3.2 `users` 테이블 확장 (직급 및 직무) +CSV에서 업로드되는 사용자의 인사 정보(직급, 직무 등)는 `User` 모델에 직접 저장합니다. +* **`position` (String):** 직급 (예: "수석", "책임", "사원"). +* **`job_title` (String):** 직무 (예: "프론트엔드 개발", "기획"). +* *(또는 기존에 존재하는 `Metadata` (JSONB) 필드를 활용하여 스키마 변경 없이 동적 속성으로 관리할 수도 있습니다.)* + +--- + +## 4. ReBAC Integration Policy (Ory Keto 연동 정책) + +DB의 `user_groups` 계층 트리는 Ory Keto의 관계 튜플(Tuple)과 동기화되어 권한 제어에 사용됩니다. (기존 통합 권한 정책 `tenant-usergroup-policy.md` 준수) + +1. **조직 계층 동기화 (Hierarchy):** + * DB에서 A팀(`UserGroup`)이 B본부(`UserGroup`)의 하위로 설정되면, Keto에는 `UserGroup:#parent@UserGroup:` 튜플이 생성됩니다. +2. **소속원 매핑 (Membership):** + * 유저가 A팀에 속하면 `UserGroup:#members@User:<유저_ID>` 튜플이 생성됩니다. +3. **조직장 및 어드민 승격 (Leadership):** + * CSV 데이터 분석 또는 수동 지정을 통해 특정 유저가 A팀의 '조직장'으로 식별되면, `UserGroup:#owners@User:<유저_ID>` 튜플이 생성됩니다. + * 정책에 따라 `owners` 관계를 가진 유저는 해당 조직(UserGroup)과 그 하위 조직에 대한 `admins` 권한을 자동으로 상속받습니다. + +--- + +## 5. Data Loading & CSV Upload Strategy (데이터 로딩 및 CSV 업로드 전략) + +고정된 컬럼 구조는 다양한 회사의 조직도를 수용할 수 없으므로 유연한 파싱 로직이 필요합니다. + +### 5.1 Flexible CSV Format (유연한 CSV 포맷) +* **경로 기반 방식 (Path-based):** 조직 계층을 슬래시(`/`) 등으로 구분하여 하나의 문자열로 전달받습니다. + * *예시 컬럼:* `[조직_경로, 직급, 이름, 직무, 이메일]` + * *데이터 예시:* `"개발본부/클라우드실/플랫폼팀", "수석", "홍길동", "백엔드 개발", "hong@example.com"` +* **동적 뎁스 방식 (Dynamic Depth):** 뒤에서부터 고정된 사용자 속성 열(직급, 직무, 이름, 이메일 등)을 식별하고, 그 앞의 모든 열을 동적인 계층 구조로 해석합니다. + +### 5.2 Processing Flow (처리 흐름) +1. **Parsing & Validation:** 프론트엔드/백엔드에서 유연한 CSV 포맷을 파싱하고, `UserGroup` 계층 경로를 분석합니다. +2. **Tree Resolution:** 백엔드는 "개발본부 > 클라우드실 > 플랫폼팀" 경로를 DB에서 조회하거나 없으면 순차적으로 생성(`parent_id` 매핑)하여 `UserGroup` ID 트리를 완성합니다. +3. **User Upsert:** `User` 정보를 생성하거나 업데이트(`position`, `job_title` 갱신)합니다. +4. **Keto Synchronization:** DB 트랜잭션 완료 후, Background Worker가 변경된 조직 계층과 멤버십 정보를 기반으로 Ory Keto 튜플을 생성/삭제(Reconciliation)합니다. + +--- + +## 6. Frontend Multi-Tenancy UI (프론트엔드 다중 테넌트 UI) + +관리자가 여러 테넌트(Company)에 접근 권한이 있을 경우, 조직도를 명확히 구분하여 보여주어야 합니다. + +* **Tabs Interface:** 화면 상단 또는 측면에 사용자가 접근 가능한 최상위 `Tenant` 목록을 탭(Tabs) 형태로 제공합니다. +* **Scoped Fetching:** 특정 탭(Tenant)을 선택할 때마다 해당 `tenant_id`를 파라미터로 백엔드 API를 호출하여, 격리된 해당 회사만의 `UserGroup` 트리를 렌더링합니다. +* **Checkbox Tree Component:** Radix UI와 TailwindCSS를 기반으로 개발되며, N-Depth 중첩을 지원하고 부모-자식 간의 반선택(Indeterminate) 상태를 재귀적으로 계산하는 독립적인(Reusable) 컴포넌트로 구현됩니다. \ No newline at end of file diff --git a/docs/tenant-usergroup-policy.md b/docs/tenant-usergroup-policy.md index fb31b518..345b0b2d 100644 --- a/docs/tenant-usergroup-policy.md +++ b/docs/tenant-usergroup-policy.md @@ -1,68 +1,154 @@ -# 유저 그룹 및 테넌트 통합 권한 정책 (Integrated Policy) +# 통합 테넌트 및 권한 아키텍처 정책 (Integrated Tenant & ReBAC Policy) -이 문서는 Baron SSO의 테넌트(Tenant)와 유저 그룹(User Group) 간의 관계 및 권한 상속에 관한 공식 정책을 정의합니다. +이 문서는 Baron SSO 시스템 내 B2C(개인)부터 B2B(단일기업), B2B2B(그룹사) 및 사내 조직도(`UserGroup`)에 이르는 모든 격리 공간과 권한 상속을 다루는 **다형성 테넌트(Polymorphic Tenant)**의 공식 정책을 정의합니다. + +이 정책은 기존의 단순 `Tenant` 및 `UserGroup` 모델을 발전시켜, Ory Kratos(Identity), PostgreSQL(Business), Ory Keto(ReBAC) 3개의 분산 시스템 간 역할을 명확히 하고 데이터 정합성을 강제합니다. + +--- ## 1. 기본 원칙 (Core Axioms) -### 1.1 유저 그룹의 테넌트성 (User Group as a Tenant) -- **모든 테넌트가 유저 그룹은 아니지만, 모든 유저 그룹은 반드시 테넌트의 속성을 가집니다.** -- 유저 그룹은 "사용자들의 집합"인 동시에, 그 자체가 권한을 담고 다른 자원을 소유할 수 있는 **격리된 공간(Tenant)**으로 취급됩니다. +### 1.1 "모든 격리 공간은 테넌트이다" (Everything is a Tenant) +- 개인 워크스페이스, 기업 고객, 지주사, 그리고 사내의 특정 팀이나 본부(`UserGroup`) 등 **모든 종류의 격리 공간은 최상위 범용 단위인 `Tenant`로 취급**됩니다. +- 과거에 혼용되던 `UserGroup` 네임스페이스는 폐기되며, 격리 공간을 나타내는 Keto(ReBAC) 네임스페이스는 `Tenant` 하나로 단일화됩니다. 권한 상속 로직은 테넌트의 성격과 무관하게 동일하게 동작합니다. -### 1.2 권한 상속 로직의 단일화 (Unified Inheritance) -- 테넌트 간의 상속(Parent-Child Tenant)과 유저 그룹의 권한 전파(Group-Member)는 **기술적으로 동일한 ReBAC 로직**을 사용합니다. -- `UserGroup:members` 관계는 `Tenant:members`와 동일한 우선순위를 가지며, 시스템은 이를 구분 없이 하나의 상속 트리로 처리합니다. +### 1.2 "권한 주체와 자원의 분리" (Namespaces) +시스템의 완벽한 권한 통제를 위해, Keto에는 `Tenant` 외에도 다음과 같은 필수 네임스페이스가 존재합니다. +- **`Tenant`:** 격리된 공간 (회사, 부서, 개인) +- **`RelyingParty`:** 테넌트가 소유하는 자원/앱 (OIDC 클라이언트) +- **`System`:** 테넌트에 종속되지 않는 전역 권한 (Super Admin 등) -### 1.3 그룹장-어드민 연동 (Leader-Admin Mapping) -- 특정 유저 그룹에 명시적으로 **'그룹장(Group Leader)'**을 지정하면, 시스템은 해당 사용자를 해당 유저 그룹(테넌트)의 **'테넌트 어드민(Tenant Admin)'**으로 자동 격상합니다. -- 그룹장은 해당 그룹이 소유한 모든 하위 테넌트 및 리소스에 대해 완전한 제어권을 가집니다. +### 1.3 소유(Ownership)와 가시성(Visibility)의 분리 +- 시스템의 모든 자원(예: RelyingParty, 앱)은 반드시 특정 `Tenant`가 소유(`manage`)합니다. +- 그러나 자원의 소유권과 **누가 접근할 수 있는가(가시성, `access`)는 별개**입니다. 내부망용 앱(Private)과 대국민 서비스(Public)를 동일한 기업(Tenant)이 동시에 소유하고 제어할 수 있습니다. -## 2. 권한 흐름도 (Mermaid) +### 1.4 단일 진실 공급원 분리 (Separation of SoT) +- **Kratos (Identity):** "누구인가?" (인증, 이메일, 패스워드 등 순수 식별 정보). 테넌트, 직급 등 관계형 데이터는 절대 보관하지 않습니다. +- **PostgreSQL (Business):** "어디에 속하며 조직 구조는 어떠한가?" (직급, 조직도, 테넌트 설정 등). +- **Keto (ReBAC):** "무엇을 할 수 있는가?" (권한 및 상속). + +--- + +## 2. 하이브리드(다형성) 테넌트 아키텍처 + +데이터베이스의 `tenants` 테이블은 가장 가벼운 신분증(Identity) 역할을 수행하며, 세부 비즈니스 설정은 타입별로 별도의 테이블에 1:1 조인(Join)하여 관리합니다. + +### 2.1 테넌트 유형 (Tenant Types) +| 타입 (Enum) | 설명 | 특징 및 1:1 조인 테이블 | +| :--- | :--- | :--- | +| `PERSONAL` | B2C 개인 워크스페이스 | 일반 사용자 가입 시 1:1로 생성. 조직도(UserGroup) 기능 비활성화. | +| `COMPANY` | B2B 일반 기업/법인 | 독립된 비즈니스. 사내 조직도를 가짐. 무거운 설정은 `company_settings`에 저장. | +| `COMPANY_GROUP`| B2B2B 지주사/그룹사 | 여러 `COMPANY`를 하위로 거느리며 최고 관리자 권한을 통합. `company_settings` 조인. | +| `USER_GROUP` | 사내 조직 (본부/팀 등) | `COMPANY` 내부에 속하는 조직. 사내 조직도 메타데이터는 `user_groups` 테이블에 저장. | + +--- + +## 3. ReBAC 권한 상속 및 관계 튜플 (Keto Tuples) + +이전에는 `UserGroup` 네임스페이스가 존재했으나, 이제 모든 권한 검증은 오직 `Tenant` 네임스페이스 내에서 처리됩니다. + +### 3.1 조직장(Leader)과 어드민(Admin) 상속 +특정 부서(테넌트)의 그룹장(`owners`)으로 임명되면, 해당 부서 및 그 하위 부서의 최고 관리자(`admins`) 권한을 자동으로 상속받습니다. +- **조직장 임명:** `Tenant:<조직ID>#owners@User:<유저ID>` +- **자동 상속 룰:** `Tenant:<조직ID>#admins@Tenant:<조직ID>#owners` + +### 3.2 계층 간 권한 상속 (Hierarchy) +지주사(COMPANY_GROUP) $\rightarrow$ 법인(COMPANY) $\rightarrow$ 사내조직(USER_GROUP) 간의 상속은 모두 `parents` 튜플을 사용합니다. +- **계층 설정:** `Tenant:<하위ID>#parents@Tenant:<상위ID>` +- **자동 상속 룰:** 상위 테넌트의 `admins`는 하위 테넌트의 `manage` 및 `view` 권한을 모두 가집니다. + +### 3.3 리소스 제어 및 RP Admin (Relying Party) +RP(앱)는 별도의 가상 테넌트를 만들지 않고 자원(Object) 자체의 다중 상속을 통해 권한을 제어합니다. + +- **앱 관리 (Manage):** + - `RelyingParty:<앱ID>#parents@Tenant:<소유테넌트ID>` (앱 소유권 지정. 해당 테넌트의 최고 관리자가 앱을 관리할 수 있음) + - `RelyingParty:<앱ID>#admins@User:<유저ID>` (특정 유저를 **RP Admin**으로 직접 지정) +- **Private 앱 접근 (Access):** `RelyingParty:<앱ID>#access@Tenant:<소유테넌트ID>#members` (소유한 회사의 멤버만 접근) +- **Public 앱 접근 (Access):** `RelyingParty:<앱ID>#access@System:authenticated_users#members` (전역 인증 유저 누구나 접근) + +--- + +## 4. 권한 흐름도 (Mermaid) ```mermaid graph TD - %% Roles - Leader[Group Leader / 그룹장] - Member[Group Member / 멤버] - - %% Entities (Polymorphic) - subgraph UG_T [User Group / Specialized Tenant] - UG_ID[Group: Hanmac 운영팀] + %% Types + subgraph COMPANY_GROUP [지주사 테넌트] + CG[Tenant: 한맥 그룹] end - subgraph Child_T [Child Tenants / 하위 테넌트] - T1[Tenant: 한맥 엔지니어링] - T2[Tenant: 한맥 IT] + subgraph COMPANY [법인 테넌트] + C1[Tenant: 한맥 IT] end - %% Policy Links - Leader -- "Explicitly Assigned" --> UG_ID - Leader -. "Automatically Becomes" .-> Admin[Tenant Admin] - - Member -- "is member of" --> UG_ID - - %% Inheritance (Identical Logic) - UG_ID -- "Inherits Access To" --> T1 - UG_ID -- "Inherits Access To" --> T2 + subgraph USER_GROUP [사내조직 테넌트] + UG1[Tenant: 개발본부] + UG2[Tenant: 클라우드팀] + end - %% Effective Access - Admin -- "Full Control" --> UG_ID - Member -- "Shared Access" --> T1 - Member -- "Shared Access" --> T2 + subgraph USERS [사용자] + CEO[User: 그룹 최고경영자] + DEV_L[User: 팀장] + DEV[User: 팀원] + RP_ADM[User: RP 전담 관리자] + end + + subgraph RESOURCES [자원] + RP1[RelyingParty: 사내 인트라넷] + end + + %% Hierarchy Tuples + C1 -- "parents" --> CG + UG1 -- "parents" --> C1 + UG2 -- "parents" --> UG1 + RP1 -- "parents" --> C1 + + %% Memberships + CEO -- "owners" --> CG + DEV_L -- "owners" --> UG2 + DEV -- "members" --> UG2 + + %% RP Admin + RP_ADM -- "admins" --> RP1 + + %% Effective Admin Control (Inherited) + CEO -. "Inherits Admin Control" .-> C1 + CEO -. "Inherits Admin Control" .-> UG1 + CEO -. "Inherits Admin Control" .-> UG2 + CEO -. "Inherits Manage" .-> RP1 + DEV_L -. "Inherits Admin Control" .-> UG2 %% Styles - style UG_ID fill:#f9f,stroke:#333,stroke-width:2px - style Leader fill:#ff9,stroke:#333 - style Admin fill:#ffd,stroke:#333,stroke-dasharray: 5 5 + style CG fill:#dfd,stroke:#333 + style C1 fill:#dfd,stroke:#333 + style UG1 fill:#f9f,stroke:#333 + style UG2 fill:#f9f,stroke:#333 + style CEO fill:#ff9,stroke:#333 + style DEV_L fill:#ff9,stroke:#333 + style RP_ADM fill:#ff9,stroke:#333 + style RP1 fill:#bbf,stroke:#333 ``` -## 3. 기술적 구현 가이드 (Implementation) +--- -### 3.1 Keto Relationship Tuples -- **그룹장 임명:** `UserGroup:#owners@User:` -- **어드민 자동 승격:** `Tenant:#admins@UserGroup:#owners` (그룹 소유자는 해당 테넌트의 어드민) -- **멤버십:** `UserGroup:#members@User:` +## 5. 분산 시스템 정합성 강제 정책 (Data Consistency Check) -### 3.2 기대 효과 -- **정책 단순화:** '어드민'과 '그룹장'을 별도로 관리할 필요가 없어 시스템 복잡도가 감소합니다. -- **책임 명확화:** 그룹의 장이 해당 자원의 최종 책임자가 되는 직관적인 거버넌스를 수립합니다. -- **일관된 UX:** 사용자는 자신이 관리하는 것이 '테넌트'인지 '그룹'인지 고민할 필요 없이 동일한 관리 도구를 사용합니다. +인증(Kratos), 비즈니스 로직(PG), 권한(Keto)이 어긋나지 않도록 다음의 안전 장치를 반드시 구현해야 합니다. + +### 5.1 트랜잭셔널 아웃박스 패턴 (Transactional Outbox) +- **목적:** 백엔드 DB에는 저장되었으나, 일시적인 네트워크 장애로 인해 Keto(ReBAC)에 권한이 저장되지 않는 '부분 실패(Partial Failure)' 방지. +- **방식:** DB에 새로운 테넌트(`UserGroup` 등) 생성 시, 동일 DB 트랜잭션 내에서 `keto_outbox` 테이블에 튜플 이벤트를 함께 저장합니다. 이후 Background Worker가 이를 안전하게 Keto로 동기화(재시도 보장)합니다. + +### 5.2 삭제 정책 연동 (Delete Cascade) +- **보안 원칙 (즉시 회수):** 백엔드에서 테넌트나 유저를 Soft Delete(`deleted_at`) 처리하는 즉시, Outbox를 통해 **Keto의 관련된 모든 튜플을 Hard Delete**하여 권한을 즉각적으로 회수합니다. +- **Kratos 계정 삭제:** 사용자가 계정을 영구 탈퇴할 경우, Kratos에서 데이터가 지워지기 직전에 백엔드로 Webhook을 전송하여 Keto 권한 및 로컬 `users` 테이블의 프로필 데이터를 먼저 비우도록 연동합니다. + +### 5.3 Kratos-Backend 실시간 동기화 (Webhooks) +- Kratos에서의 회원 가입(`after_registration`) 및 정보 변경(`after_settings`) 발생 시, 즉시 Webhook을 발생시켜 Backend의 `users` 테이블(프로필 로컬 캐시)을 Upsert 합니다. + +### 5.4 정기 대사 스크립트 (Reconciliation Cron Job) +- 매일 1회 이상 배치 작업을 돌려 분산 시스템 간 엣지 케이스 오류를 복구합니다. +- **검증 항목:** + 1. Keto에 남아있는 고아 튜플(Orphaned Tuples) 검출 및 자동 삭제. + 2. DB의 `user_groups` 멤버십에는 존재하나 Keto 튜플에서 누락된 권한 색출 및 복원. + 3. Kratos와 Backend 간 유저 ID 매핑 상태 일치 여부 스캔. \ No newline at end of file diff --git a/docs/test-plan.md b/docs/test-plan.md index f9546356..6b1599a9 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -5,128 +5,38 @@ - 멀티 서비스(Backend/Ory Stack/Front) 연동 품질 확보 - 릴리즈 기준과 장애 분석 기준의 표준화 -## 2) 범위 -### 포함 -- Backend (Go Fiber) -- UserFront (Flutter Web/App) -- AdminFront / DevFront (React) -- Ory Stack (Kratos/Hydra/Keto/Oathkeeper) -- Gateway/네트워크 구성 (baron_net, ory-net, public_net) -- DB (PostgreSQL, ClickHouse, Redis) +## 2) 현재 기준 스냅샷 +- 기준 시각: 2026-02-19 16:32:09 KST +- 기준 커밋: `85998bd` +- Backend 전체 statement coverage: **24.7%** +- 테스트 인벤토리 수량: + - Backend(Go): **104개** + - UserFront(Flutter): **47개** + - AdminFront/DevFront(Playwright): **4개** -### 제외(별도 계획) -- 외부 IDP 벤더의 장애 대응 (Descope 등) -- 프로덕션 데이터 복구 시나리오(백업/DR) +### Backend 패키지별 커버리지 +- `cmd/server`: 2.6% +- `internal/handler`: 25.0% +- `internal/idp`: 16.6% +- `internal/middleware`: 59.6% +- `internal/response`: 91.7% +- `internal/service`: 32.7% +- `internal/utils`: 24.5% +- `internal/validator`: 89.3% -## 3) 원칙 -- **Shift-left**: 개발 단계에서 최대한 조기 검증 -- **단계적 신뢰**: Unit → Integration → E2E 순으로 신뢰도 상승 -- **환경 분리**: 로컬/스테이징/프로덕션 구성 차이를 문서로 명시 -- **결정적 테스트**: 시간/랜덤/외부 의존성 최소화 -- **Idempotent**: 반복 실행 시 동일 결과 보장 -- **보안 우선**: 민감정보(PII/Token)는 테스트 로그에 노출 금지 -- **실패 우선 기록**: 실패 로그/재현 절차를 우선 확보 +## 3) 하위 문서 (전수 목록) +- Backend 테스트 전수 목록: `docs/test-plan/backend-test-inventory.md` +- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md` +- AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-test-inventory.md` -## 4) 테스트 레이어 및 목표 -### 4.1 Unit Test -- Backend: 비즈니스 로직, 유효성 검증, Mapper/Adapter -- Frontend: 유틸/상태관리/컴포넌트 로직 -- 목표: 빠른 피드백(수초~수분) +## 4) 실행 커맨드 +- Backend 전체 테스트: `cd backend && go test ./...` +- Backend 커버리지: `cd backend && go test ./... -coverprofile=coverage.out && go tool cover -func=coverage.out` +- UserFront 테스트: `cd userfront && flutter test` +- AdminFront E2E: `cd adminfront && npm test` +- DevFront E2E: `cd devfront && npm test` -### 4.2 Integration Test -- Backend + DB(Postgres/ClickHouse/Redis) -- Backend + Ory Admin API (Kratos/Hydra/Keto) -- 목표: 네트워크/스토리지 연동 검증 - -### 4.3 Contract Test -- Backend ↔ Frontend API 스키마/응답 계약 검증 -- OIDC/OpenID Connect 표준 응답 형식 검증 - -### 4.4 E2E Test (Happy/Edge Path) -- 로그인 플로우(Password / Magic Link / SMS / QR) -- Consent 플로우 (Hydra login/consent) -- 토큰 발급/재발급/로그아웃/세션 만료 -- 목표: 핵심 사용자 여정의 회귀 방지 - -### 4.5 Smoke Test -- 배포 직후 필수 엔드포인트 헬스체크 -- `GET /health`, Ory readiness, UserFront 정적 리소스 - -### 4.6 Regression / Non-functional -- 성능: 로그인/토큰 발급 지연, 대량 감사 로그 적재 -- 보안: 인증 우회, 권한 상승, 세션 고정 공격 -- 관측성: 핵심 로그/메트릭 누락 여부 - -## 5) 환경 전략 -- 로컬: `make up-all` 또는 `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d` -- 스테이징: 프로덕션과 동일한 네트워크/도메인 구성 -- 프로덕션: 최소한의 smoke/관측성 점검 - -## 6) 테스트 데이터 정책 -- 표준 시드 사용자/테넌트/클라이언트 세트 정의 -- PII 마스킹 규칙(이메일/전화번호/토큰) -- 재현용 고정 데이터와 랜덤 데이터 분리 -- 테스트 종료 후 클린업 규칙 정의 - -## 7) 자동화 및 CI/CD 기준 (현행) -- **현재 상태**: 레포에 CI/CD 워크플로우 정의가 없음. 테스트는 로컬/수동 실행 기준으로 운영. -- **CI 변수 활용**: AdminFront/DevFront Playwright 설정은 `CI` 환경 변수에 따라 재시도/워커 수를 조정함. -- **수동 실행 기준**: - - Backend: `go test ./...` (위치: `backend/`) - - UserFront: `flutter test` (위치: `userfront/`) - - AdminFront: `npm test` (Playwright, 위치: `adminfront/`, baseURL `http://localhost:5173`) - - DevFront: `npm test` (Playwright, 위치: `devfront/`, baseURL `http://localhost:5174`) - -### 7.1 수동 게이트 제안(현행 기준) -- PR/머지 전 최소 기준: Backend Unit + 해당 Front 테스트(변경 범위) -- 배포 전 최소 기준: Smoke + 핵심 E2E(로그인/Consent) - -## 8) 핵심 플로우 테스트 시나리오 -### 인증/세션 -- Password 로그인 성공/실패/락/재시도 -- Magic Link 발송/검증/만료 -- SMS 코드 발송/검증/재시도 제한 -- QR 승인/거절/타임아웃 -- 로그아웃 시 세션/쿠키/토큰 무효화 - -### 원격 링크 로그인(verify-only) -- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Poll로 세션 발급 -- Mobile 단말에 세션 생성/로그인이 발생하지 않는지 확인 -- Audit/로그인 이력에 Desktop 세션 ID만 기록되는지 확인 -- 인증수단 표기(SMS/Email)가 요청 수단과 일치하는지 확인 -- 코드/링크 만료 시 승인 실패 및 재요청 안내 - -### OIDC/Hydra -- Login Challenge 처리 -- Consent 승인/거절 -- Token/Refresh Token 발급 -- Redirect URI 검증 - -### 권한/정책(Keto) -- 권한 부여/회수 시 접근 제어 확인 -- 관리자/일반 사용자 분리 - -### 네트워크/프록시 -- `baron_net`와 `ory-net` 경계 준수 -- Frontend에서 Ory 내부 Admin 포트 접근 불가 - -## 9) 관측성/장애 대응 테스트 -- 에러 로그 구조(필수 필드 포함) 확인 -- Audit Log 누락/중복 체크 -- 실패 시 재시도 정책 검증 - -## 10) 책임 및 운영 프로세스 -- 각 영역별 오너 지정(Backend/Front/Ory) -- 실패 시 triage 기준: 재현 가능 여부 → 영향도 → 우선순위 -- 테스트 케이스/기대 결과는 이슈/PR에 링크 - -## 11) 유지보수 원칙 -- 신규 기능은 반드시 관련 테스트 추가 -- 회귀 버그 발생 시 재현 테스트를 우선 추가 -- 불안정 테스트는 원인 분석 후 격리 또는 개선 - -## 12) 체크리스트 (릴리즈 전) -- Smoke 통과 -- 핵심 E2E 통과 -- 보안 관련 회귀 없음 -- 장애/모니터링 대시보드 정상 +## 5) 유지 원칙 +- 신규 기능은 관련 테스트를 반드시 추가합니다. +- 회귀 이슈는 재현 테스트를 먼저 추가하고 수정합니다. +- 본 문서의 수치/목록은 `origin/dev` 기준으로 주기적으로 갱신합니다. diff --git a/docs/test-plan/backend-test-inventory.md b/docs/test-plan/backend-test-inventory.md new file mode 100644 index 00000000..6daa3b97 --- /dev/null +++ b/docs/test-plan/backend-test-inventory.md @@ -0,0 +1,111 @@ +# Backend 테스트 전수 목록 + +- 범위: `backend/cmd/server`, `backend/internal/**` +- 기준: `func Test...` 패턴으로 수집한 단위/통합 테스트 + +| 파일 | 테스트 | 역할 | +|---|---|---| +| `backend/cmd/server/error_handler_test.go:23` | `TestNewErrorHandler_ProductionMasksServerError` | 오류/예외/거부 경로 검증 | +| `backend/cmd/server/error_handler_test.go:48` | `TestNewErrorHandler_ProductionPassesClientError` | 오류/예외/거부 경로 검증 | +| `backend/cmd/server/error_handler_test.go:73` | `TestNewErrorHandler_DevelopmentReturnsOriginalServerError` | 오류/예외/거부 경로 검증 | +| `backend/cmd/server/error_handler_test.go:98` | `TestNewErrorHandler_MapsUnauthorizedCode` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/api_key_handler_test.go:19` | `TestApiKeyHandler_CreateApiKey` | 핵심 CRUD/서비스 동작 검증 | +| `backend/internal/handler/api_key_handler_test.go:41` | `TestApiKeyHandler_Validation` | 유효성/정책/유틸 검증 | +| `backend/internal/handler/auth_handler_async_test.go:198` | `TestSignup_AsyncDB_Isolation` | 복구/격리/회복 탄력성 검증 | +| `backend/internal/handler/auth_handler_client_test.go:16` | `TestRevokeLinkedRp_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_client_test.go:56` | `TestListRpHistory_Aggregation` | Hydra/RP 연동 검증 | +| `backend/internal/handler/auth_handler_consent_test.go:134` | `TestAcceptConsentRequest_Normal` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_consent_test.go:26` | `TestGetConsentRequest_Normal` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_consent_test.go:71` | `TestGetConsentRequest_Skip_AutoAccept` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_link_test.go:24` | `TestEnchantedLinkFlow_Email_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_link_test.go:94` | `TestEnchantedLinkFlow_Sms_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_linked_test.go:26` | `TestListLinkedRps_PriorityAndAggregation` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_login_test.go:114` | `TestPasswordLogin_OIDC_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_login_test.go:201` | `TestPasswordLogin_OIDC_InactiveClient` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_login_test.go:255` | `TestPasswordLogin_NoOIDC_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_oidc_test.go:106` | `TestAcceptOidcLoginRequest_TokenFallbackToCookie` | 복구/격리/회복 탄력성 검증 | +| `backend/internal/handler/auth_handler_oidc_test.go:21` | `TestAcceptOidcLoginRequest_CookieOnly` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_otp_test.go:14` | `TestHandleKratosCourierRelay_Email` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_otp_test.go:43` | `TestVerifySignupCode_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_otp_test.go:85` | `TestVerifySignupCode_Invalid` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_qr_test.go:107` | `TestScanQRLogin_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_qr_test.go:150` | `TestResolveConsentSubjects_TokenAndCookie` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_qr_test.go:57` | `TestQRLoginFlow_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/auth_handler_test.go:20` | `TestCompletePasswordReset_MissingLoginID` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_test.go:50` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/auth_handler_test.go:80` | `TestCompletePasswordReset_NilIDPProvider` | 인증/OIDC 플로우 검증 | +| `backend/internal/handler/dev_handler_test.go:103` | `TestCreateClient_Success` | Hydra/RP 연동 검증 | +| `backend/internal/handler/dev_handler_test.go:15` | `TestListClients_Success` | Hydra/RP 연동 검증 | +| `backend/internal/handler/dev_handler_test.go:49` | `TestGetClient_Success` | Hydra/RP 연동 검증 | +| `backend/internal/handler/dev_handler_test.go:83` | `TestGetClient_NotFound` | 오류/예외/거부 경로 검증 | +| `backend/internal/handler/password_policy_test.go:57` | `TestGeneratePasswordUsesNonAlphanumericRequirement` | 유효성/정책/유틸 검증 | +| `backend/internal/handler/tenant_handler_test.go:101` | `TestTenantHandler_ApproveTenant` | 핵심 CRUD/서비스 동작 검증 | +| `backend/internal/handler/tenant_handler_test.go:73` | `TestTenantHandler_CreateTenant` | 핵심 CRUD/서비스 동작 검증 | +| `backend/internal/handler/user_group_handler_test.go:109` | `TestUserGroupHandler_AddMember` | 권한/관계 모델 검증 | +| `backend/internal/handler/user_group_handler_test.go:128` | `TestUserGroupHandler_AssignRole` | 권한/관계 모델 검증 | +| `backend/internal/handler/user_group_handler_test.go:71` | `TestUserGroupHandler_List` | 권한/관계 모델 검증 | +| `backend/internal/handler/user_group_handler_test.go:91` | `TestUserGroupHandler_Create` | 권한/관계 모델 검증 | +| `backend/internal/idp/factory_test.go:123` | `TestChainedProviderMetadataUnion` | 회귀 방지 기본 동작 검증 | +| `backend/internal/idp/factory_test.go:139` | `TestChainedProviderUpdateUserPasswordFallback` | 복구/격리/회복 탄력성 검증 | +| `backend/internal/idp/factory_test.go:152` | `TestChainedProviderUpdateUserPasswordAllFail` | 인증/OIDC 플로우 검증 | +| `backend/internal/middleware/audit_middleware_test.go:42` | `TestAuditMiddleware` | 회귀 방지 기본 동작 검증 | +| `backend/internal/middleware/error_code_enricher_test.go:22` | `TestErrorCodeEnricher_AddsCodeToLegacyErrorResponse` | 오류/예외/거부 경로 검증 | +| `backend/internal/middleware/error_code_enricher_test.go:50` | `TestErrorCodeEnricher_DoesNotOverrideExistingCode` | 오류/예외/거부 경로 검증 | +| `backend/internal/middleware/error_code_enricher_test.go:72` | `TestErrorCodeEnricher_IgnoreSuccessPayload` | 오류/예외/거부 경로 검증 | +| `backend/internal/middleware/rbac_test.go:115` | `TestRequireKetoPermission_Success` | 권한/관계 모델 검증 | +| `backend/internal/middleware/rbac_test.go:138` | `TestRequireTenantMatch_SuperAdmin` | 권한/관계 모델 검증 | +| `backend/internal/middleware/rbac_test.go:160` | `TestRequireTenantMatch_Forbidden` | 오류/예외/거부 경로 검증 | +| `backend/internal/middleware/rbac_test.go:184` | `TestRequireRole_Unauthorized` | 오류/예외/거부 경로 검증 | +| `backend/internal/middleware/rbac_test.go:69` | `TestRequireRole_Success` | 권한/관계 모델 검증 | +| `backend/internal/middleware/rbac_test.go:92` | `TestRequireRole_Forbidden` | 오류/예외/거부 경로 검증 | +| `backend/internal/response/error_response_test.go:22` | `TestErrorWithDetailsResponseShape` | 오류/예외/거부 경로 검증 | +| `backend/internal/response/error_response_test.go:61` | `TestStatusCodeMapping` | 회귀 방지 기본 동작 검증 | +| `backend/internal/service/hydra_admin_service_test.go:102` | `TestHydraAdminService_GetConsentRequest` | 인증/OIDC 플로우 검증 | +| `backend/internal/service/hydra_admin_service_test.go:124` | `TestHydraAdminService_PatchClientStatus` | Hydra/RP 연동 검증 | +| `backend/internal/service/hydra_admin_service_test.go:13` | `TestHydraAdminService_ListClients` | Hydra/RP 연동 검증 | +| `backend/internal/service/hydra_admin_service_test.go:148` | `TestHydraAdminService_UpdateClient` | Hydra/RP 연동 검증 | +| `backend/internal/service/hydra_admin_service_test.go:165` | `TestHydraAdminService_ListConsentSessions` | 인증/OIDC 플로우 검증 | +| `backend/internal/service/hydra_admin_service_test.go:183` | `TestHydraAdminService_RevokeConsentSessions` | 인증/OIDC 플로우 검증 | +| `backend/internal/service/hydra_admin_service_test.go:198` | `TestHydraAdminService_RejectConsentRequest` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/hydra_admin_service_test.go:215` | `TestHydraAdminService_RejectLoginRequest` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/hydra_admin_service_test.go:232` | `TestHydraAdminService_GetLoginRequest` | 인증/OIDC 플로우 검증 | +| `backend/internal/service/hydra_admin_service_test.go:249` | `TestHydraAdminService_AcceptConsentRequest` | 인증/OIDC 플로우 검증 | +| `backend/internal/service/hydra_admin_service_test.go:267` | `TestHydraAdminService_AcceptLoginRequest` | 인증/OIDC 플로우 검증 | +| `backend/internal/service/hydra_admin_service_test.go:294` | `TestHydraAdminService_ErrorHandling` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/hydra_admin_service_test.go:319` | `TestHydraAdminService_NotFound` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/hydra_admin_service_test.go:39` | `TestHydraAdminService_GetClient` | Hydra/RP 연동 검증 | +| `backend/internal/service/hydra_admin_service_test.go:60` | `TestHydraAdminService_CreateClient` | Hydra/RP 연동 검증 | +| `backend/internal/service/hydra_admin_service_test.go:86` | `TestHydraAdminService_DeleteClient` | Hydra/RP 연동 검증 | +| `backend/internal/service/keto_service_test.go:101` | `TestKetoService_ErrorHandling` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/keto_service_test.go:123` | `TestKetoService_CheckPermission_Forbidden` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/keto_service_test.go:12` | `TestKetoService_CheckPermission` | 권한/관계 모델 검증 | +| `backend/internal/service/keto_service_test.go:137` | `TestKetoService_CreateRelation_Retry` | 복구/격리/회복 탄력성 검증 | +| `backend/internal/service/keto_service_test.go:34` | `TestKetoService_CreateRelation` | 권한/관계 모델 검증 | +| `backend/internal/service/keto_service_test.go:58` | `TestKetoService_DeleteRelation` | 권한/관계 모델 검증 | +| `backend/internal/service/keto_service_test.go:79` | `TestKetoService_ListRelations` | 권한/관계 모델 검증 | +| `backend/internal/service/ory_service_test.go:125` | `TestFindIdentityID_QueryEncoding` | 회귀 방지 기본 동작 검증 | +| `backend/internal/service/ory_service_test.go:38` | `TestUpdateUserPassword_Success` | 인증/OIDC 플로우 검증 | +| `backend/internal/service/ory_service_test.go:78` | `TestUpdateUserPassword_NotFound` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/ory_service_test.go:98` | `TestUpdateUserPassword_ServerError` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/relying_party_service_test.go:133` | `TestRelyingPartyService_Create_HydraFail` | Hydra/RP 연동 검증 | +| `backend/internal/service/relying_party_service_test.go:152` | `TestRelyingPartyService_Create_KetoFail_Rollback` | 복구/격리/회복 탄력성 검증 | +| `backend/internal/service/relying_party_service_test.go:190` | `TestRelyingPartyService_Get_Success` | Hydra/RP 연동 검증 | +| `backend/internal/service/relying_party_service_test.go:221` | `TestRelyingPartyService_Update_Success` | Hydra/RP 연동 검증 | +| `backend/internal/service/relying_party_service_test.go:250` | `TestRelyingPartyService_Delete_Success` | Hydra/RP 연동 검증 | +| `backend/internal/service/relying_party_service_test.go:85` | `TestRelyingPartyService_Create_Success` | Hydra/RP 연동 검증 | +| `backend/internal/service/tenant_service_test.go:126` | `TestTenantService_RegisterTenant_AddsDomainsAsVerified` | 핵심 CRUD/서비스 동작 검증 | +| `backend/internal/service/tenant_service_test.go:156` | `TestTenantService_RequestRegistration_AddsDomainAsUnverified` | 핵심 CRUD/서비스 동작 검증 | +| `backend/internal/service/tenant_service_test.go:187` | `TestTenantService_RequestRegistration_RejectsDomainMismatch` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/tenant_service_test.go:208` | `TestTenantService_ApproveTenant_AssignsAdminRelationWhenUserExists` | 권한/관계 모델 검증 | +| `backend/internal/service/tenant_service_test.go:240` | `TestTenantService_ApproveTenant_DoesNotAssignWhenUserMissing` | 오류/예외/거부 경로 검증 | +| `backend/internal/service/user_group_service_test.go:103` | `TestUserGroupService_Create` | 권한/관계 모델 검증 | +| `backend/internal/service/user_group_service_test.go:124` | `TestUserGroupService_AddMember` | 권한/관계 모델 검증 | +| `backend/internal/service/user_group_service_test.go:138` | `TestUserGroupService_AssignRoleToTenant` | 권한/관계 모델 검증 | +| `backend/internal/service/user_group_service_test.go:154` | `TestUserGroupService_ListRoles` | 권한/관계 모델 검증 | +| `backend/internal/service/user_group_service_test.go:188` | `TestUserGroupService_Get_WithKratosFallback` | 복구/격리/회복 탄력성 검증 | +| `backend/internal/utils/masking_test.go:9` | `TestMaskSensitiveJSON` | 유효성/정책/유틸 검증 | +| `backend/internal/utils/slug_test.go:15` | `TestValidateSlug_ReservedKeywords` | 유효성/정책/유틸 검증 | +| `backend/internal/utils/slug_test.go:38` | `TestValidateSlug_LengthRules` | 유효성/정책/유틸 검증 | +| `backend/internal/utils/slug_test.go:60` | `TestValidateSlug_FormatRules` | 유효성/정책/유틸 검증 | +| `backend/internal/utils/slug_test.go:8` | `TestValidateSlug_Valid` | 유효성/정책/유틸 검증 | +| `backend/internal/validator/schema_validator_test.go:65` | `TestValidateIDPCompatibility` | 유효성/정책/유틸 검증 | diff --git a/docs/test-plan/userfront-test-inventory.md b/docs/test-plan/userfront-test-inventory.md new file mode 100644 index 00000000..76d27e9a --- /dev/null +++ b/docs/test-plan/userfront-test-inventory.md @@ -0,0 +1,54 @@ +# UserFront 테스트 전수 목록 + +- 범위: `userfront/test/*.dart` +- 기준: `test(...)`, `testWidgets(...)` 케이스 전수 + +| 파일 | 테스트 | 역할 | +|---|---|---| +| `userfront/test/dashboard_providers_test.dart` | `AuthTimelineNotifier는 다음 커서를 사용해 추가 로드한다` | 대시보드 타임라인 상태관리 검증 | +| `userfront/test/dashboard_providers_test.dart` | `AuthTimelineNotifier는 실패 시 오류 메시지를 보관한다` | 대시보드 타임라인 상태관리 검증 | +| `userfront/test/dashboard_providers_test.dart` | `AuthTimelineNotifier는 초기 페이지를 로드한다` | 대시보드 타임라인 상태관리 검증 | +| `userfront/test/dashboard_providers_test.dart` | `AuthTimelineNotifier는 커서가 없으면 추가 로드를 하지 않는다` | 대시보드 타임라인 상태관리 검증 | +| `userfront/test/error_screen_test.dart` | `개발환경은 원문 메시지를 노출한다` | 에러 노출 정책 검증 | +| `userfront/test/error_screen_test.dart` | `프로덕션은 ORY 코드를 bypass 처리한다` | 핵심 동작 회귀 방지 검증 | +| `userfront/test/error_screen_test.dart` | `프로덕션은 whitelist 메시지를 노출한다` | 에러 노출 정책 검증 | +| `userfront/test/error_screen_test.dart` | `프로덕션은 비허용 에러를 unknown_error로 처리한다` | 에러 노출 정책 검증 | +| `userfront/test/locale_registry_test.dart` | `extractSupportedLocaleCodesFromAssets excludes template and invalid` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/locale_registry_test.dart` | `fallback locale prefers en when available` | fallback/복구 경로 검증 | +| `userfront/test/locale_registry_test.dart` | `fallback locale uses first sorted code when en is absent` | fallback/복구 경로 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `legacy key에서 locale로 마이그레이션 (웹)` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `localStorage write/read (웹)` | 브라우저 스토리지 저장/복원 정책 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `localStorage 접근이 차단되면 sessionStorage로 fallback (웹)` | fallback/복구 경로 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `localStorage 접근이 차단되면 메모리 fallback (웹)` | fallback/복구 경로 검증 | +| `userfront/test/locale_utils_test.dart` | `buildLocalizedPath applies locale` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildLocalizedPath keeps unknown 2-letter prefix as path` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildLocalizedPath preserves fragment` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildLocalizedPath preserves query parameters` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildLocalizedPath preserves raw query order and duplicates` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildLocalizedPath preserves redirect_url parameter` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildLocalizedPath replaces existing locale` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildSigninRedirectPath keeps path without query` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildSigninRedirectPath preserves full raw query` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `buildSigninRedirectPath preserves redirect_url and redirect_uri` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `extractLocaleFromPath picks locale when present` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `extractLocaleFromPath returns null when missing` | fallback/복구 경로 검증 | +| `userfront/test/locale_utils_test.dart` | `normalizeLocaleCode falls back to default` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `normalizeLocaleCode handles supported locales` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `stripLocalePath keeps path without locale` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/locale_utils_test.dart` | `stripLocalePath removes locale segment` | i18n 로케일 해석/정규화 규칙 검증 | +| `userfront/test/login_challenge_resolver_test.dart` | `URI query가 비어 있으면 raw search에서 복구` | fallback/복구 경로 검증 | +| `userfront/test/login_challenge_resolver_test.dart` | `raw search도 비어 있으면 raw href에서 복구` | fallback/복구 경로 검증 | +| `userfront/test/login_challenge_resolver_test.dart` | `widget 값이 없으면 URI query에서 복구` | fallback/복구 경로 검증 | +| `userfront/test/login_challenge_resolver_test.dart` | `widget 값이 있으면 최우선으로 사용` | 핵심 동작 회귀 방지 검증 | +| `userfront/test/login_challenge_resolver_test.dart` | `값이 전부 없으면 missing` | fallback/복구 경로 검증 | +| `userfront/test/oidc_redirect_guard_test.dart` | `http/https 절대 URL만 허용` | 핵심 동작 회귀 방지 검증 | +| `userfront/test/oidc_redirect_guard_test.dart` | `빈 문자열과 파싱 실패를 차단` | 핵심 동작 회귀 방지 검증 | +| `userfront/test/password_login_flow_policy_test.dart` | `OIDC challenge가 없고 jwt가 있으면 로컬 로그인 완료로 진행한다` | 로그인 분기/라우팅 규칙 검증 | +| `userfront/test/password_login_flow_policy_test.dart` | `OIDC challenge가 있고 redirectTo가 없으면 accept를 시도한다` | 로그인 분기/라우팅 규칙 검증 | +| `userfront/test/password_login_flow_policy_test.dart` | `redirectTo/jwt 모두 없으면 invalid로 처리한다` | 로그인 분기/라우팅 규칙 검증 | +| `userfront/test/password_login_flow_policy_test.dart` | `redirectTo가 있으면 OIDC redirect를 우선한다` | 로그인 분기/라우팅 규칙 검증 | +| `userfront/test/router_redirect_widget_test.dart` | `/login: login_challenge와 redirect_uri를 전달` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/router_redirect_widget_test.dart` | `로그인 상태: profile 접근 시 signin으로 리다이렉트하지 않음` | 로그인 분기/라우팅 규칙 검증 | +| `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri/login_challenge가 signin으로 전달` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri가 없으면 redirect_url을 전달` | 리다이렉트/쿼리 보존 규칙 검증 | +| `userfront/test/widget_test.dart` | `BaronSSOApp builds` | 기본 앱 렌더링 스모크 검증 | diff --git a/docs/test-plan/web-e2e-test-inventory.md b/docs/test-plan/web-e2e-test-inventory.md new file mode 100644 index 00000000..7e771a51 --- /dev/null +++ b/docs/test-plan/web-e2e-test-inventory.md @@ -0,0 +1,11 @@ +# AdminFront/DevFront E2E 테스트 전수 목록 + +- 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts` +- 기준: Playwright `test(...)` 케이스 전수 + +| 파일 | 테스트 | 역할 | +|---|---|---| +| `adminfront/tests/example.spec.ts:3` | `has title` | 초기 페이지 렌더링 스모크 검증 | +| `adminfront/tests/user-management.spec.ts:26` | `user create and delete flow` | 관리자 사용자 생성/삭제 E2E 검증 | +| `devfront/tests/clients.spec.ts:3` | `clients page loads correctly` | DevFront 클라이언트 페이지 접근 스모크 검증 | +| `devfront/tests/example.spec.ts:3` | `has title` | 초기 페이지 렌더링 스모크 검증 | diff --git a/docs/trouble-shooting/issue-281-locale-storage-refactor-plan.md b/docs/trouble-shooting/issue-281-locale-storage-refactor-plan.md new file mode 100644 index 00000000..8ab92634 --- /dev/null +++ b/docs/trouble-shooting/issue-281-locale-storage-refactor-plan.md @@ -0,0 +1,92 @@ +# Issue #281: locale_storage 리팩터링 계획 + +## 목적 +- `locale_storage` 관련 로직에서 정책 지식(키, fallback, migration)을 한 곳으로 모아 변경 비용을 줄입니다. +- 테스트를 내부 구현 의존(`webStorage`)에서 파사드 의존(`LocaleStorage`) 중심으로 바꿔 회귀 위험을 낮춥니다. +- 테스트 강제 훅(`forceMemoryStorageForTests`, `forceSessionStorageForTests`)의 진입점을 단일화합니다. + +## 현재 문제 요약 +1. 저장 정책 지식이 여러 파일에 분산되어 있습니다. +2. 테스트가 구현 세부사항에 결합되어 정책 변경 시 함께 깨질 가능성이 큽니다. +3. 플랫폼별 훅 wiring이 반복되어 확장 시 누락 가능성이 있습니다. + +## 변경 범위 +- 대상 파일 + - `userfront/lib/core/i18n/locale_storage.dart` + - `userfront/lib/core/i18n/locale_storage_stub.dart` + - `userfront/lib/core/i18n/locale_storage_web.dart` + - `userfront/test/locale_storage_platform_test.dart` + - `userfront/test/helpers/web_storage.dart` + - `userfront/test/helpers/web_storage_stub.dart` + - `userfront/test/helpers/web_storage_web.dart` + +## 리팩터링 단계 +### 1) 저장 정책 공통화 +- 저장 키 상수(`locale`, legacy `baron_locale`)와 migration 로직을 공통 모듈로 이동합니다. +- fallback 순서(local -> session -> memory)를 공통 정책 함수로 추출합니다. +- `locale_storage_web.dart`는 정책 모듈 호출 위주로 단순화합니다. + +### 2) 테스트 결합도 축소 +- 테스트 assertion의 중심을 `LocaleStorage` API로 이동합니다. +- `webStorage` 직접 검증은 최소화하고, 필요한 경우 정책 모듈의 관찰 포인트를 제한적으로 제공합니다. + +### 3) 테스트 훅 단일 진입 +- `LocaleStorage` 파사드에서만 테스트 훅을 제어하도록 정리합니다. +- 플랫폼 구현은 훅 내부 세부 정책을 직접 노출하지 않도록 인터페이스를 정돈합니다. + +### 4) 회귀 테스트 보강 +- legacy key migration(`baron_locale -> locale`) 회귀 테스트를 명시적으로 유지합니다. +- storage access 실패/비가용 상황에서 fallback 순서를 검증하는 테스트를 추가합니다. + +## 완료 기준(DoD) +- 정책 변경 시 수정 포인트가 공통 모듈 중심으로 줄어듭니다. +- 기존 locale 저장/조회 동작과 migration 동작이 유지됩니다. +- 웹 테스트가 안정적으로 통과하며 fallback/migration 회귀 케이스가 포함됩니다. + +## 구현 시 주의사항 +- 외부에서 사용하는 public API 시그니처는 가능한 유지합니다. +- 테스트 편의를 위한 훅은 운영 코드 경로에 영향이 없도록 격리합니다. +- 리팩터링 중간 단계에서도 테스트가 통과하도록 작은 단위로 나눠 적용합니다. + +## 롤백 기준 +- locale 저장/복구가 실패하거나, 웹 환경에서 fallback 동작이 달라지는 경우 즉시 이전 커밋 단위로 되돌립니다. +- migration 동작이 깨지는 경우 해당 단계만 우선 revert하고 정책 모듈 분리부터 재진행합니다. + +## 구현 결과 (2026-02-20) +### 반영된 코드 변경 +- 공통 계약/디버그 상태 타입 추가 + - `userfront/lib/core/i18n/locale_storage_backend.dart` +- 저장 정책 상수/판단 로직 분리 + - `userfront/lib/core/i18n/locale_storage_policy.dart` +- 파사드 단일 테스트 진입점 정리 + - `userfront/lib/core/i18n/locale_storage.dart` + - `setTestModeForTests`, `clearForTests`, `seedLegacyForTests`, `debugStateForTests` 추가 + - 기존 `forceMemoryStorageForTests`, `forceSessionStorageForTests` 호환 유지 +- 플랫폼 구현 리팩터링 + - `userfront/lib/core/i18n/locale_storage_web.dart` + - `userfront/lib/core/i18n/locale_storage_stub.dart` + - fallback 순서(local -> session -> memory) 유지 + - legacy migration 시 legacy key(`baron_locale`)를 local/session/memory 전체에서 정리 +- 테스트 정리 + - `userfront/test/locale_storage_platform_test.dart`를 `LocaleStorage` API 중심 검증으로 전환 + - 삭제: `userfront/test/helpers/web_storage.dart` + - 삭제: `userfront/test/helpers/web_storage_stub.dart` + - 삭제: `userfront/test/helpers/web_storage_web.dart` + +### CI/워크플로우 반영 +- 파일: `.gitea/workflows/code_check.yml` +- `workflow_dispatch.inputs` 복원 + - `run_lint` + - `run_backend_tests` + - `run_userfront_tests` +- 각 job 실행 조건 복원 + - lint: `inputs.run_lint` + - backend-tests: `inputs.run_backend_tests` + - userfront-tests: `inputs.run_userfront_tests` +- userfront-tests에 웹 테스트 실행 유지 + - `flutter test --platform chrome test/locale_storage_platform_test.dart` + +### 검증 결과 +- `cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos` 통과 +- `cd userfront && flutter test` 통과 +- `cd userfront && CHROME_EXECUTABLE= flutter test --platform chrome test/locale_storage_platform_test.dart` 통과 diff --git a/userfront/lib/core/i18n/locale_storage.dart b/userfront/lib/core/i18n/locale_storage.dart index 596a30ae..2e98db30 100644 --- a/userfront/lib/core/i18n/locale_storage.dart +++ b/userfront/lib/core/i18n/locale_storage.dart @@ -1,11 +1,59 @@ +import 'package:flutter/foundation.dart'; + +import 'locale_storage_backend.dart'; import 'locale_storage_stub.dart' if (dart.library.js_interop) 'locale_storage_web.dart'; abstract class LocaleStorage { + static bool _forceMemory = false; + static bool _forceSession = false; + + static void _syncTestMode() { + if (_forceMemory) { + localeStorage.setTestMode(LocaleStorageTestMode.memoryOnly); + return; + } + if (_forceSession) { + localeStorage.setTestMode(LocaleStorageTestMode.sessionOnly); + return; + } + localeStorage.setTestMode(LocaleStorageTestMode.normal); + } + static String? read() => localeStorage.read(); static void write(String locale) => localeStorage.write(locale); - static void forceMemoryStorageForTests(bool value) => - localeStorage.forceMemoryStorageForTests(value); - static void forceSessionStorageForTests(bool value) => - localeStorage.forceSessionStorageForTests(value); + + @visibleForTesting + static void setTestModeForTests(LocaleStorageTestMode mode) { + _forceMemory = mode == LocaleStorageTestMode.memoryOnly; + _forceSession = mode == LocaleStorageTestMode.sessionOnly; + _syncTestMode(); + } + + @visibleForTesting + static void clearForTests() { + localeStorage.clearForTests(); + _forceMemory = false; + _forceSession = false; + } + + @visibleForTesting + static void seedLegacyForTests(String locale) { + localeStorage.seedLegacyForTests(locale); + } + + @visibleForTesting + static LocaleStorageDebugState debugStateForTests() { + return localeStorage.debugStateForTests(); + } + + static void forceMemoryStorageForTests(bool value) { + _forceMemory = value; + _syncTestMode(); + } + + static void forceSessionStorageForTests(bool value) { + _forceSession = value; + _syncTestMode(); + } } diff --git a/userfront/lib/core/i18n/locale_storage_backend.dart b/userfront/lib/core/i18n/locale_storage_backend.dart new file mode 100644 index 00000000..e02234d5 --- /dev/null +++ b/userfront/lib/core/i18n/locale_storage_backend.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; + +enum LocaleStorageTestMode { normal, sessionOnly, memoryOnly } + +@immutable +class LocaleStorageDebugState { + const LocaleStorageDebugState({ + required this.mode, + this.localCurrent, + this.localLegacy, + this.sessionCurrent, + this.sessionLegacy, + this.memoryCurrent, + this.memoryLegacy, + }); + + final LocaleStorageTestMode mode; + final String? localCurrent; + final String? localLegacy; + final String? sessionCurrent; + final String? sessionLegacy; + final String? memoryCurrent; + final String? memoryLegacy; +} + +abstract interface class LocaleStorageBackend { + String? read(); + + void write(String locale); + + void setTestMode(LocaleStorageTestMode mode); + + void clearForTests(); + + void seedLegacyForTests(String locale); + + LocaleStorageDebugState debugStateForTests(); +} diff --git a/userfront/lib/core/i18n/locale_storage_policy.dart b/userfront/lib/core/i18n/locale_storage_policy.dart new file mode 100644 index 00000000..e84ebca1 --- /dev/null +++ b/userfront/lib/core/i18n/locale_storage_policy.dart @@ -0,0 +1,13 @@ +class LocaleStoragePolicy { + static const currentKey = 'locale'; + static const legacyKey = 'baron_locale'; + + static bool hasValue(String? value) => value != null && value.isNotEmpty; + + static bool shouldMigrateLegacy({ + required String? current, + required String? legacy, + }) { + return !hasValue(current) && hasValue(legacy); + } +} diff --git a/userfront/lib/core/i18n/locale_storage_stub.dart b/userfront/lib/core/i18n/locale_storage_stub.dart index 7d69d372..1fb79030 100644 --- a/userfront/lib/core/i18n/locale_storage_stub.dart +++ b/userfront/lib/core/i18n/locale_storage_stub.dart @@ -1,19 +1,59 @@ -class LocaleStorageImpl { - String? _locale; +import 'locale_storage_backend.dart'; +import 'locale_storage_policy.dart'; - String? read() => _locale; +class LocaleStorageImpl implements LocaleStorageBackend { + final Map _memory = {}; + LocaleStorageTestMode _mode = LocaleStorageTestMode.normal; + @override + String? read() { + final current = _memory[LocaleStoragePolicy.currentKey]; + if (LocaleStoragePolicy.hasValue(current)) { + return current; + } + + final legacy = _memory[LocaleStoragePolicy.legacyKey]; + if (LocaleStoragePolicy.shouldMigrateLegacy( + current: current, + legacy: legacy, + )) { + _memory[LocaleStoragePolicy.currentKey] = legacy!; + _memory.remove(LocaleStoragePolicy.legacyKey); + return legacy; + } + + return null; + } + + @override void write(String locale) { - _locale = locale; + _memory[LocaleStoragePolicy.currentKey] = locale; } - void forceMemoryStorageForTests(bool value) { - // Stub + @override + void setTestMode(LocaleStorageTestMode mode) { + _mode = mode; } - void forceSessionStorageForTests(bool value) { - // Stub + @override + void clearForTests() { + _memory.clear(); + _mode = LocaleStorageTestMode.normal; + } + + @override + void seedLegacyForTests(String locale) { + _memory[LocaleStoragePolicy.legacyKey] = locale; + } + + @override + LocaleStorageDebugState debugStateForTests() { + return LocaleStorageDebugState( + mode: _mode, + memoryCurrent: _memory[LocaleStoragePolicy.currentKey], + memoryLegacy: _memory[LocaleStoragePolicy.legacyKey], + ); } } -final localeStorage = LocaleStorageImpl(); +final LocaleStorageBackend localeStorage = LocaleStorageImpl(); diff --git a/userfront/lib/core/i18n/locale_storage_web.dart b/userfront/lib/core/i18n/locale_storage_web.dart index 7c32ccff..05e436e9 100644 --- a/userfront/lib/core/i18n/locale_storage_web.dart +++ b/userfront/lib/core/i18n/locale_storage_web.dart @@ -1,120 +1,200 @@ // ignore_for_file: avoid_web_libraries_in_flutter import 'package:web/web.dart' as web; -import 'package:flutter/foundation.dart'; -class LocaleStorageImpl { - static const _key = 'locale'; - static const _legacyKey = 'baron_locale'; +import 'locale_storage_backend.dart'; +import 'locale_storage_policy.dart'; + +enum _StorageTarget { local, session, memory } + +class LocaleStorageImpl implements LocaleStorageBackend { static final Map _memory = {}; - static bool _forceMemory = false; - static bool _forceSession = false; + static LocaleStorageTestMode _mode = LocaleStorageTestMode.normal; - @visibleForTesting - void forceMemoryStorageForTests(bool value) { - _forceMemory = value; - if (!value) { - _memory.clear(); + List<_StorageTarget> _fallbackTargets() { + switch (_mode) { + case LocaleStorageTestMode.normal: + return [ + _StorageTarget.local, + _StorageTarget.session, + _StorageTarget.memory, + ]; + case LocaleStorageTestMode.sessionOnly: + return [_StorageTarget.session, _StorageTarget.memory]; + case LocaleStorageTestMode.memoryOnly: + return [_StorageTarget.memory]; } } - @visibleForTesting - void forceSessionStorageForTests(bool value) { - _forceSession = value; + String? _safeReadLocal(String key) { + try { + return web.window.localStorage.getItem(key); + } catch (_) { + return null; + } } - String? _read(String key) { - if (!_forceMemory && !_forceSession) { - try { - return web.window.localStorage.getItem(key); - } catch (_) { - // localStorage 접근이 차단된 경우 sessionStorage로 fallback. - try { - return web.window.sessionStorage.getItem(key); - } catch (_) { - // sessionStorage도 차단된 경우 메모리 fallback 사용. - } - } + String? _safeReadSession(String key) { + try { + return web.window.sessionStorage.getItem(key); + } catch (_) { + return null; } - if (!_forceMemory) { - try { - return web.window.sessionStorage.getItem(key); - } catch (_) { - // sessionStorage도 차단된 경우 메모리 fallback 사용. - } - } - return _memory[key]; } - void _write(String key, String value) { - if (!_forceMemory && !_forceSession) { - try { - web.window.localStorage.setItem(key, value); - return; - } catch (_) { - // localStorage 접근이 차단된 경우 sessionStorage로 fallback. - try { - web.window.sessionStorage.setItem(key, value); - return; - } catch (_) { - // sessionStorage도 차단된 경우 메모리 fallback 사용. - } - } + bool _safeWriteLocal(String key, String value) { + try { + web.window.localStorage.setItem(key, value); + return true; + } catch (_) { + return false; } - if (!_forceMemory) { - try { - web.window.sessionStorage.setItem(key, value); - return; - } catch (_) { - // sessionStorage도 차단된 경우 메모리 fallback 사용. - } - } - _memory[key] = value; } - void _remove(String key) { - if (!_forceMemory && !_forceSession) { - try { - web.window.localStorage.removeItem(key); - return; - } catch (_) { - // localStorage 접근이 차단된 경우 sessionStorage로 fallback. - try { - web.window.sessionStorage.removeItem(key); - return; - } catch (_) { - // sessionStorage도 차단된 경우 메모리 fallback 사용. - } + bool _safeWriteSession(String key, String value) { + try { + web.window.sessionStorage.setItem(key, value); + return true; + } catch (_) { + return false; + } + } + + bool _safeRemoveLocal(String key) { + try { + web.window.localStorage.removeItem(key); + return true; + } catch (_) { + return false; + } + } + + bool _safeRemoveSession(String key) { + try { + web.window.sessionStorage.removeItem(key); + return true; + } catch (_) { + return false; + } + } + + void _safeClearLocal() { + try { + web.window.localStorage.clear(); + } catch (_) { + // 스토리지 접근이 차단된 경우는 테스트 정리에서 무시합니다. + } + } + + void _safeClearSession() { + try { + web.window.sessionStorage.clear(); + } catch (_) { + // 스토리지 접근이 차단된 경우는 테스트 정리에서 무시합니다. + } + } + + String? _readFromTarget(_StorageTarget target, String key) { + switch (target) { + case _StorageTarget.local: + return _safeReadLocal(key); + case _StorageTarget.session: + return _safeReadSession(key); + case _StorageTarget.memory: + return _memory[key]; + } + } + + bool _writeToTarget(_StorageTarget target, String key, String value) { + switch (target) { + case _StorageTarget.local: + return _safeWriteLocal(key, value); + case _StorageTarget.session: + return _safeWriteSession(key, value); + case _StorageTarget.memory: + _memory[key] = value; + return true; + } + } + + String? _readByKey(String key) { + for (final target in _fallbackTargets()) { + final value = _readFromTarget(target, key); + if (value != null) { + return value; } } - if (!_forceMemory) { - try { - web.window.sessionStorage.removeItem(key); + return null; + } + + void _writeByKey(String key, String value) { + for (final target in _fallbackTargets()) { + if (_writeToTarget(target, key, value)) { return; - } catch (_) { - // sessionStorage도 차단된 경우 메모리 fallback 사용. } } + } + + void _removeEverywhere(String key) { + _safeRemoveLocal(key); + _safeRemoveSession(key); _memory.remove(key); } + @override String? read() { - final current = _read(_key); - if (current != null && current.isNotEmpty) { + final current = _readByKey(LocaleStoragePolicy.currentKey); + if (LocaleStoragePolicy.hasValue(current)) { return current; } - final legacy = _read(_legacyKey); - if (legacy != null && legacy.isNotEmpty) { - _write(_key, legacy); - _remove(_legacyKey); + + final legacy = _readByKey(LocaleStoragePolicy.legacyKey); + if (LocaleStoragePolicy.shouldMigrateLegacy( + current: current, + legacy: legacy, + )) { + _writeByKey(LocaleStoragePolicy.currentKey, legacy!); + _removeEverywhere(LocaleStoragePolicy.legacyKey); return legacy; } return null; } + @override void write(String locale) { - _write(_key, locale); + _writeByKey(LocaleStoragePolicy.currentKey, locale); + } + + @override + void setTestMode(LocaleStorageTestMode mode) { + _mode = mode; + } + + @override + void clearForTests() { + _safeClearLocal(); + _safeClearSession(); + _memory.clear(); + _mode = LocaleStorageTestMode.normal; + } + + @override + void seedLegacyForTests(String locale) { + _writeByKey(LocaleStoragePolicy.legacyKey, locale); + } + + @override + LocaleStorageDebugState debugStateForTests() { + return LocaleStorageDebugState( + mode: _mode, + localCurrent: _safeReadLocal(LocaleStoragePolicy.currentKey), + localLegacy: _safeReadLocal(LocaleStoragePolicy.legacyKey), + sessionCurrent: _safeReadSession(LocaleStoragePolicy.currentKey), + sessionLegacy: _safeReadSession(LocaleStoragePolicy.legacyKey), + memoryCurrent: _memory[LocaleStoragePolicy.currentKey], + memoryLegacy: _memory[LocaleStoragePolicy.legacyKey], + ); } } -final localeStorage = LocaleStorageImpl(); +final LocaleStorageBackend localeStorage = LocaleStorageImpl(); diff --git a/userfront/test/helpers/web_storage.dart b/userfront/test/helpers/web_storage.dart deleted file mode 100644 index 6d2e3c99..00000000 --- a/userfront/test/helpers/web_storage.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'web_storage_stub.dart' - if (dart.library.html) 'web_storage_web.dart'; - -export 'web_storage_stub.dart' - if (dart.library.html) 'web_storage_web.dart'; diff --git a/userfront/test/helpers/web_storage_stub.dart b/userfront/test/helpers/web_storage_stub.dart deleted file mode 100644 index 99d1770e..00000000 --- a/userfront/test/helpers/web_storage_stub.dart +++ /dev/null @@ -1,21 +0,0 @@ -class WebStorage { - bool get isWeb => false; - - String? get(String key) => null; - - void set(String key, String value) {} - - String? getSession(String key) => null; - - void setSession(String key, String value) {} - - void removeSession(String key) {} - - void clearSession() {} - - void remove(String key) {} - - void clear() {} -} - -final webStorage = WebStorage(); diff --git a/userfront/test/helpers/web_storage_web.dart b/userfront/test/helpers/web_storage_web.dart deleted file mode 100644 index bcb4be4c..00000000 --- a/userfront/test/helpers/web_storage_web.dart +++ /dev/null @@ -1,37 +0,0 @@ -// ignore_for_file: avoid_web_libraries_in_flutter - -import 'package:web/web.dart' as web; - -class WebStorage { - bool get isWeb => true; - - String? get(String key) => web.window.localStorage.getItem(key); - - void set(String key, String value) { - web.window.localStorage.setItem(key, value); - } - - String? getSession(String key) => web.window.sessionStorage.getItem(key); - - void setSession(String key, String value) { - web.window.sessionStorage.setItem(key, value); - } - - void removeSession(String key) { - web.window.sessionStorage.removeItem(key); - } - - void clearSession() { - web.window.sessionStorage.clear(); - } - - void remove(String key) { - web.window.localStorage.removeItem(key); - } - - void clear() { - web.window.localStorage.clear(); - } -} - -final webStorage = WebStorage(); diff --git a/userfront/test/locale_storage_platform_test.dart b/userfront/test/locale_storage_platform_test.dart index a1156264..119439e2 100644 --- a/userfront/test/locale_storage_platform_test.dart +++ b/userfront/test/locale_storage_platform_test.dart @@ -1,87 +1,75 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:userfront/core/i18n/locale_storage.dart'; - -import 'helpers/web_storage.dart'; +import 'package:userfront/core/i18n/locale_storage_backend.dart'; void main() { setUp(() { - LocaleStorage.forceMemoryStorageForTests(false); - LocaleStorage.forceSessionStorageForTests(false); - if (webStorage.isWeb) { - webStorage.clear(); - webStorage.clearSession(); - } + LocaleStorage.setTestModeForTests(LocaleStorageTestMode.normal); + LocaleStorage.clearForTests(); }); tearDown(() { - LocaleStorage.forceMemoryStorageForTests(false); - LocaleStorage.forceSessionStorageForTests(false); - if (webStorage.isWeb) { - webStorage.clear(); - webStorage.clearSession(); - } + LocaleStorage.setTestModeForTests(LocaleStorageTestMode.normal); + LocaleStorage.clearForTests(); }); - test( - 'localStorage write/read (웹)', - () { - if (!webStorage.isWeb) { - return; - } + test('localStorage write/read (웹)', () { + if (!kIsWeb) { + return; + } - LocaleStorage.write('ko'); - expect(webStorage.get('locale'), 'ko'); - expect(LocaleStorage.read(), 'ko'); - }, - skip: !webStorage.isWeb, - ); + LocaleStorage.write('ko'); + expect(LocaleStorage.read(), 'ko'); - test( - 'legacy key에서 locale로 마이그레이션 (웹)', - () { - if (!webStorage.isWeb) { - return; - } + final state = LocaleStorage.debugStateForTests(); + expect(state.localCurrent, 'ko'); + expect(state.sessionCurrent, isNull); + expect(state.memoryCurrent, isNull); + }, skip: !kIsWeb); - webStorage.set('baron_locale', 'en'); - expect(LocaleStorage.read(), 'en'); - expect(webStorage.get('locale'), 'en'); - expect(webStorage.get('baron_locale'), isNull); - }, - skip: !webStorage.isWeb, - ); + test('legacy key에서 locale로 마이그레이션 (웹)', () { + if (!kIsWeb) { + return; + } - test( - 'localStorage 접근이 차단되면 메모리 fallback (웹)', - () { - if (!webStorage.isWeb) { - return; - } + LocaleStorage.seedLegacyForTests('en'); + expect(LocaleStorage.read(), 'en'); - LocaleStorage.forceMemoryStorageForTests(true); + final state = LocaleStorage.debugStateForTests(); + expect(state.localCurrent, 'en'); + expect(state.localLegacy, isNull); + }, skip: !kIsWeb); - LocaleStorage.write('en'); - expect(webStorage.get('locale'), isNull); - expect(webStorage.getSession('locale'), isNull); - expect(LocaleStorage.read(), 'en'); - }, - skip: !webStorage.isWeb, - ); + test('localStorage 접근이 차단되면 메모리 fallback (웹)', () { + if (!kIsWeb) { + return; + } - test( - 'localStorage 접근이 차단되면 sessionStorage로 fallback (웹)', - () { - if (!webStorage.isWeb) { - return; - } + LocaleStorage.forceMemoryStorageForTests(true); - LocaleStorage.forceSessionStorageForTests(true); + LocaleStorage.write('en'); + expect(LocaleStorage.read(), 'en'); - LocaleStorage.write('ko'); - expect(webStorage.get('locale'), isNull); - expect(webStorage.getSession('locale'), 'ko'); - expect(LocaleStorage.read(), 'ko'); - }, - skip: !webStorage.isWeb, - ); + final state = LocaleStorage.debugStateForTests(); + expect(state.localCurrent, isNull); + expect(state.sessionCurrent, isNull); + expect(state.memoryCurrent, 'en'); + }, skip: !kIsWeb); + + test('localStorage 접근이 차단되면 sessionStorage로 fallback (웹)', () { + if (!kIsWeb) { + return; + } + + LocaleStorage.forceSessionStorageForTests(true); + + LocaleStorage.write('ko'); + expect(LocaleStorage.read(), 'ko'); + + final state = LocaleStorage.debugStateForTests(); + expect(state.localCurrent, isNull); + expect(state.sessionCurrent, 'ko'); + expect(state.memoryCurrent, isNull); + }, skip: !kIsWeb); }