From 4ffe5110dd39892f129d81947edcff517f5f9c41 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Tue, 24 Feb 2026 15:23:36 +0900 Subject: [PATCH] =?UTF-8?q?e2e=20=EA=B5=AC=EC=A1=B0=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/code_check.yml | 229 ++++++++++++ Makefile | 23 +- README.md | 13 + adminfront/src/locales/ko.toml | 2 +- backend/internal/handler/api_key_handler.go | 22 +- backend/internal/handler/audit_handler.go | 24 +- backend/internal/handler/auth_handler.go | 342 +++++++++--------- .../handler/auth_handler_link_test.go | 23 ++ .../handler/auth_handler_login_code_test.go | 206 +++++++++++ .../auth_handler_profile_cache_test.go | 109 ++++++ .../internal/handler/auth_handler_qr_test.go | 1 + backend/internal/handler/auth_handler_test.go | 40 ++ backend/internal/handler/common_test.go | 8 + backend/internal/handler/dev_handler.go | 94 +++-- backend/internal/handler/error_helper.go | 17 + .../internal/handler/federation_handler.go | 34 +- .../internal/handler/relying_party_handler.go | 24 +- backend/internal/handler/tenant_handler.go | 86 ++--- .../internal/handler/user_group_handler.go | 28 +- backend/internal/handler/user_handler.go | 58 +-- backend/internal/middleware/error_helper.go | 17 + backend/internal/middleware/rbac.go | 24 +- docs/test-plan.md | 5 +- .../userfront-wasm-e2e-expansion-plan.md | 38 +- .../userfront-wasm-e2e-route-inventory.md | 59 +++ ...e-303-login-link-shortcode-locale-route.md | 47 +++ locales/en.toml | 18 + locales/ko.toml | 18 + locales/template.toml | 18 + userfront-e2e/.gitignore | 3 + userfront-e2e/README.md | 29 ++ userfront-e2e/package-lock.json | 111 ++++++ userfront-e2e/package.json | 18 + userfront-e2e/playwright.config.ts | 39 ++ .../scripts/serve-userfront-build.mjs | 68 ++++ userfront-e2e/tests/auth-routing.spec.ts | 143 ++++++++ .../tests/password-and-reset.spec.ts | 257 +++++++++++++ .../tests/profile-department.spec.ts | 275 ++++++++++++++ userfront-e2e/tests/route-inventory.spec.ts | 320 ++++++++++++++++ userfront-e2e/tsconfig.json | 14 + userfront/assets/translations/ko.toml | 6 +- .../auth/presentation/login_screen.dart | 9 + .../presentation/reset_password_screen.dart | 3 + .../presentation/dashboard_screen.dart | 60 ++- .../presentation/pages/profile_page.dart | 34 +- .../profile_notifier_persistence_test.dart | 112 ++++++ 46 files changed, 2735 insertions(+), 393 deletions(-) create mode 100644 backend/internal/handler/auth_handler_login_code_test.go create mode 100644 backend/internal/handler/auth_handler_profile_cache_test.go create mode 100644 backend/internal/handler/error_helper.go create mode 100644 backend/internal/middleware/error_helper.go create mode 100644 docs/test-plan/userfront-wasm-e2e-route-inventory.md create mode 100644 docs/trouble-shooting/issue-303-login-link-shortcode-locale-route.md create mode 100644 userfront-e2e/.gitignore create mode 100644 userfront-e2e/README.md create mode 100644 userfront-e2e/package-lock.json create mode 100644 userfront-e2e/package.json create mode 100644 userfront-e2e/playwright.config.ts create mode 100644 userfront-e2e/scripts/serve-userfront-build.mjs create mode 100644 userfront-e2e/tests/auth-routing.spec.ts create mode 100644 userfront-e2e/tests/password-and-reset.spec.ts create mode 100644 userfront-e2e/tests/profile-department.spec.ts create mode 100644 userfront-e2e/tests/route-inventory.spec.ts create mode 100644 userfront-e2e/tsconfig.json create mode 100644 userfront/test/profile_notifier_persistence_test.dart diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 3e802d76..afcbff43 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -24,6 +24,11 @@ on: required: true type: boolean default: true + run_userfront_e2e_tests: + description: "Run userfront WASM Playwright E2E tests" + required: true + type: boolean + default: true run_adminfront_tests: description: "Run adminfront Playwright tests" required: true @@ -96,6 +101,10 @@ jobs: working-directory: backend args: --enable-only=gofmt,gofumpt + - name: Sync userfront locales + run: | + /bin/sh ./scripts/sync_userfront_locales.sh + - name: Format Flutter userfront run: | cd userfront @@ -196,6 +205,10 @@ jobs: channel: "stable" cache: true + - name: Sync userfront locales + run: | + /bin/sh ./scripts/sync_userfront_locales.sh + - name: Run userfront tests run: | cd userfront @@ -271,6 +284,222 @@ jobs: reports/userfront-test.log if-no-files-found: ignore + userfront-e2e-tests: + needs: lint + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_e2e_tests == true) }} + runs-on: ubuntu-latest + timeout-minutes: 40 + 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: userfront-e2e/package-lock.json + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Sync userfront locales + run: | + /bin/sh ./scripts/sync_userfront_locales.sh + + - name: Install userfront-e2e dependencies + run: | + mkdir -p reports + set +e + cd userfront-e2e + npm ci 2>&1 | tee ../reports/userfront-e2e-install.log + install_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$install_exit_code" -ne 0 ]; then + { + echo "# Userfront E2E Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-e2e-tests\`" + echo "- Reason: \`Dependency install failed\`" + echo "- Exit Code: \`$install_exit_code\`" + echo + echo "## Command" + echo "\`cd userfront-e2e && npm ci\`" + echo + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-install.log + echo '```' + } > reports/userfront-e2e-test-failure-report.md + exit 1 + fi + + - name: Build userfront WASM + run: | + mkdir -p reports + set +e + cd userfront + flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log + build_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$build_exit_code" -ne 0 ]; then + { + echo "# Userfront E2E Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-e2e-tests\`" + echo "- Reason: \`WASM build failed\`" + echo "- Exit Code: \`$build_exit_code\`" + echo + echo "## Command" + echo "\`cd userfront && flutter build web --wasm --release\`" + echo + echo "## Build Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-build.log + echo '```' + } > reports/userfront-e2e-test-failure-report.md + exit 1 + fi + + - name: Provision browsers for userfront-e2e tests + run: | + set +e + cd userfront-e2e + npx playwright install --with-deps chromium 2>&1 | tee ../reports/userfront-e2e-provision.log + provision_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$provision_exit_code" -ne 0 ]; then + { + echo "# Userfront E2E Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-e2e-tests\`" + echo "- Reason: \`Browser provisioning failed\`" + echo "- Exit Code: \`$provision_exit_code\`" + echo + echo "## Command" + echo "\`cd userfront-e2e && npx playwright install --with-deps chromium\`" + echo + echo "## Provision Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-provision.log + echo '```' + } > reports/userfront-e2e-test-failure-report.md + exit 1 + fi + + - name: Run userfront-e2e tests + run: | + mkdir -p reports + set +e + cd userfront-e2e + npm test 2>&1 | tee ../reports/userfront-e2e-test.log + test_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$test_exit_code" -ne 0 ]; then + { + echo "# Userfront E2E Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-e2e-tests\`" + echo "- Exit Code: \`$test_exit_code\`" + echo + echo "## Commands" + echo "1. \`cd userfront-e2e\`" + echo "2. \`npm ci\`" + echo "3. \`cd ../userfront && flutter build web --wasm --release\`" + echo "4. \`cd ../userfront-e2e && npx playwright install --with-deps chromium\`" + echo "5. \`npm test\`" + echo + echo "## Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-test.log + echo '```' + } > reports/userfront-e2e-test-failure-report.md + fi + + exit "$test_exit_code" + + - name: Ensure userfront-e2e failure report exists + if: ${{ failure() }} + run: | + mkdir -p reports + if [ -f reports/userfront-e2e-test-failure-report.md ]; then + exit 0 + fi + + { + echo "# Userfront E2E Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-e2e-tests\`" + echo "- Reason: \`Job failed before detailed report generation\`" + echo + if [ -f reports/userfront-e2e-install.log ]; then + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-install.log + echo '```' + echo + fi + if [ -f reports/userfront-e2e-build.log ]; then + echo "## Build Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-build.log + echo '```' + echo + fi + if [ -f reports/userfront-e2e-provision.log ]; then + echo "## Provision Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-provision.log + echo '```' + echo + fi + if [ -f reports/userfront-e2e-test.log ]; then + echo "## Test Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-e2e-test.log + echo '```' + fi + } > reports/userfront-e2e-test-failure-report.md + + - name: Publish userfront-e2e failure summary + if: ${{ failure() }} + run: | + if [ -f reports/userfront-e2e-test-failure-report.md ]; then + cat reports/userfront-e2e-test-failure-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload userfront-e2e failure report artifact + if: ${{ failure() }} + uses: actions/upload-artifact@v3 + continue-on-error: true + with: + name: userfront-e2e-test-failure-report + path: | + reports/userfront-e2e-test-failure-report.md + reports/userfront-e2e-install.log + reports/userfront-e2e-build.log + reports/userfront-e2e-provision.log + reports/userfront-e2e-test.log + userfront-e2e/playwright-report + userfront-e2e/test-results + if-no-files-found: ignore + adminfront-tests: needs: lint if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} diff --git a/Makefile b/Makefile index cccb840b..bd75229a 100644 --- a/Makefile +++ b/Makefile @@ -107,9 +107,9 @@ logs-app: docker compose -f $(COMPOSE_APP) logs -f # --- 로컬 통합 코드 체크 --- -.PHONY: code-check code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests +.PHONY: code-check code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests -code-check: code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests +code-check: code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests @echo "code-check complete." code-check-i18n: @@ -182,3 +182,22 @@ code-check-devfront-tests: [ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \ [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ exit $$status + +code-check-userfront-e2e-tests: + @echo "==> userfront wasm playwright e2e tests" + @mkdir -p reports/userfront-e2e + @rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results + @status=0; \ + (cd userfront && flutter build web --wasm --release) || status=$$?; \ + if [ $$status -eq 0 ]; then \ + (cd userfront-e2e && npm ci) || status=$$?; \ + fi; \ + if [ $$status -eq 0 ]; then \ + (cd userfront-e2e && npx playwright install --with-deps chromium) || status=$$?; \ + fi; \ + if [ $$status -eq 0 ]; then \ + (cd userfront-e2e && npm test) || status=$$?; \ + fi; \ + [ -d userfront-e2e/playwright-report ] && cp -R userfront-e2e/playwright-report reports/userfront-e2e/ || true; \ + [ -d userfront-e2e/test-results ] && cp -R userfront-e2e/test-results reports/userfront-e2e/ || true; \ + exit $$status diff --git a/README.md b/README.md index 7bac9d2c..cbf08d14 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ KETO_WRITE_URL = "http://keto:4467" - `run_lint`: Go/Flutter lint 실행 여부 - `run_backend_tests`: backend 테스트 실행 여부 - `run_userfront_tests`: userfront 테스트 실행 여부 +- `run_userfront_e2e_tests`: userfront WASM Playwright E2E 실행 여부 - `run_adminfront_tests`: adminfront 테스트 실행 여부 - `run_devfront_tests`: devfront 테스트 실행 여부 @@ -327,6 +328,7 @@ KETO_WRITE_URL = "http://keto:4467" - `lint` - `backend-tests` - `userfront-tests` +- `userfront-e2e-tests` - `adminfront-tests` - `devfront-tests` @@ -353,6 +355,17 @@ KETO_WRITE_URL = "http://keto:4467" - 단일 파일만 확인하려면 다음 명령을 사용합니다. - `flutter test test/locale_storage_platform_test.dart` +### userfront WASM Playwright E2E +- 워크스페이스: `userfront-e2e/` +- 빌드+실행: + - `cd userfront-e2e && npm run test:wasm` +- 빌드 결과가 이미 있을 때: + - `cd userfront-e2e && npm test` +- Makefile 타깃: + - `make code-check-userfront-e2e-tests` +- 전수 인벤토리: + - `docs/test-plan/userfront-wasm-e2e-route-inventory.md` + ### 로컬 개발 (Manual) Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능. 백그라운드로 infra 및 ory stack이 구동중이라는 가정 diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 73958bf0..517a6120 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -329,7 +329,7 @@ empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." error = "연동 정보를 불러오지 못했습니다." [msg.userfront.dashboard.approved_session] -copy_click = "{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다." +copy_click = "{{label}}: {{id}} \\n클릭하면 복사됩니다." copy_tap = "{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다." none = "{{label}} 없음" diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index bb1d8afb..a8934159 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -35,7 +35,7 @@ type apiKeyListResponse struct { func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } limit := c.QueryInt("limit", 50) @@ -43,12 +43,12 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error { var total int64 if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } var keys []domain.ApiKey if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&keys).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } items := make([]apiKeySummary, 0, len(keys)) @@ -73,7 +73,7 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error { func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } var req struct { @@ -81,11 +81,11 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { Scopes []string `json:"scopes"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if strings.TrimSpace(req.Name) == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + return errorJSON(c, fiber.StatusBadRequest, "name is required") } // Generate Client ID (16 chars hex) @@ -96,7 +96,7 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash secret"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret") } apiKey := domain.ApiKey{ @@ -108,7 +108,7 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { } if err := h.DB.Create(&apiKey).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Return summary + PLAIN SECRET (only this time) @@ -129,16 +129,16 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } id := c.Params("id") if id == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "id is required") } if err := h.DB.Delete(&domain.ApiKey{}, "id = ?", id).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go index d246b352..be968edd 100644 --- a/backend/internal/handler/audit_handler.go +++ b/backend/internal/handler/audit_handler.go @@ -23,9 +23,7 @@ func NewAuditHandler(repo domain.AuditRepository) *AuditHandler { func (h *AuditHandler) CreateLog(c *fiber.Ctx) error { var req domain.AuditLog if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Cannot parse JSON", - }) + return errorJSON(c, fiber.StatusBadRequest, "Cannot parse JSON") } // Auto-fill metadata if missing @@ -43,16 +41,12 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error { } if h.repo == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ - "error": "Audit service unavailable", - }) + return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } if err := h.repo.Create(&req); err != nil { // Log internal error but don't expose details - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to save audit log", - }) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to save audit log") } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ @@ -66,22 +60,16 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error { cursorRaw := c.Query("cursor") cursor, err := parseAuditCursor(cursorRaw) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid cursor", - }) + return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor") } if h.repo == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ - "error": "Audit service unavailable", - }) + return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } logs, err := h.repo.FindPage(c.Context(), limit+1, cursor) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to retrieve audit logs", - }) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs") } nextCursor := "" diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 11b5ee11..3d40d242 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4,7 +4,6 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/repository" - "baron-sso-backend/internal/response" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "bytes" @@ -171,21 +170,21 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { var req domain.CheckEmailRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request") } // Email Format Validation if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") } if h.IdpProvider == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } exists, err := h.IdpProvider.UserExists(req.Email) if err != nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } if exists { return c.JSON(fiber.Map{"available": false, "message": "Email already registered"}) @@ -197,7 +196,7 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { var req domain.SendSignupCodeRequest if err := c.BodyParser(&req); err != nil || req.Target == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid email") } req.Type = "email" // Enforce type @@ -209,7 +208,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { // Check if block expired // Simple block implementation: if FailCount > 5, user is blocked until TTL expires // Since we refresh TTL on each update, we rely on Redis TTL. - return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts. Try again later."}) + return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts. Try again later.") } // 2. Generate Code @@ -237,7 +236,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { // 4. Send Email if h.EmailService == nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } subject := "[Baron 로그인] 회원가입 인증코드" @@ -259,7 +258,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { var req domain.SendSignupCodeRequest if err := c.BodyParser(&req); err != nil || req.Target == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid phone number"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid phone number") } req.Type = "phone" @@ -270,7 +269,7 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { // 1. Check existing state state, _ := h.getSignupState(key) if state != nil && state.FailCount > maxSignupFailures { - return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts. Try again later."}) + return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts. Try again later.") } // 2. Generate Code @@ -300,7 +299,7 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { var req domain.VerifySignupCodeRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request") } var key string @@ -310,12 +309,12 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { phone := strings.ReplaceAll(req.Target, "-", "") key = prefixSignupPhone + phone } else { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid type"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid type") } state, err := h.getSignupState(key) if err != nil || state == nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Verification session expired or not found"}) + return errorJSON(c, fiber.StatusBadRequest, "Verification session expired or not found") } // Check Verified @@ -325,19 +324,23 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { // Check Attempts if state.FailCount > maxSignupFailures { - return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts"}) + return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts") } // Check Code match if state.Code != req.Code { state.FailCount++ h.saveSignupState(key, state, signupStateExpiration) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code", "failCount": state.FailCount}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid code", + "code": "invalid_code", + "failCount": state.FailCount, + }) } // Check Expiry (Logic time vs stored time) if time.Now().Unix() > state.ExpiresAt { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Code expired"}) + return errorJSON(c, fiber.StatusBadRequest, "Code expired") } // Success @@ -351,24 +354,24 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { func (h *AuthHandler) Signup(c *fiber.Ctx) error { var req domain.SignupRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } // 1. Validate Fields (Simple validation) if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing required fields"}) + return errorJSON(c, fiber.StatusBadRequest, "Missing required fields") } if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") } if !req.TermsAccepted { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"}) + return errorJSON(c, fiber.StatusBadRequest, "Terms must be accepted") } // 비밀번호 정책 검증 policy := h.resolvePasswordPolicy() if err := validatePasswordWithPolicy(policy, req.Password); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusBadRequest, err.Error()) } // 2. Verify Auth Status (Redis) @@ -379,14 +382,14 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { phoneState, _ := h.getSignupState(phoneKey) if emailState == nil || !emailState.Verified { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email not verified"}) + return errorJSON(c, fiber.StatusUnauthorized, "Email not verified") } if phoneState == nil || !phoneState.Verified { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Phone not verified"}) + return errorJSON(c, fiber.StatusUnauthorized, "Phone not verified") } if h.IdpProvider == nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable") } // [Strict] Enforce Tenant Auto-Assignment @@ -404,7 +407,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { tenantID = &tenant.ID } else { slog.Warn("[Signup] Attempted to join non-active tenant by domain", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Your organization's tenant is currently not active."}) + return errorJSON(c, fiber.StatusForbidden, "Your organization's tenant is currently not active.") } } } @@ -420,12 +423,12 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { companyCode = tenant.Slug tenantID = &tenant.ID } else { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "The specified organization is not active."}) + return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.") } } else { // If companyCode provided but not found, we should probably reject if we want strictness, // or just treat as GENERAL user. Given the risk "존재하지 않는 테넌트도 저장됨", we should reject. - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid company code."}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid company code.") } } @@ -456,13 +459,13 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Signup method not supported"}) + return errorJSON(c, fiber.StatusNotImplemented, "Signup method not supported") } slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err) if strings.Contains(err.Error(), "already exists") { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"}) + return errorJSON(c, fiber.StatusConflict, "User already exists") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to create user") } // 4. Cleanup Redis @@ -829,7 +832,7 @@ func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error { func (h *AuthHandler) SendSms(c *fiber.Ctx) error { var req domain.SmsRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } slog.Info("[SMS] Sending code", "phoneNumber", req.PhoneNumber) @@ -840,7 +843,7 @@ func (h *AuthHandler) SendSms(c *fiber.Ctx) error { h.RedisService.StoreVerificationCode(sanitizedPhone, code) if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } return c.JSON(fiber.Map{"message": "SMS sent successfully"}) @@ -850,32 +853,32 @@ func (h *AuthHandler) SendSms(c *fiber.Ctx) error { func (h *AuthHandler) VerifySms(c *fiber.Ctx) error { var req domain.SmsVerifyRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") storedCode, _ := h.RedisService.GetVerificationCode(sanitizedPhone) if storedCode == "" || storedCode != req.Code { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired code") } h.RedisService.DeleteVerificationCode(sanitizedPhone) if h.IdpProvider == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Authentication service not configured"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Authentication service not configured") } loginID := normalizePhoneForLoginID(req.PhoneNumber) authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -891,7 +894,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkInitRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Enchanted] Body parse error", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } loginID := strings.ReplaceAll(req.LoginID, "-", "") @@ -903,16 +906,16 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { // 사용자 존재 여부 확인 if h.IdpProvider == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } exists, err := h.IdpProvider.UserExists(lookupLoginID) if err != nil { slog.Warn("[Enchanted] IDP user lookup failed", "loginID", loginID, "error", err) - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } if !exists { slog.Warn("[Enchanted] User not found", "loginID", loginID) - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) + return errorJSON(c, fiber.StatusNotFound, "User not registered") } userfrontURL := os.Getenv("USERFRONT_URL") @@ -981,7 +984,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { }) } else if err != nil && !errors.Is(err, domain.ErrNotSupported) { slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } // [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정 @@ -1016,7 +1019,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { // Send Email if !drySend && h.EmailService == nil { slog.Error("[Enchanted] Email Service not configured") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } subject := "[Baron 로그인] 링크" @@ -1039,7 +1042,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID) if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { slog.Error("[Enchanted] Email Failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send Email") } } } else { @@ -1051,7 +1054,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { slog.Error("[Enchanted] SMS Failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } } } @@ -1071,20 +1074,24 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkPollRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } pollKey := prefixPollMeta + "enchanted:" + req.PendingRef if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { return c.JSON(fiber.Map{ "error": "slow_down", + "code": "slow_down", "interval": interval, }) } val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.JSON(fiber.Map{"error": "expired_token"}) + return c.JSON(fiber.Map{ + "error": "expired_token", + "code": "expired_token", + }) } var data map[string]string @@ -1105,10 +1112,10 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { } if loginID == "" { slog.Warn("[Poll] Approved but missing loginId", "pendingRef", req.PendingRef) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid session reference") } if h.IdpProvider == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } loginStrategy := h.loadLoginStrategy(req.PendingRef) @@ -1123,32 +1130,32 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { code = normalizeLoginCode(code) if code == "" { slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", req.PendingRef) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login code expired"}) + return errorJSON(c, fiber.StatusBadRequest, "Login code expired") } flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) if flowID == "" { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + return errorJSON(c, fiber.StatusNotFound, "Login flow expired") } authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, code) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } slog.Error("[Poll] IDP code verify failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code") } } else { authInfo, err = h.IdpProvider.IssueSession(loginID) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } slog.Error("[Poll] IDP session issue failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } c.Locals("login_id", loginID) @@ -1190,6 +1197,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "error": "authorization_pending", + "code": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) } @@ -1199,7 +1207,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { var req domain.MagicLinkVerifyRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Verify] Body parse error", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } slog.Info("[Verify] Attempting to verify token", "token", req.Token) @@ -1208,7 +1216,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { val, err := h.RedisService.Get(tokenKey) if err != nil || val == "" { slog.Warn("[Verify] Token not found or expired in Redis", "token", req.Token) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired token") } var tokenData map[string]string @@ -1222,7 +1230,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { c.Locals("auth_timeline_skip", true) if pendingRef == "" || loginID == "" { slog.Warn("[Verify] Missing pendingRef/loginID for verify-only", "token", req.Token) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid session reference") } h.storeLoginApproverMeta(pendingRef, c, defaultExpiration) @@ -1243,21 +1251,21 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { if h.IdpProvider == nil { slog.Error("[Verify] IDP Provider is nil") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured") } authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { if errors.Is(err, domain.ErrNotSupported) { slog.Warn("[Verify] IDP session issue not supported", "provider", h.IdpProvider.Name()) - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } slog.Error("[Verify] IDP session issue failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { slog.Error("[Verify] IDP returned empty session") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } sessionToken := authInfo.SessionToken.JWT c.Locals("login_id", loginID) @@ -1293,13 +1301,13 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginCode] Body parse error", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } loginID := strings.TrimSpace(req.LoginID) loginID = strings.ReplaceAll(loginID, " ", "+") if loginID == "" || req.Code == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "loginId and code are required"}) + return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "loginId and code are required") } lookupLoginID := loginID @@ -1308,12 +1316,12 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { } if h.IdpProvider == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable") } flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID) if err != nil || flowID == "" { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired") } if req.VerifyOnly { @@ -1329,16 +1337,16 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { if pendingRef == "" { pendingRef = storedRef } else if storedRef != "" && pendingRef != storedRef { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference") } if pendingRef == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference") } expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef) expectedCode = normalizeLoginCode(expectedCode) inputCode := normalizeLoginCode(req.Code) if expectedCode == "" || inputCode == "" || inputCode != expectedCode { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code") } h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration) sessionData, _ := json.Marshal(map[string]string{ @@ -1356,18 +1364,18 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported") } slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session") } subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), lookupLoginID) if resolveErr != nil || subject == "" { slog.Error("[LoginCode] Failed to resolve kratos identity", "loginID", lookupLoginID, "error", resolveErr) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"}) + return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") } authInfo.Subject = subject c.Locals("login_id", lookupLoginID) @@ -1415,31 +1423,31 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginShortCode] Body parse error", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode)) if shortCode == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "shortCode is required"}) + return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "shortCode is required") } val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode) if val == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) + return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code") } var payload shortLoginCodePayload if err := json.Unmarshal([]byte(val), &payload); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Invalid code payload"}) + return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Invalid code payload") } if payload.LoginID == "" || payload.Code == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) + return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code") } if req.VerifyOnly { c.Locals("auth_timeline_skip", true) if payload.PendingRef == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference") } normalizedCode := normalizeLoginCode(payload.Code) if normalizedCode != "" { @@ -1460,29 +1468,29 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { } if h.IdpProvider == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable") } flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID) if err != nil || flowID == "" { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired") } authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported") } slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session") } subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), payload.LoginID) if resolveErr != nil || subject == "" { slog.Error("[LoginShortCode] Failed to resolve kratos identity", "loginID", payload.LoginID, "error", resolveErr) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"}) + return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") } authInfo.Subject = subject c.Locals("login_id", payload.LoginID) @@ -1561,7 +1569,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Body parse error") - return response.Error(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") + return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } loginID := strings.TrimSpace(req.LoginID) @@ -1575,22 +1583,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "IDP Provider is nil" ale.Log(slog.LevelError, "IDP Provider is nil") - return response.Error(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured") + return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured") } authInfo, err := h.IdpProvider.SignIn(loginID, req.Password) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return response.Error(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported") + return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported") } ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name())) if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") { - return response.Error(c, fiber.StatusNotFound, "not_found", "User not registered") + return errorJSONCode(c, fiber.StatusNotFound, "not_found", "User not registered") } - return response.Error(c, fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials") + return errorJSONCode(c, fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials") } subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID) @@ -1659,7 +1667,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Body parse error") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } loginID := strings.TrimSpace(req.LoginID) @@ -1671,7 +1679,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Login ID is required" ale.Log(slog.LevelWarn, "Login ID missing") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID is required"}) + return errorJSON(c, fiber.StatusBadRequest, "Login ID is required") } if h.IdpProvider == nil { @@ -1679,7 +1687,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "IDP Provider is not initialized" ale.Log(slog.LevelError, "IDP Provider is not initialized") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured") } userfrontURL := os.Getenv("USERFRONT_URL") @@ -1688,7 +1696,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "USERFRONT_URL is not set" ale.Log(slog.LevelError, "USERFRONT_URL is not set") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "USERFRONT_URL environment variable is not set"}) + return errorJSON(c, fiber.StatusInternalServerError, "USERFRONT_URL environment variable is not set") } // [Changed] Point to Backend API for verification (which then redirects to Frontend) redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL) @@ -1701,7 +1709,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Failed to generate reset token" ale.Log(slog.LevelError, "Failed to generate reset token") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate reset token"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to generate reset token") } if err := h.RedisService.Set(prefixPwdResetToken+resetToken, loginID, pwdResetExpiration); err != nil { @@ -1709,7 +1717,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to store reset token in Redis") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store reset token"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token") } resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken) @@ -1727,7 +1735,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Email service not configured" ale.Log(slog.LevelError, "Email service not configured") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } subject := "[Baron 로그인] 비밀번호 재설정" body := fmt.Sprintf(` @@ -1748,7 +1756,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID)) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send reset email") } } } else { @@ -1761,7 +1769,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID)) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send reset SMS") } } } @@ -1899,7 +1907,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Body parse error") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } // loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다. @@ -1913,7 +1921,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.ProviderError = "Invalid or expired reset token" ale.Token = resetToken ale.Log(slog.LevelWarn, "Reset token invalid or expired") - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired reset token"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired reset token") } loginID = strings.TrimSpace(val) ale.Token = resetToken @@ -1939,7 +1947,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Login ID and new password are required" ale.Log(slog.LevelWarn, "Login ID or new password missing") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"}) + return errorJSON(c, fiber.StatusBadRequest, "Login ID and new password are required") } // 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다. @@ -1951,7 +1959,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelWarn, "Validation failed: "+err.Error()) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusBadRequest, err.Error()) } ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName)) @@ -1961,7 +1969,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "IDP Provider is nil" ale.Log(slog.LevelError, "IDP Provider is nil") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured") } if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil { @@ -1969,7 +1977,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to update password via IDP") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password") } ale.Status = fiber.StatusOK @@ -2017,20 +2025,21 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { PendingRef string `json:"pendingRef"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid body") } pollKey := prefixPollMeta + "qr:" + req.PendingRef if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "slow_down", + "code": "slow_down", "interval": interval, }) } val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) + return errorJSON(c, fiber.StatusBadRequest, "expired_token") } var data map[string]string @@ -2046,6 +2055,7 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "authorization_pending", + "code": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) } @@ -2059,13 +2069,13 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { } if err := c.BodyParser(&req); err != nil { slog.Error("[QR] Scan body parse error", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid body") } rawRef := strings.TrimSpace(req.PendingRef) pendingRef, err := h.resolveQrPendingRef(rawRef) if err != nil || pendingRef == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid pendingRef"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid pendingRef") } slog.Info("[QR] Scan & Approve", "pendingRef", pendingRef) @@ -2073,32 +2083,32 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { // 1. Redis에서 세션 확인 val, err := h.RedisService.Get(prefixSession + pendingRef) if err != nil || val == "" { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"}) + return errorJSON(c, fiber.StatusNotFound, "Session expired or not found") } if req.Token == "" { cookie := c.Get(fiber.HeaderCookie) if cookie == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing session token"}) + return errorJSON(c, fiber.StatusUnauthorized, "Missing session token") } _, traits, err := h.getKratosIdentityWithCookie(cookie) if err != nil { slog.Warn("[QR] Cookie session invalid", "error", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil && sessionID != "" { h.storeQrApproverSessionID(pendingRef, sessionID) } loginID := pickLoginIDFromTraits(traits) if loginID == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil { slog.Error("[QR] Start code login failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session") } return c.JSON(fiber.Map{"message": "QR Login Approved"}) } @@ -2117,11 +2127,11 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { loginID, err := h.resolveKratosLoginID(req.Token) if err != nil { slog.Warn("[QR] Invalid token", "error", err) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil { slog.Error("[QR] Start code login failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session") } return c.JSON(fiber.Map{"message": "QR Login Approved"}) @@ -2140,12 +2150,12 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { var req kratosCourierRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Kratos Courier] Body parsing failed", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } if req.Recipient == "" { slog.Warn("[Kratos Courier] Missing recipient") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient"}) + return errorJSON(c, fiber.StatusBadRequest, "Missing recipient") } loginID := req.Recipient @@ -2164,17 +2174,17 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { slog.Error("[QR] Missing login code in courier", "loginID", loginID) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"}) + return errorJSON(c, fiber.StatusInternalServerError, "Missing login code") } flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) if flowID == "" { slog.Error("[QR] Missing login flow for code verify", "loginID", loginID) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Login flow expired"}) + return errorJSON(c, fiber.StatusInternalServerError, "Login flow expired") } authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code) if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code") } sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, @@ -2192,17 +2202,17 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { slog.Error("[QR] Missing login code in courier", "loginID", loginID) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"}) + return errorJSON(c, fiber.StatusInternalServerError, "Missing login code") } flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) if flowID == "" { slog.Error("[QR] Missing login flow for code verify", "loginID", loginID) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Login flow expired"}) + return errorJSON(c, fiber.StatusInternalServerError, "Login flow expired") } authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code) if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code") } sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, @@ -2221,7 +2231,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { subject, body := h.buildKratosCourierMessage(&req) if strings.TrimSpace(body) == "" { slog.Warn("[Kratos Courier] Empty body", "recipient", req.Recipient, "template", req.TemplateType) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"}) + return errorJSON(c, fiber.StatusBadRequest, "Empty message") } if strings.Contains(req.Recipient, "@") { @@ -2237,7 +2247,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { } if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } slog.Info("[Kratos Courier] SMS sent (email relay)", "to", phone, "template", req.TemplateType) return c.JSON(fiber.Map{"status": "ok"}) @@ -2246,7 +2256,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { if strings.Contains(req.Recipient, "@") { if !drySend && h.EmailService == nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" { subject = shortSubject @@ -2258,14 +2268,14 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { } if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil { slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send email") } slog.Info("[Kratos Courier] Email sent", "to", req.Recipient, "template", req.TemplateType) return c.JSON(fiber.Map{"status": "ok"}) } if !drySend && h.SmsService == nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"}) + return errorJSON(c, fiber.StatusInternalServerError, "SMS service not configured") } phone := sanitizePhoneForSms(req.Recipient) smsLoginID := req.Recipient @@ -2287,7 +2297,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { } if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } slog.Info("[Kratos Courier] SMS sent", "to", phone, "template", req.TemplateType) return c.JSON(fiber.Map{"status": "ok"}) @@ -2544,7 +2554,7 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { func (h *AuthHandler) GetMe(c *fiber.Ctx) error { profile, err := h.resolveCurrentProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusUnauthorized, err.Error()) } return c.JSON(profile) } @@ -2918,7 +2928,7 @@ type loginClientInfo struct { func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { if h.AuditRepo == nil && h.OathkeeperRepo == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } limit := c.QueryInt("limit", 20) @@ -2935,13 +2945,13 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { var err error cursor, err = parseAuditCursor(cursorRaw) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid cursor"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor") } } profile, err := h.resolveCurrentProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } currentSessionID := "" if token := h.getBearerToken(c); token != "" { @@ -3022,7 +3032,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { for batch := 0; batch < maxBatches && len(authLogs) < fetchLimit; batch++ { logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs") } if len(logs) == 0 { break @@ -3298,12 +3308,12 @@ type linkedRpRecord struct { func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if h.Hydra == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "hydra admin unavailable") } subjects, err := h.resolveConsentSubjects(c) if err != nil || len(subjects) == 0 { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } var sessions []domain.HydraConsentSession @@ -3324,7 +3334,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { sessions = append(sessions, linked...) } if !hasSuccess && lastErr != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": lastErr.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, lastErr.Error()) } records := make(map[string]*linkedRpRecord) @@ -5137,7 +5147,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { token := h.getBearerToken(c) var req domain.UpdateUserRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } var ( @@ -5150,12 +5160,12 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { } else { cookie := c.Get("Cookie") if cookie == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) + return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token") } identityID, traits, err = h.getKratosIdentityWithCookie(cookie) } if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } currentPhone, _ := traits["phone_number"].(string) @@ -5168,7 +5178,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { val, _ := h.RedisService.Get(verifyKey) if val != "verified" { slog.Warn("[UpdateMe] Phone verification missing (Kratos)", "key", verifyKey) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."}) + return errorJSON(c, fiber.StatusForbidden, "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다.") } traits["phone_number"] = newPhoneStorage h.RedisService.Delete(verifyKey) @@ -5183,7 +5193,13 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { if err := h.updateKratosIdentity(identityID, traits); err != nil { slog.Error("Failed to update profile in Kratos", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "프로필 업데이트에 실패했습니다."}) + return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.") + } + + // Invalidate token-based profile cache so refreshed /user/me returns latest traits. + if h.RedisService != nil && token != "" { + cacheKey := "cache:profile:token:" + token + _ = h.RedisService.Delete(cacheKey) } slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID) @@ -5197,18 +5213,18 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error { var req domain.PasswordChangeRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } currentPassword := strings.TrimSpace(req.CurrentPassword) newPassword := strings.TrimSpace(req.NewPassword) if currentPassword == "" || newPassword == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Current password and new password are required"}) + return errorJSON(c, fiber.StatusBadRequest, "Current password and new password are required") } policy := h.resolvePasswordPolicy() if err := validatePasswordWithPolicy(policy, newPassword); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusBadRequest, err.Error()) } loginID := "" @@ -5222,15 +5238,15 @@ func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error { if loginID == "" { cookie := c.Get("Cookie") if cookie == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) + return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token") } _, traits, err := h.getKratosIdentityWithCookie(cookie) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } loginID = pickLoginIDFromTraits(traits) if loginID == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Login ID not found"}) + return errorJSON(c, fiber.StatusUnauthorized, "Login ID not found") } if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) @@ -5238,11 +5254,11 @@ func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error { } if _, err := h.IdpProvider.SignIn(loginID, currentPassword); err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Current password is invalid"}) + return errorJSON(c, fiber.StatusUnauthorized, "Current password is invalid") } if err := h.IdpProvider.UpdateUserPassword(loginID, newPassword, nil); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password") } return c.JSON(fiber.Map{"message": "Password updated"}) @@ -5260,19 +5276,19 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { } else { cookie := c.Get("Cookie") if cookie == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + return errorJSON(c, fiber.StatusUnauthorized, "Unauthorized") } userID, _, err = h.getKratosIdentityWithCookie(cookie) } if err != nil || userID == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } var req struct { Phone string `json:"phone"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid phone"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid phone") } phone := h.formatPhoneForStorage(req.Phone) @@ -5301,12 +5317,12 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { } else { cookie := c.Get("Cookie") if cookie == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } userID, _, err = h.getKratosIdentityWithCookie(cookie) } if err != nil || userID == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } var req struct { @@ -5314,7 +5330,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { Code string `json:"code"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) + return errorJSON(c, fiber.StatusBadRequest, "Invalid request") } phone := h.formatPhoneForStorage(req.Phone) @@ -5322,7 +5338,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { storedCode, _ := h.RedisService.Get(key) if storedCode == "" || storedCode != req.Code { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증번호가 일치하지 않거나 만료되었습니다."}) + return errorJSON(c, fiber.StatusUnauthorized, "인증번호가 일치하지 않거나 만료되었습니다.") } // Mark as verified for 10 minutes @@ -5410,16 +5426,16 @@ type rpHistoryItem struct { func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error { subject, err := h.resolveConsentSubject(c) if err != nil || subject == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if h.AuditRepo == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 100) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch history"}) + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch history") } historyMap := make(map[string]*rpHistoryItem) diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 331ac4f6..4a51cc6e 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -121,3 +121,26 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { json.NewDecoder(resp.Body).Decode(&initResp) assert.NotEmpty(t, initResp["userCode"]) } + +func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) + + body, _ := json.Marshal(map[string]string{ + "pendingRef": "missing-ref", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var got map[string]interface{} + json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "expired_token", got["error"]) + assert.Equal(t, "expired_token", got["code"]) +} diff --git a/backend/internal/handler/auth_handler_login_code_test.go b/backend/internal/handler/auth_handler_login_code_test.go new file mode 100644 index 00000000..2f3f7ea2 --- /dev/null +++ b/backend/internal/handler/auth_handler_login_code_test.go @@ -0,0 +1,206 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func newVerifyLoginCodeTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode) + app.Post("/api/v1/auth/login/code/verify-short", h.VerifyLoginShortCode) + return app +} + +func decodeJSONBody(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + return got +} + +func TestVerifyLoginCode_InvalidBody_ReturnsExplicitCode(t *testing.T) { + h := &AuthHandler{} + app := newVerifyLoginCodeTestApp(h) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewBufferString("{")) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + got := decodeJSONBody(t, resp) + if got["code"] != "bad_request" { + t.Fatalf("expected code=bad_request, got %v", got["code"]) + } +} + +func TestVerifyLoginCode_IdpUnavailable_ReturnsExplicitCode(t *testing.T) { + h := &AuthHandler{} + app := newVerifyLoginCodeTestApp(h) + + body, _ := json.Marshal(map[string]any{ + "loginId": "user@example.com", + "code": "AA-111111", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", resp.StatusCode) + } + + got := decodeJSONBody(t, resp) + if got["code"] != "service_unavailable" { + t.Fatalf("expected code=service_unavailable, got %v", got["code"]) + } +} + +func TestVerifyLoginCode_VerifyOnlyInvalidCode_ReturnsExplicitCode(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + redis.data[prefixLoginCode+"user@example.com"] = "flow-1" + redis.data[prefixLoginCodePending+"user@example.com"] = "pending-1" + redis.data[prefixLoginCodeValue+"pending-1"] = "AB-123" + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{}, + } + app := newVerifyLoginCodeTestApp(h) + + body, _ := json.Marshal(map[string]any{ + "loginId": "user@example.com", + "code": "ZZ-999", + "verifyOnly": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + + got := decodeJSONBody(t, resp) + if got["code"] != "invalid_code" { + t.Fatalf("expected code=invalid_code, got %v", got["code"]) + } +} + +func TestVerifyLoginShortCode_MissingShortCode_ReturnsExplicitCode(t *testing.T) { + h := &AuthHandler{ + RedisService: &mockRedisRepo{data: make(map[string]string)}, + } + app := newVerifyLoginCodeTestApp(h) + + body, _ := json.Marshal(map[string]any{ + "shortCode": "", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + got := decodeJSONBody(t, resp) + if got["code"] != "bad_request" { + t.Fatalf("expected code=bad_request, got %v", got["code"]) + } +} + +func TestVerifyLoginShortCode_InvalidOrExpired_ReturnsExplicitCode(t *testing.T) { + h := &AuthHandler{ + RedisService: &mockRedisRepo{data: make(map[string]string)}, + } + app := newVerifyLoginCodeTestApp(h) + + body, _ := json.Marshal(map[string]any{ + "shortCode": "AB-123456", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + + got := decodeJSONBody(t, resp) + if got["code"] != "invalid_or_expired_code" { + t.Fatalf("expected code=invalid_or_expired_code, got %v", got["code"]) + } +} + +func TestVerifyLoginShortCode_VerifyOnlyMissingPendingRef_ReturnsExplicitCode(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + payload, _ := json.Marshal(shortLoginCodePayload{ + LoginID: "user@example.com", + Code: "AB-123", + }) + redis.data[prefixLoginCodeShort+"AB-123456"] = string(payload) + + h := &AuthHandler{ + RedisService: redis, + } + app := newVerifyLoginCodeTestApp(h) + + body, _ := json.Marshal(map[string]any{ + "shortCode": "AB-123456", + "verifyOnly": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify-short", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + got := decodeJSONBody(t, resp) + if got["code"] != "invalid_session_reference" { + t.Fatalf("expected code=invalid_session_reference, got %v", got["code"]) + } +} diff --git a/backend/internal/handler/auth_handler_profile_cache_test.go b/backend/internal/handler/auth_handler_profile_cache_test.go new file mode 100644 index 00000000..aa66ac14 --- /dev/null +++ b/backend/internal/handler/auth_handler_profile_cache_test.go @@ -0,0 +1,109 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" +) + +func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) { + token := "token-abc" + identityID := "user-1" + traits := map[string]interface{}{ + "email": "qa@example.com", + "name": "QA User", + "phone_number": "+821012345678", + "department": "Old Dept", + "affiliationType": "employee", + "companyCode": "", + "role": domain.RoleUser, + } + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.URL.Host == "kratos.test" && + r.URL.Path == "/sessions/whoami" && + r.Method == http.MethodGet: + if r.Header.Get("X-Session-Token") != token { + return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil + } + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": identityID, + "traits": traits, + }, + }), nil + + case r.URL.Host == "kratos.test" && + r.URL.Path == "/admin/identities/"+identityID && + r.Method == http.MethodPut: + var payload struct { + Traits map[string]interface{} `json:"traits"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil + } + for k, v := range payload.Traits { + traits[k] = v + } + return httpResponse(r, http.StatusOK, `{"ok":true}`), nil + } + + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + setDefaultHTTPClientForTest(t, transport) + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Get("/api/v1/user/me", h.GetMe) + app.Put("/api/v1/user/me", h.UpdateMe) + + // 1) 첫 조회로 Old Dept가 캐시에 저장됨 + getReq1 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil) + getReq1.Header.Set("Authorization", "Bearer "+token) + getResp1, err := app.Test(getReq1, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, getResp1.StatusCode) + var profile1 map[string]interface{} + require.NoError(t, json.NewDecoder(getResp1.Body).Decode(&profile1)) + require.Equal(t, "Old Dept", profile1["department"]) + + // 2) 소속을 New Dept로 변경 + updateBody, _ := json.Marshal(map[string]string{ + "name": "QA User", + "phone": "01012345678", + "department": "New Dept", + }) + updateReq := httptest.NewRequest( + http.MethodPut, + "/api/v1/user/me", + bytes.NewReader(updateBody), + ) + updateReq.Header.Set("Content-Type", "application/json") + updateReq.Header.Set("Authorization", "Bearer "+token) + updateResp, err := app.Test(updateReq, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, updateResp.StatusCode) + require.Equal(t, "New Dept", traits["department"]) + + // 3) 새로고침 재조회 시 New Dept가 보여야 함(캐시 무효화 회귀 방지) + getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil) + getReq2.Header.Set("Authorization", "Bearer "+token) + getResp2, err := app.Test(getReq2, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, getResp2.StatusCode) + var profile2 map[string]interface{} + require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2)) + require.Equal(t, "New Dept", profile2["department"]) +} diff --git a/backend/internal/handler/auth_handler_qr_test.go b/backend/internal/handler/auth_handler_qr_test.go index 9089a948..7caf77b8 100644 --- a/backend/internal/handler/auth_handler_qr_test.go +++ b/backend/internal/handler/auth_handler_qr_test.go @@ -83,6 +83,7 @@ func TestQRLoginFlow_Success(t *testing.T) { var pollResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&pollResp) assert.Equal(t, "authorization_pending", pollResp["error"]) + assert.Equal(t, "authorization_pending", pollResp["code"]) // 3. Mock Approval sessionData, _ := json.Marshal(map[string]string{ diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go index e92f19fc..87a10b64 100644 --- a/backend/internal/handler/auth_handler_test.go +++ b/backend/internal/handler/auth_handler_test.go @@ -1,6 +1,7 @@ package handler import ( + "baron-sso-backend/internal/middleware" "bytes" "encoding/json" "fmt" @@ -26,6 +27,13 @@ func newResetFlowTestApp(h *AuthHandler) *fiber.App { return app } +func newResetInitAppWithErrorCodeEnricher(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Use(middleware.ErrorCodeEnricher()) + app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset) + return app +} + type testRedisRepo struct { values map[string]string } @@ -286,3 +294,35 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) { t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location) } } + +func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) { + h := &AuthHandler{} + app := newResetInitAppWithErrorCodeEnricher(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["error"] != "Login ID is required" { + t.Fatalf("unexpected error message: %v", got["error"]) + } + if got["code"] != "bad_request" { + t.Fatalf("expected code=bad_request, got %v", got["code"]) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 1b862c84..15a6277e 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -163,6 +163,14 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func setDefaultHTTPClientForTest(t interface{ Cleanup(func()) }, transport http.RoundTripper) { + origDefault := http.DefaultClient + http.DefaultClient = &http.Client{Transport: transport} + t.Cleanup(func() { + http.DefaultClient = origDefault + }) +} + func httpResponse(r *http.Request, code int, body string) *http.Response { return &http.Response{ StatusCode: code, diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index e25ceeca..498752ad 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -275,15 +275,13 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { clients, err := h.Hydra.ListClients(c.Context(), limit, offset) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"}) + return errorJSON(c, fiber.StatusNotFound, "clients not found") } errMsg := err.Error() if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ - "error": "Hydra service is unavailable. Please check if Ory Hydra is running.", - }) + return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg}) + return errorJSON(c, fiber.StatusInternalServerError, errMsg) } items := make([]clientSummary, 0, len(clients)) @@ -306,15 +304,15 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { func (h *DevHandler) GetClient(c *fiber.Ctx) error { clientID := c.Params("id") if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "client id is required") } client, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) + return errorJSON(c, fiber.StatusNotFound, "client not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*client) @@ -323,10 +321,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { if summary.Type == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -345,19 +343,19 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { clientID := c.Params("id") if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "client id is required") } var req struct { Status string `json:"status"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } status := strings.ToLower(strings.TrimSpace(req.Status)) if status != "active" && status != "inactive" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) + return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } // [Security] Check permission before patching @@ -367,7 +365,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { if summary.Type == "private" { isAppManager, _ := h.checkAppManagerPermission(c) if !isAppManager { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } } @@ -375,9 +373,9 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) + return errorJSON(c, fiber.StatusNotFound, "client not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*updated) @@ -396,7 +394,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { func (h *DevHandler) CreateClient(c *fiber.Ctx) error { var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } clientID := strings.TrimSpace(valueOr(req.ID, "")) @@ -411,7 +409,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { redirectURIs := derefSlice(req.RedirectURIs, nil) if len(redirectURIs) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "redirectUris is required"}) + return errorJSON(c, fiber.StatusBadRequest, "redirectUris is required") } scopes := derefSlice(req.Scopes, defaultClientScopes()) @@ -420,23 +418,23 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private"))) if clientType != "pkce" && clientType != "private" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"}) + return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") } // [Security] Check permission for private clients if clientType == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions to create private client"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") } } status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active"))) if status != "active" && status != "inactive" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) + return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } metadata := mergeMetadata(nil, req.Metadata) @@ -468,7 +466,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { created, err := h.Hydra.CreateClient(c.Context(), clientReq) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Store secret in metadata for later retrieval @@ -500,27 +498,27 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "client id is required") } var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } current, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) + return errorJSON(c, fiber.StatusNotFound, "client not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) if clientType != "pkce" && clientType != "private" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"}) + return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") } } @@ -529,10 +527,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { if currentSummary.Type == "private" || clientType == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -540,7 +538,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { if req.Status != nil { status = strings.ToLower(strings.TrimSpace(*req.Status)) if status != "active" && status != "inactive" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) + return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } } @@ -554,7 +552,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { } if req.RedirectURIs != nil && len(*req.RedirectURIs) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "redirectUris cannot be empty"}) + return errorJSON(c, fiber.StatusBadRequest, "redirectUris cannot be empty") } metadata := mergeMetadata(current.Metadata, req.Metadata) @@ -579,9 +577,9 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) + return errorJSON(c, fiber.StatusNotFound, "client not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*updatedClient) @@ -600,7 +598,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "client id is required") } // [Security] Check permission for private clients @@ -610,16 +608,16 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { if summary.Type == "private" { isAppManager, _ := h.checkAppManagerPermission(c) if !isAppManager { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } } if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { if errors.Is(err, service.ErrHydraNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) + return errorJSON(c, fiber.StatusNotFound, "client not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 1. Clean up PostgreSQL @@ -638,7 +636,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { func (h *DevHandler) ListConsents(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Query("client_id")) if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "client_id is required") } subject := strings.TrimSpace(c.Query("subject")) @@ -678,7 +676,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { } if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } items := make([]consentSummary, 0, len(consents)) @@ -719,7 +717,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { subject := strings.TrimSpace(c.Query("subject")) if subject == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"}) + return errorJSON(c, fiber.StatusBadRequest, "subject is required") } clientID := strings.TrimSpace(c.Query("client_id")) @@ -733,7 +731,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { // 1. Revoke in Hydra if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 2. Sync to Local DB (Delete) @@ -747,7 +745,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "client id is required") } // [Security] Check permission for private clients @@ -757,7 +755,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { if summary.Type == "private" { isAppManager, _ := h.checkAppManagerPermission(c) if !isAppManager { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } } @@ -765,22 +763,22 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { // 1. Generate new secret newSecret, err := generateRandomSecret(20) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret") } // 2. Get current client to preserve other fields (already fetched above) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) + return errorJSON(c, fiber.StatusNotFound, "client not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 3. Update Hydra current.ClientSecret = newSecret updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 4. Update Persistence (DB & Redis) diff --git a/backend/internal/handler/error_helper.go b/backend/internal/handler/error_helper.go new file mode 100644 index 00000000..e91fa4a8 --- /dev/null +++ b/backend/internal/handler/error_helper.go @@ -0,0 +1,17 @@ +package handler + +import ( + "baron-sso-backend/internal/response" + + "github.com/gofiber/fiber/v2" +) + +// errorJSON은 기존 error 필드를 유지하면서 기계 판독용 code를 명시적으로 추가합니다. +func errorJSON(c *fiber.Ctx, status int, message string) error { + return response.Error(c, status, response.StatusCode(status), message) +} + +// errorJSONCode는 상태코드 기반 매핑만으로 부족한 경우 명시 코드를 강제할 때 사용합니다. +func errorJSONCode(c *fiber.Ctx, status int, code, message string) error { + return response.Error(c, status, code, message) +} diff --git a/backend/internal/handler/federation_handler.go b/backend/internal/handler/federation_handler.go index 0c05123f..e8821de0 100644 --- a/backend/internal/handler/federation_handler.go +++ b/backend/internal/handler/federation_handler.go @@ -33,13 +33,13 @@ func (h *FederationHandler) InitiateOIDCLogin(c *fiber.Ctx) error { loginChallenge := c.Query("login_challenge") if providerID == "" || loginChallenge == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "provider_id and login_challenge are required"}) + return errorJSON(c, fiber.StatusBadRequest, "provider_id and login_challenge are required") } redirectURL, err := h.fedSvc.InitiateOIDCLogin(c.Context(), providerID, loginChallenge) if err != nil { // Log the error properly in a real application - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to initiate OIDC login"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to initiate OIDC login") } return c.Redirect(redirectURL, fiber.StatusFound) @@ -51,12 +51,12 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error { state := c.Query("state") if code == "" || state == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "code and state are required"}) + return errorJSON(c, fiber.StatusBadRequest, "code and state are required") } redirectURL, err := h.fedSvc.HandleOIDCCallback(c.Context(), code, state) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to handle OIDC callback"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to handle OIDC callback") } return c.Redirect(redirectURL, fiber.StatusFound) @@ -68,12 +68,12 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error { func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error { clientID := c.Params("clientId") if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required"}) + return errorJSON(c, fiber.StatusBadRequest, "clientId is required") } var configs []domain.IdentityProviderConfig if err := h.db.Where("client_id = ?", clientID).Find(&configs).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(configs) @@ -83,12 +83,12 @@ func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error { func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error { clientID := c.Params("clientId") if clientID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "clientId is required in path"}) + return errorJSON(c, fiber.StatusBadRequest, "clientId is required in path") } var req domain.IdentityProviderConfig if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } // Assign clientID from path parameter @@ -96,14 +96,14 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error { // Basic validation if req.DisplayName == "" || req.ProviderType == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "display_name and provider_type are required"}) + return errorJSON(c, fiber.StatusBadRequest, "display_name and provider_type are required") } // TODO: Optionally, validate if the clientID exists in Hydra // Create in DB if err := h.db.Create(&req).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusCreated).JSON(req) @@ -115,7 +115,7 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error { func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { tenantID := c.Params("tenantId") if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") } // This is a temporary solution. We should create a proper method in the repository. @@ -123,7 +123,7 @@ func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { // Note: This now queries client_id, which is incorrect for tenants. // This method is deprecated. if err := h.db.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(configs) @@ -133,26 +133,26 @@ func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error { func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error { var req domain.IdentityProviderConfig if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } // Basic validation - This is the old validation logic if req.ClientID == "" || req.DisplayName == "" || req.ProviderType == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id, display_name, and provider_type are required"}) + return errorJSON(c, fiber.StatusBadRequest, "client_id, display_name, and provider_type are required") } // This check is now incorrect and deprecated. var tenant domain.Tenant if err := h.db.First(&tenant, "id = ?", req.ClientID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant not found"}) + return errorJSON(c, fiber.StatusBadRequest, "tenant not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Create in DB if err := h.db.Create(&req).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusCreated).JSON(req) diff --git a/backend/internal/handler/relying_party_handler.go b/backend/internal/handler/relying_party_handler.go index 342d7f5d..74bddee0 100644 --- a/backend/internal/handler/relying_party_handler.go +++ b/backend/internal/handler/relying_party_handler.go @@ -20,17 +20,17 @@ func NewRelyingPartyHandler(s service.RelyingPartyService, kratos *service.Krato func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error { tenantID := c.Params("tenantId") if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") } var req domain.HydraClient if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } rp, err := h.Service.Create(c.Context(), tenantID, req) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusCreated).JSON(rp) @@ -39,7 +39,7 @@ func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error { func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error { profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found in context"}) + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: user profile not found in context") } var rps []domain.RelyingParty @@ -51,11 +51,11 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error { rps, err = h.Service.List(c.Context(), *profile.TenantID) } else { slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", profile.Role) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient role to list all applications"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient role to list all applications") } if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(rps) @@ -64,12 +64,12 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error { func (h *RelyingPartyHandler) List(c *fiber.Ctx) error { tenantID := c.Params("tenantId") if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenantId is required") } rps, err := h.Service.List(c.Context(), tenantID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(rps) @@ -79,7 +79,7 @@ func (h *RelyingPartyHandler) Get(c *fiber.Ctx) error { id := c.Params("id") rp, hydraClient, err := h.Service.Get(c.Context(), id) if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "relying party not found"}) + return errorJSON(c, fiber.StatusNotFound, "relying party not found") } return c.JSON(fiber.Map{ @@ -92,12 +92,12 @@ func (h *RelyingPartyHandler) Update(c *fiber.Ctx) error { id := c.Params("id") var req domain.HydraClient if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } rp, err := h.Service.Update(c.Context(), id, req) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(rp) @@ -106,7 +106,7 @@ func (h *RelyingPartyHandler) Update(c *fiber.Ctx) error { func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error { id := c.Params("id") if err := h.Service.Delete(c.Context(), id); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 956e7696..62f5100e 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -56,17 +56,17 @@ func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error { AdminEmail string `json:"adminEmail"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } // Basic validation if req.Name == "" || req.Domain == "" || req.AdminEmail == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name, domain, and adminEmail are required"}) + return errorJSON(c, fiber.StatusBadRequest, "name, domain, and adminEmail are required") } tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail) if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusBadRequest, err.Error()) } return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ @@ -78,11 +78,11 @@ func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error { func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(fiber.Map{"message": "Tenant approved successfully"}) @@ -90,7 +90,7 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } limit := c.QueryInt("limit", 50) @@ -104,12 +104,12 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { var total int64 if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } var tenants []domain.Tenant if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } items := make([]tenantSummary, 0, len(tenants)) @@ -122,20 +122,20 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) + return errorJSON(c, fiber.StatusNotFound, "tenant not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(mapTenantSummary(tenant)) @@ -143,7 +143,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } var req struct { @@ -155,12 +155,12 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } name := strings.TrimSpace(req.Name) if name == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + return errorJSON(c, fiber.StatusBadRequest, "name is required") } slug := normalizeTenantSlug(req.Slug) @@ -168,7 +168,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { slug = normalizeTenantSlug(name) } if slug == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug is required"}) + return errorJSON(c, fiber.StatusBadRequest, "slug is required") } status := normalizeTenantStatus(req.Status) @@ -180,9 +180,9 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains) if err != nil { if strings.Contains(err.Error(), "already exists") { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusConflict, err.Error()) } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if req.Config != nil { @@ -195,20 +195,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) + return errorJSON(c, fiber.StatusNotFound, "tenant not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } var req struct { @@ -220,27 +220,27 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name cannot be empty"}) + return errorJSON(c, fiber.StatusBadRequest, "name cannot be empty") } tenant.Name = name } if req.Slug != nil { slug := normalizeTenantSlug(*req.Slug) if slug == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug cannot be empty"}) + return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty") } if slug != tenant.Slug { var exists domain.Tenant if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "slug already exists"}) + return errorJSON(c, fiber.StatusConflict, "slug already exists") } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } tenant.Slug = slug } @@ -251,7 +251,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { if req.Status != nil { status := normalizeTenantStatus(*req.Status) if status == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) + return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } tenant.Status = status } @@ -260,14 +260,14 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } if err := h.DB.Save(&tenant).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Update domains if provided if req.Domains != nil { // Simple approach: Delete existing and recreate if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to clear old domains"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to clear old domains") } for _, d := range req.Domains { if strings.TrimSpace(d) == "" { @@ -275,7 +275,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } // Use repository for consistency if err := repository.NewTenantRepository(h.DB).AddDomain(c.Context(), tenant.ID, strings.TrimSpace(d), true); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to add domain: " + d}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to add domain: "+d) } } } @@ -288,30 +288,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) + return errorJSON(c, fiber.StatusNotFound, "tenant not found") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Rename slug to release it for reuse before soft delete deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405") if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to release slug"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to release slug") } if err := h.DB.Delete(&tenant).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) @@ -320,13 +320,13 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } // Fetch admins from Keto relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "") if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } type adminInfo struct { @@ -372,11 +372,11 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusOK) @@ -386,11 +386,11 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) + return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) diff --git a/backend/internal/handler/user_group_handler.go b/backend/internal/handler/user_group_handler.go index 359754e9..9d9fa0cf 100644 --- a/backend/internal/handler/user_group_handler.go +++ b/backend/internal/handler/user_group_handler.go @@ -19,7 +19,7 @@ func (h *UserGroupHandler) List(c *fiber.Ctx) error { tenantID := c.Params("tenantId") groups, err := h.Service.List(c.Context(), tenantID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(groups) } @@ -28,12 +28,12 @@ func (h *UserGroupHandler) Create(c *fiber.Ctx) error { tenantID := c.Params("tenantId") var group domain.UserGroup if err := c.BodyParser(&group); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid body") } group.TenantID = tenantID if err := h.Service.Create(c.Context(), &group); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusCreated).JSON(group) } @@ -42,7 +42,7 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error { id := c.Params("id") group, err := h.Service.Get(c.Context(), id) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get group: " + err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to get group: "+err.Error()) } return c.JSON(group) } @@ -51,12 +51,12 @@ func (h *UserGroupHandler) Update(c *fiber.Ctx) error { id := c.Params("id") var group domain.UserGroup if err := c.BodyParser(&group); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid body") } group.ID = id if err := h.Service.Update(c.Context(), &group); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(group) } @@ -64,7 +64,7 @@ func (h *UserGroupHandler) Update(c *fiber.Ctx) error { func (h *UserGroupHandler) Delete(c *fiber.Ctx) error { id := c.Params("id") if err := h.Service.Delete(c.Context(), id); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) } @@ -75,11 +75,11 @@ func (h *UserGroupHandler) AddMember(c *fiber.Ctx) error { UserID string `json:"userId"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "userId is required"}) + return errorJSON(c, fiber.StatusBadRequest, "userId is required") } if err := h.Service.AddMember(c.Context(), groupID, req.UserID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusOK) } @@ -89,7 +89,7 @@ func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error { userID := c.Params("userId") if err := h.Service.RemoveMember(c.Context(), groupID, userID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) } @@ -101,11 +101,11 @@ func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error { Relation string `json:"relation"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid body") } if err := h.Service.AssignRoleToTenant(c.Context(), groupID, req.TenantID, req.Relation); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusOK) } @@ -114,7 +114,7 @@ func (h *UserGroupHandler) ListRoles(c *fiber.Ctx) error { groupID := c.Params("id") roles, err := h.Service.ListRoles(c.Context(), groupID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(roles) } @@ -125,7 +125,7 @@ func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error { relation := c.Params("relation") if err := h.Service.RemoveRoleFromTenant(c.Context(), groupID, tenantID, relation); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 00a64ba7..b2775802 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -125,7 +125,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { // Fetch from UserRepo users, total, err := h.UserRepo.List(c.Context(), offset, limit, search) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db") } items := make([]userSummary, 0, len(users)) @@ -154,20 +154,20 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { func (h *UserHandler) GetUser(c *fiber.Ctx) error { if h.KratosAdmin == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } userID := strings.TrimSpace(c.Params("id")) if userID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "user id is required") } identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if identity == nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + return errorJSON(c, fiber.StatusNotFound, "user not found") } // [New] Check access scope @@ -175,7 +175,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { if requester != nil && requester.Role == domain.RoleTenantAdmin { compCode := extractTraitString(identity.Traits, "companyCode") if requester.CompanyCode == "" || compCode != requester.CompanyCode { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: access to user in another tenant denied"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied") } } @@ -184,7 +184,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if h.OryProvider == nil || h.KratosAdmin == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } var req struct { @@ -198,19 +198,19 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } email := strings.TrimSpace(req.Email) if email == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"}) + return errorJSON(c, fiber.StatusBadRequest, "email is required") } if !strings.Contains(email, "@") || !strings.Contains(email, ".") { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid email format"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid email format") } name := strings.TrimSpace(req.Name) if name == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + return errorJSON(c, fiber.StatusBadRequest, "name is required") } password := strings.TrimSpace(req.Password) @@ -230,13 +230,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if password == "" { generated, genErr := utils.GeneratePasswordWithPolicy(policy) if genErr != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate password"}) + return errorJSON(c, fiber.StatusInternalServerError, "failed to generate password") } password = generated generatedPassword = generated } else { if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusBadRequest, err.Error()) } } @@ -282,9 +282,9 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { identityID, err := h.OryProvider.CreateUser(brokerUser, password) if err != nil { if strings.Contains(err.Error(), "already exists") { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"}) + return errorJSON(c, fiber.StatusConflict, "email already exists") } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // [New] Local DB Sync @@ -334,7 +334,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if identity == nil { return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) @@ -349,20 +349,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if h.KratosAdmin == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } userID := strings.TrimSpace(c.Params("id")) if userID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "user id is required") } identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if identity == nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + return errorJSON(c, fiber.StatusNotFound, "user not found") } // [New] Check access scope @@ -370,7 +370,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if requester != nil && requester.Role == domain.RoleTenantAdmin { compCode := extractTraitString(identity.Traits, "companyCode") if requester.CompanyCode == "" || compCode != requester.CompanyCode { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot update user in another tenant"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant") } } @@ -385,13 +385,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } // [New] Tenant Admin restriction: Cannot change companyCode if requester != nil && requester.Role == domain.RoleTenantAdmin { if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: tenant admins cannot change user's tenant"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant") } } @@ -451,7 +451,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { state := normalizeKratosState(req.Status) updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // [New] Local DB Sync @@ -519,7 +519,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if req.Password != nil && *req.Password != "" { if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } @@ -528,12 +528,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { if h.KratosAdmin == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) + return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } userID := strings.TrimSpace(c.Params("id")) if userID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + return errorJSON(c, fiber.StatusBadRequest, "user id is required") } // [New] Check access scope before deletion @@ -543,13 +543,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { if err == nil && identity != nil { compCode := extractTraitString(identity.Traits, "companyCode") if requester.CompanyCode == "" || compCode != requester.CompanyCode { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot delete user in another tenant"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant") } } } if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // [Keto] Cleanup relations (Best effort) diff --git a/backend/internal/middleware/error_helper.go b/backend/internal/middleware/error_helper.go new file mode 100644 index 00000000..31388152 --- /dev/null +++ b/backend/internal/middleware/error_helper.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "baron-sso-backend/internal/response" + + "github.com/gofiber/fiber/v2" +) + +// errorJSON은 legacy error 필드를 유지하면서 status 기반 code를 함께 반환합니다. +func errorJSON(c *fiber.Ctx, status int, message string) error { + return response.Error(c, status, response.StatusCode(status), message) +} + +// errorJSONCode는 상태코드 매핑과 다른 명시 코드가 필요할 때 사용합니다. +func errorJSONCode(c *fiber.Ctx, status int, code, message string) error { + return response.Error(c, status, code, message) +} diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index 4b8df4fd..0175974b 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -25,7 +25,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. return func(c *fiber.Ctx) error { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"}) + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_keto)") } // Store profile in locals for further use in handlers @@ -49,7 +49,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. if objectID == "" { slog.Error("RBAC Keto check failed: missing object id", "path", c.Path()) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"}) + return errorJSON(c, fiber.StatusBadRequest, "missing object id for permission check") } slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) @@ -63,12 +63,12 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) if err != nil { slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !allowed { slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied for " + namespace + ":" + objectID}) + return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID) } return c.Next() @@ -85,9 +85,7 @@ func RequireRole(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "error": "unauthorized (trace:rbac_role): " + err.Error(), - }) + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_role): "+err.Error()) } // Store profile in locals for further use in handlers @@ -114,9 +112,7 @@ func RequireRole(config RBACConfig) fiber.Handler { "allowedRoles", config.AllowedRoles, "path", c.Path(), ) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": "forbidden: insufficient permissions", - }) + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions") } // Store profile in locals for further use in handlers @@ -136,7 +132,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"}) + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_match)") } // Store profile in locals for further use in handlers @@ -174,13 +170,11 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler { if !isAllowed { slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": "forbidden: you do not have access to this tenant", - }) + return errorJSON(c, fiber.StatusForbidden, "forbidden: you do not have access to this tenant") } return c.Next() } - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + return errorJSON(c, fiber.StatusForbidden, "forbidden") } } diff --git a/docs/test-plan.md b/docs/test-plan.md index 5c6ab8f9..75984c1a 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -13,6 +13,7 @@ - Backend(Go): **104개** - UserFront(Flutter): **47개** - AdminFront/DevFront(Playwright): **4개** + - UserFront WASM Playwright E2E: **42개** ### Backend 패키지별 커버리지 - `cmd/server`: 2.6% @@ -29,6 +30,7 @@ - UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md` - AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-test-inventory.md` - UserFront WASM Playwright E2E 확장 계획: `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` +- UserFront WASM Playwright E2E 전수 목록: `docs/test-plan/userfront-wasm-e2e-route-inventory.md` ## 4) 실행 커맨드 - Backend 전체 테스트: `cd backend && go test ./...` @@ -36,7 +38,8 @@ - UserFront 테스트: `cd userfront && flutter test` - AdminFront E2E: `cd adminfront && npm test` - DevFront E2E: `cd devfront && npm test` -- UserFront WASM E2E(계획): `docs/test-plan/userfront-wasm-e2e-expansion-plan.md` 기준으로 Playwright 워크스페이스를 추가한 뒤 실행 +- UserFront WASM E2E: `cd userfront-e2e && npm run test:wasm` +- UserFront WASM E2E(테스트만): `cd userfront-e2e && npm test` ## 5) 유지 원칙 - 신규 기능은 관련 테스트를 반드시 추가합니다. diff --git a/docs/test-plan/userfront-wasm-e2e-expansion-plan.md b/docs/test-plan/userfront-wasm-e2e-expansion-plan.md index 26c8d90a..ce5d9b58 100644 --- a/docs/test-plan/userfront-wasm-e2e-expansion-plan.md +++ b/docs/test-plan/userfront-wasm-e2e-expansion-plan.md @@ -58,12 +58,46 @@ - 범위 6 구현 - null-check 복구 라우팅 검증 -## 4) 완료 기준 +## 4) 현재 구현 상태 (2026-02-24) +- Phase 0: 완료 + - `userfront-e2e/` 워크스페이스 추가 + - 로컬 SPA fallback 서버(`scripts/serve-userfront-build.mjs`) 추가 + - 실행 커맨드: `cd userfront-e2e && npm run test:wasm` + - CI 잡 연결: `.gitea/workflows/code_check.yml`의 `userfront-e2e-tests` +- Phase 1: 완료 + - `tests/auth-routing.spec.ts` 추가 + - 구현 시나리오: + - 비로그인 `/ko` → `/ko/signin` 리다이렉트 + - 로그인 상태 `/ko` 진입 + 새로고침 후 `/ko/dashboard` 유지 + - 비로그인 `/ko/approve?ref=...` 진입 시 `notice=qr_login_required`와 함께 signin 이동 + - 로그인 상태 `/ko/approve?ref=...`에서 approve API 호출 후 dashboard 이동 +- Phase 2: 완료 + - `tests/password-and-reset.spec.ts` 추가 + - 구현 시나리오: + - 비밀번호 로그인 성공 시 dashboard 이동 + 토큰 저장 확인 + - 비밀번호 로그인 실패 시 코드 기반 에러(`password_or_email_mismatch`)가 client-log로 기록되는지 확인 + - reset-password 성공 시 signin 이동 확인 + - 참고: + - WASM 렌더링에서는 접근성/DOM selector가 제한되어 로그인/리셋 폼은 `flt-glass-pane` 좌표 기반 입력으로 검증 + - 전수 인벤토리: + - `docs/test-plan/userfront-wasm-e2e-route-inventory.md` + - 라우트 22개 + 기능 회귀 12개(총 42 테스트) 코드화 완료 + - 프로필 소속 회귀 강화: + - `tests/profile-department.spec.ts` 추가 + - 구현 시나리오: + - 소속 수정 후 blur 저장 요청 전송 + - 입력 후 즉시 새로고침 시 저장 요청 미전송 재현 + - 동일값/빈값 입력 시 저장 요청 미전송 + - 수정 후 새로고침 뒤 재수정 저장 요청 누락 방지 + +## 5) 완료 기준 - 핵심 인증 플로우(로그인/새로고침/리다이렉트/QR)가 Playwright 회귀군으로 자동화됩니다. - 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다. - PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다. -## 5) 운영 원칙 +## 6) 운영 원칙 - 버그는 반드시 재현 테스트를 먼저 추가합니다. - 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다. - 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다. +- 테스트 하네스는 단계별로 초기화/정리합니다. + - 예: `beforeEach`에서 auth/mock state 재시드, `afterEach`에서 route mock 해제(`page.unroute`) 및 누수 상태 정리 diff --git a/docs/test-plan/userfront-wasm-e2e-route-inventory.md b/docs/test-plan/userfront-wasm-e2e-route-inventory.md new file mode 100644 index 00000000..2cf45a27 --- /dev/null +++ b/docs/test-plan/userfront-wasm-e2e-route-inventory.md @@ -0,0 +1,59 @@ +# UserFront WASM E2E 라우트/기능 전수 인벤토리 + +- 기준 소스: `userfront/lib/main.dart` +- 목적: 라우트 전수 항목을 Playwright 테스트로 코드화하고 CI에서 상시 검증 +- 현재 구현 파일: + - `userfront-e2e/tests/route-inventory.spec.ts` + - `userfront-e2e/tests/auth-routing.spec.ts` + - `userfront-e2e/tests/password-and-reset.spec.ts` + - `userfront-e2e/tests/profile-department.spec.ts` + +## 1) 라우트 전수 (main.dart 기준) + +| ID | Route | 검증 상태 | 테스트 파일 | +|---|---|---|---| +| R01 | `/` | 비로그인 시 `/{locale}/signin` 리다이렉트 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R02 | `/:locale` (`/ko`) | 비로그인 `signin` / 로그인 `dashboard` 분기 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/auth-routing.spec.ts` | +| R03 | `/:locale/dashboard` | 비로그인 `signin` / 로그인 유지 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/auth-routing.spec.ts` | +| R04 | `/:locale/profile` | 비로그인 `signin` / 로그인 유지 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R05 | `/:locale/signin` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R06 | `/:locale/login` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R07 | `/:locale/consent` | challenge 유무 케이스 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R08 | `/:locale/signup` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R09 | `/:locale/registration` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R10 | `/:locale/verify` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R11 | `/:locale/verify/:token` | verify 경로 진입/처리 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R12 | `/:locale/verification` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R13 | `/:locale/l/:shortCode` | short code 경로 진입/처리 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R14 | `/:locale/forgot-password` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R15 | `/:locale/recovery` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R16 | `/:locale/reset-password` | token 기반 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/password-and-reset.spec.ts` | +| R17 | `/:locale/error` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R18 | `/:locale/settings` | 진입 가능 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R19 | `/:locale/approve` | 비로그인 `signin?notice=...` / 로그인 `dashboard` 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts`, `userfront-e2e/tests/auth-routing.spec.ts` | +| R20 | `/:locale/ql/:ref` | 비로그인 `signin?notice=...` / 로그인 `dashboard` 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R21 | `/:locale/scan` | 비로그인 `signin` / 로그인 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | +| R22 | `/:locale/admin/users` | 비로그인 `signin` / 로그인 진입 검증 완료 | `userfront-e2e/tests/route-inventory.spec.ts` | + +## 2) 기능 회귀 (핵심) + +| ID | 기능 | 검증 상태 | 테스트 파일 | +|---|---|---|---| +| F01 | `/ko` 비로그인 리다이렉트 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` | +| F02 | 로그인 후 `/ko` + 새로고침 세션 유지 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` | +| F03 | approve 경로 비로그인 보호 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` | +| F04 | approve 경로 로그인 자동 승인 | 완료 | `userfront-e2e/tests/auth-routing.spec.ts` | +| F05 | 비밀번호 로그인 성공 | 완료 | `userfront-e2e/tests/password-and-reset.spec.ts` | +| F06 | 비밀번호 로그인 실패 코드 처리 | 완료 | `userfront-e2e/tests/password-and-reset.spec.ts` | +| F07 | 비밀번호 재설정 완료 후 signin 이동 | 완료 | `userfront-e2e/tests/password-and-reset.spec.ts` | +| F08 | 프로필 소속 수정 후 blur 저장 요청 전송 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` | +| F09 | 프로필 소속 입력 후 즉시 새로고침 시 저장 요청 미전송(재현) | 완료 | `userfront-e2e/tests/profile-department.spec.ts` | +| F10 | 프로필 소속 동일값 입력 시 저장 요청 미전송 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` | +| F11 | 프로필 소속 빈값 입력 시 저장 요청 미전송 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` | +| F12 | 프로필 소속 수정 후 새로고침 뒤 재수정 저장 요청 누락 방지 | 완료 | `userfront-e2e/tests/profile-department.spec.ts` | + +## 3) 실행/CI + +- 로컬 실행: `cd userfront-e2e && npm run test:wasm` +- CI 워크플로우: `.gitea/workflows/code_check.yml`의 `userfront-e2e-tests` 잡에서 매 실행 검증 +- 현재 스위트 수량: 총 42 테스트(라우트 30 + 인증/리다이렉트 4 + 비밀번호/리셋 3 + 프로필 소속 5) diff --git a/docs/trouble-shooting/issue-303-login-link-shortcode-locale-route.md b/docs/trouble-shooting/issue-303-login-link-shortcode-locale-route.md new file mode 100644 index 00000000..b03b7ee7 --- /dev/null +++ b/docs/trouble-shooting/issue-303-login-link-shortcode-locale-route.md @@ -0,0 +1,47 @@ +# #303 로그인 링크/코드 진입 실패 (`/{locale}/l/{shortCode}`) 대응 + +## 요약 +- 로그인 링크 발송은 정상이나, 링크 클릭 후 로그인 검증 진입이 실패하는 사례가 있었습니다. +- 특히 locale prefix가 포함된 short-code 경로(`/{locale}/l/{shortCode}`)에서 재현되었습니다. +- 원인은 라우터의 public path 판별과 short-code 추출 로직이 locale prefix 경로를 고려하지 못한 점이었습니다. + +## 증상 +- 링크 클릭 URL이 `https://.../ko/l/AB123456` 형태일 때 로그인 검증이 자동 시작되지 않음 +- 비로그인 상태에서 해당 경로 접근 시 signin으로 리다이렉트되어 short-code 검증이 끊김 + +## 원인 +1. Public path 판별에서 `/l/` 경로가 제외되어 있었음 +2. `LoginScreen`의 short-code 추출이 `uri.pathSegments.first == 'l'`에만 의존 + - `/{locale}/l/{shortCode}`에서는 첫 세그먼트가 locale이므로 추출 실패 + +## 조치 내용 +1. 경로 정책 분리 +- `userfront/lib/features/auth/domain/login_link_route_policy.dart` 신규 추가 +- public path 판별(`isPublicAuthPath`)과 short-code 추출(`extractLoginShortCode`)을 공용화 + +2. 라우터 반영 +- `userfront/lib/main.dart` redirect에서 `isPublicAuthPath` 사용 +- `/l/` 경로를 public path로 허용 + +3. 로그인 화면 반영 +- `userfront/lib/features/auth/presentation/login_screen.dart`에서 + `extractLoginShortCode(Uri.base)`로 short-code를 추출하도록 변경 +- locale prefix 유무와 관계없이 short-code 검증 진입 가능 + +## 테스트 +### 재현 테스트 (Failing first) +- `flutter test test/login_link_route_policy_test.dart` +- 초기 실패 확인: + - localized short-code 추출 실패 + - localized short-code public path 판별 실패 + +### 수정 후 회귀 테스트 +- `flutter test test/login_link_route_policy_test.dart` 통과 +- `flutter test test/router_redirect_widget_test.dart` 통과 + +## 영향 범위 +- 링크/코드 로그인 진입 라우팅 (`/l/{shortCode}` 및 `/{locale}/l/{shortCode}`) +- 기존 `/verify`, `/signin`, `/login` 경로에는 동작 변화 없음 + +## 관련 이슈 +- Gitea: #303 `[bug][auth] 링크 클릭/코드 입력 로그인 실패 재현 및 수정` diff --git a/locales/en.toml b/locales/en.toml index 6740edd9..b6f0fdd6 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -18,6 +18,24 @@ saman = "Saman" [err.common] unknown = "An unknown error occurred." +[err.backend] +authorization_pending = "Authentication approval is still pending." +bad_request = "Please check your request." +conflict = "The request conflicts with the current state." +expired_token = "The token has expired." +forbidden = "This request is not allowed." +internal_error = "An internal error occurred while processing the request." +invalid_code = "The verification code is invalid." +invalid_or_expired_code = "The verification code is invalid or expired." +invalid_session = "The session is invalid." +invalid_session_reference = "The session reference is invalid." +not_found = "The requested authentication flow was not found." +not_supported = "This login method is not supported." +password_or_email_mismatch = "Email or password does not match." +rate_limited = "Too many requests. Please try again later." +service_unavailable = "The authentication service is currently unavailable." +slow_down = "Requests are too frequent. Please try again shortly." + [err.userfront] [err.userfront.auth_proxy] diff --git a/locales/ko.toml b/locales/ko.toml index 01417815..8e09067a 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -18,6 +18,24 @@ saman = "삼안" [err.common] unknown = "알 수 없는 오류가 발생했습니다." +[err.backend] +authorization_pending = "인증 승인이 아직 완료되지 않았습니다." +bad_request = "요청 값을 확인해 주세요." +conflict = "요청이 현재 상태와 충돌합니다." +expired_token = "토큰이 만료되었습니다." +forbidden = "요청이 허용되지 않습니다." +internal_error = "요청 처리 중 내부 오류가 발생했습니다." +invalid_code = "인증 코드가 올바르지 않습니다." +invalid_or_expired_code = "인증 코드가 유효하지 않거나 만료되었습니다." +invalid_session = "세션이 유효하지 않습니다." +invalid_session_reference = "세션 참조 정보가 유효하지 않습니다." +not_found = "요청한 인증 흐름을 찾을 수 없습니다." +not_supported = "지원하지 않는 로그인 방식입니다." +password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다." +rate_limited = "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." +service_unavailable = "인증 서비스를 현재 사용할 수 없습니다." +slow_down = "요청 간격이 너무 빠릅니다. 잠시 후 다시 시도해 주세요." + [err.userfront] [err.userfront.auth_proxy] diff --git a/locales/template.toml b/locales/template.toml index 39c88db5..a284b6df 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -18,6 +18,24 @@ saman = "" [err.common] unknown = "" +[err.backend] +authorization_pending = "" +bad_request = "" +conflict = "" +expired_token = "" +forbidden = "" +internal_error = "" +invalid_code = "" +invalid_or_expired_code = "" +invalid_session = "" +invalid_session_reference = "" +not_found = "" +not_supported = "" +password_or_email_mismatch = "" +rate_limited = "" +service_unavailable = "" +slow_down = "" + [err.userfront] [err.userfront.auth_proxy] diff --git a/userfront-e2e/.gitignore b/userfront-e2e/.gitignore new file mode 100644 index 00000000..945fcd0d --- /dev/null +++ b/userfront-e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/userfront-e2e/README.md b/userfront-e2e/README.md new file mode 100644 index 00000000..b90b2702 --- /dev/null +++ b/userfront-e2e/README.md @@ -0,0 +1,29 @@ +# UserFront WASM E2E + +`userfront` WASM 빌드 산출물을 Playwright로 검증하는 테스트 워크스페이스입니다. + +## 실행 방법 + +1. 의존성 설치 +```bash +cd userfront-e2e +npm install +``` + +2. 테스트 실행(빌드 포함) +```bash +cd userfront-e2e +npm run test:wasm +``` + +3. 이미 빌드가 있을 때 테스트만 실행 +```bash +cd userfront-e2e +npm test +``` + +## 환경변수 + +- `BASE_URL`: 외부 배포 URL을 테스트할 때 사용합니다. 설정하면 로컬 정적 서버를 띄우지 않습니다. +- `PORT`: 로컬 정적 서버 포트 (기본 `4173`) +- `LOCALE`: 브라우저 locale (기본 `ko-KR`) diff --git a/userfront-e2e/package-lock.json b/userfront-e2e/package-lock.json new file mode 100644 index 00000000..829a0589 --- /dev/null +++ b/userfront-e2e/package-lock.json @@ -0,0 +1,111 @@ +{ + "name": "userfront-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "userfront-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^24.3.0", + "typescript": "^5.9.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/userfront-e2e/package.json b/userfront-e2e/package.json new file mode 100644 index 00000000..3688dfd5 --- /dev/null +++ b/userfront-e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "userfront-e2e", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "serve:build": "node ./scripts/serve-userfront-build.mjs", + "build:userfront:wasm": "cd ../userfront && flutter build web --wasm --release", + "test:wasm": "npm run build:userfront:wasm && npm test" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^24.3.0", + "typescript": "^5.9.2" + } +} diff --git a/userfront-e2e/playwright.config.ts b/userfront-e2e/playwright.config.ts new file mode 100644 index 00000000..d7e33047 --- /dev/null +++ b/userfront-e2e/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number.parseInt(process.env.PORT ?? '4173', 10); +const defaultBaseUrl = `http://127.0.0.1:${port}`; +const baseURL = process.env.BASE_URL ?? defaultBaseUrl; +const reuseExistingServer = !process.env.CI; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html', + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + locale: process.env.LOCALE ?? 'ko-KR', + serviceWorkers: 'block', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + webServer: process.env.BASE_URL + ? undefined + : { + command: 'node ./scripts/serve-userfront-build.mjs', + url: defaultBaseUrl, + reuseExistingServer, + timeout: 120_000, + }, +}); diff --git a/userfront-e2e/scripts/serve-userfront-build.mjs b/userfront-e2e/scripts/serve-userfront-build.mjs new file mode 100644 index 00000000..6f74f203 --- /dev/null +++ b/userfront-e2e/scripts/serve-userfront-build.mjs @@ -0,0 +1,68 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { dirname, extname, join, normalize } from 'node:path'; +import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const root = normalize(join(__dirname, '../../userfront/build/web')); + +if (!existsSync(root) || !statSync(root).isDirectory()) { + console.error( + '[userfront-e2e] userfront/build/web not found. Run: cd userfront && flutter build web --wasm --release', + ); + process.exit(1); +} + +const port = Number.parseInt(process.env.PORT ?? '4173', 10); + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + const pathname = decodeURIComponent(url.pathname); + const relative = pathname === '/' ? '/index.html' : pathname; + const candidate = normalize(join(root, relative)); + + if (!candidate.startsWith(root)) { + res.statusCode = 403; + res.end('Forbidden'); + return; + } + + let filePath = candidate; + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + // Flutter web 라우팅 경로(`/ko`, `/ko/signin`)도 index.html로 fallback 처리 + filePath = join(root, 'index.html'); + } + + const ext = extname(filePath); + const contentType = contentTypes[ext] ?? 'application/octet-stream'; + + res.setHeader('Content-Type', contentType); + createReadStream(filePath) + .on('error', () => { + res.statusCode = 500; + res.end('Internal Server Error'); + }) + .pipe(res); +}); + +server.listen(port, '127.0.0.1', () => { + console.log(`[userfront-e2e] serving ${root} at http://127.0.0.1:${port}`); +}); diff --git a/userfront-e2e/tests/auth-routing.spec.ts b/userfront-e2e/tests/auth-routing.spec.ts new file mode 100644 index 00000000..802c2569 --- /dev/null +++ b/userfront-e2e/tests/auth-routing.spec.ts @@ -0,0 +1,143 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +type MockOptions = { + sessionStatus?: number; + captureApprove?: (pendingRef: string | null) => void; +}; + +async function seedTokenLogin(page: Page): Promise { + await page.addInitScript(() => { + window.localStorage.setItem('baron_auth_token', 'e30.e30.e30'); + window.localStorage.setItem('baron_auth_provider', 'ory'); + window.localStorage.removeItem('baron_auth_cookie_mode'); + window.localStorage.removeItem('baron_auth_pending_provider'); + }); +} + +async function mockUserfrontApis( + page: Page, + options: MockOptions = {}, +): Promise { + const sessionStatus = options.sessionStatus ?? 200; + + await page.route('**/api/v1/**', async (route: Route) => { + const requestUrl = new URL(route.request().url()); + const path = requestUrl.pathname; + + if (path.endsWith('/api/v1/user/me')) { + if (sessionStatus === 200) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'e2e-user', + email: 'e2e@example.com', + name: 'E2E User', + phone: '+821012341234', + department: 'QA', + affiliationType: 'employee', + companyCode: 'BARON', + tenant: { + id: 'tenant-1', + name: 'Baron', + slug: 'baron', + description: 'E2E tenant', + }, + }), + }); + return; + } + + await route.fulfill({ + status: sessionStatus, + contentType: 'application/json', + body: JSON.stringify({ error: 'unauthorized' }), + }); + return; + } + + if (path.endsWith('/api/v1/user/rp/linked')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [] }), + }); + return; + } + + if (path.endsWith('/api/v1/audit/auth/timeline')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], next_cursor: '' }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/qr/approve')) { + const body = route.request().postDataJSON() as { pendingRef?: string }; + options.captureApprove?.(body.pendingRef ?? null); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); +} + +test.describe('UserFront WASM auth routing', () => { + test('비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다', async ({ page }) => { + await mockUserfrontApis(page, { sessionStatus: 401 }); + + await page.goto('/ko'); + + await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); + }); + + test('로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다', async ({ + page, + }) => { + await seedTokenLogin(page); + await mockUserfrontApis(page); + + await page.goto('/ko'); + await expect(page).toHaveURL(/\/ko\/dashboard$/); + + await page.reload(); + await expect(page).toHaveURL(/\/ko\/dashboard$/); + }); + + test('비로그인 /ko/approve 는 signin(+notice)으로 이동한다', async ({ page }) => { + await mockUserfrontApis(page, { sessionStatus: 401 }); + + await page.goto('/ko/approve?ref=e2e-ref'); + + await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/); + }); + + test('로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다', async ({ + page, + }) => { + let approvedRef: string | null = null; + + await seedTokenLogin(page); + await mockUserfrontApis(page, { + captureApprove: (pendingRef) => { + approvedRef = pendingRef; + }, + }); + + await page.goto('/ko/approve?ref=e2e-approve-ref'); + + await expect(page).toHaveURL(/\/ko\/dashboard$/); + expect(approvedRef).toBe('e2e-approve-ref'); + }); +}); diff --git a/userfront-e2e/tests/password-and-reset.spec.ts b/userfront-e2e/tests/password-and-reset.spec.ts new file mode 100644 index 00000000..4bf89a37 --- /dev/null +++ b/userfront-e2e/tests/password-and-reset.spec.ts @@ -0,0 +1,257 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +type RequestCapture = { + loginBody?: Record; + resetBody?: Record; + resetToken?: string | null; + clientLogs: string[]; +}; + +const SIGNIN_PASSWORD_TAB_X = 522; +const SIGNIN_TAB_Y = 158; +const SIGNIN_LOGIN_ID_X = 640; +const SIGNIN_LOGIN_ID_Y = 245; +const SIGNIN_PASSWORD_X = 640; +const SIGNIN_PASSWORD_Y = 311; +const SIGNIN_SUBMIT_X = 640; +const SIGNIN_SUBMIT_Y = 381; + +const RESET_NEW_PASSWORD_X = 640; +const RESET_NEW_PASSWORD_Y = 401; +const RESET_CONFIRM_PASSWORD_X = 640; +const RESET_CONFIRM_PASSWORD_Y = 464; +const RESET_SUBMIT_X = 640; +const RESET_SUBMIT_Y = 534; + +async function clickPasswordTab(page: Page): Promise { + await page.waitForTimeout(900); + const pane = page.locator('flt-glass-pane'); + await pane.click({ + position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, + force: true, + }); + await page.waitForTimeout(120); + await pane.click({ + position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, + force: true, + }); + await page.waitForTimeout(200); +} + +async function fillAt(page: Page, x: number, y: number, value: string): Promise { + const pane = page.locator('flt-glass-pane'); + await pane.click({ position: { x, y }, force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + +async function mockAuthApis(page: Page, capture: RequestCapture): Promise { + await page.route('**/api/v1/**', async (route: Route) => { + const requestUrl = new URL(route.request().url()); + const path = requestUrl.pathname; + + if (path.endsWith('/api/v1/auth/password/login')) { + capture.loginBody = (route.request().postDataJSON() ?? {}) as Record< + string, + unknown + >; + + const loginId = String(capture.loginBody.loginId ?? ''); + const password = String(capture.loginBody.password ?? ''); + if (loginId === 'e2e@example.com' && password === 'ValidPass1!') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sessionJwt: 'e30.e30.e30', + provider: 'ory', + }), + }); + return; + } + + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'password_or_email_mismatch' }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/password/policy')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + minLength: 12, + minCharacterTypes: 3, + lowercase: true, + uppercase: true, + number: true, + nonAlphanumeric: true, + }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/password/reset/complete')) { + capture.resetBody = (route.request().postDataJSON() ?? {}) as Record< + string, + unknown + >; + capture.resetToken = requestUrl.searchParams.get('token'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok' }), + }); + return; + } + + if (path.endsWith('/api/v1/client-log')) { + const payload = (route.request().postDataJSON() ?? {}) as { + message?: string; + }; + if (payload.message != null) { + capture.clientLogs.push(payload.message); + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + return; + } + + if (path.endsWith('/api/v1/user/me')) { + const authHeader = route.request().headers()['authorization'] ?? ''; + if (!authHeader.startsWith('Bearer ')) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'unauthorized' }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'e2e-user', + email: 'e2e@example.com', + name: 'E2E User', + phone: '+821012341234', + department: 'QA', + affiliationType: 'employee', + companyCode: 'BARON', + tenant: { + id: 'tenant-1', + name: 'Baron', + slug: 'baron', + description: 'E2E tenant', + }, + }), + }); + return; + } + + if (path.endsWith('/api/v1/user/rp/linked')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [] }), + }); + return; + } + + if (path.endsWith('/api/v1/audit/auth/timeline')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], next_cursor: '' }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); +} + +test.describe('UserFront WASM password login and reset', () => { + test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => { + const capture: RequestCapture = { clientLogs: [] }; + await mockAuthApis(page, capture); + + await page.goto('/ko/signin'); + await clickPasswordTab(page); + await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); + await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!'); + await page.locator('flt-glass-pane').click({ + position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y }, + force: true, + }); + + await expect(page).toHaveURL(/\/ko\/dashboard$/); + + expect(capture.loginBody?.loginId).toBe('e2e@example.com'); + expect(capture.loginBody?.password).toBe('ValidPass1!'); + + const storedToken = await page.evaluate(() => + window.localStorage.getItem('baron_auth_token'), + ); + expect(storedToken).toBe('e30.e30.e30'); + }); + + test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => { + const capture: RequestCapture = { clientLogs: [] }; + await mockAuthApis(page, capture); + + await page.goto('/ko/signin'); + await clickPasswordTab(page); + await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); + await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!'); + await page.locator('flt-glass-pane').click({ + position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y }, + force: true, + }); + + await expect(page).toHaveURL(/\/ko\/signin$/); + await expect + .poll(() => + capture.clientLogs.some((message) => + message.includes('password_or_email_mismatch'), + ), + ) + .toBe(true); + }); + + test('reset-password에서 변경 성공 시 signin으로 이동한다', async ({ page }) => { + const capture: RequestCapture = { clientLogs: [] }; + await mockAuthApis(page, capture); + + await page.goto('/ko/reset-password?token=reset-token-e2e'); + await page.waitForTimeout(900); + await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A'); + await fillAt( + page, + RESET_CONFIRM_PASSWORD_X, + RESET_CONFIRM_PASSWORD_Y, + 'ValidPass1!A', + ); + await page.locator('flt-glass-pane').click({ + position: { x: RESET_SUBMIT_X, y: RESET_SUBMIT_Y }, + force: true, + }); + + await expect(page).toHaveURL(/\/ko\/signin$/); + expect(capture.resetToken).toBe('reset-token-e2e'); + expect(capture.resetBody?.newPassword).toBe('ValidPass1!A'); + }); +}); diff --git a/userfront-e2e/tests/profile-department.spec.ts b/userfront-e2e/tests/profile-department.spec.ts new file mode 100644 index 00000000..d3a9a914 --- /dev/null +++ b/userfront-e2e/tests/profile-department.spec.ts @@ -0,0 +1,275 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +type ProfileState = { + department: string; + getMeCount: number; + putBodies: Array>; +}; + +const PROFILE_DEPARTMENT_EDIT_X = 1170; +const PROFILE_DEPARTMENT_EDIT_Y = 680; +const PROFILE_DEPARTMENT_INPUT_X = 110; +const PROFILE_DEPARTMENT_INPUT_Y = 685; +const PROFILE_BLUR_X = 200; +const PROFILE_BLUR_Y = 260; + +async function seedTokenLogin(page: Page): Promise { + await page.addInitScript(() => { + window.localStorage.setItem('baron_auth_token', 'e30.e30.e30'); + window.localStorage.setItem('baron_auth_provider', 'ory'); + window.localStorage.removeItem('baron_auth_cookie_mode'); + window.localStorage.removeItem('baron_auth_pending_provider'); + }); +} + +async function fillAt(page: Page, x: number, y: number, value: string): Promise { + const pane = page.locator('flt-glass-pane'); + await pane.click({ position: { x, y }, force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + +async function openDepartmentEditor(page: Page): Promise { + await page.locator('flt-glass-pane').click({ + position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y }, + force: true, + }); + await page.waitForTimeout(200); +} + +async function blurDepartmentEditor(page: Page): Promise { + await page.locator('flt-glass-pane').click({ + position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y }, + force: true, + }); + await page.waitForTimeout(250); +} + +async function mockProfileApis(page: Page, state: ProfileState): Promise { + await page.route('**/api/v1/**', async (route: Route) => { + const request = route.request(); + const requestUrl = new URL(request.url()); + const path = requestUrl.pathname; + const method = request.method().toUpperCase(); + + if (path.endsWith('/api/v1/user/me') && method === 'GET') { + const authHeader = request.headers()['authorization'] ?? ''; + if (!authHeader.startsWith('Bearer ')) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'unauthorized' }), + }); + return; + } + state.getMeCount += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'e2e-user', + email: 'e2e@example.com', + name: 'E2E User', + phone: '+821012341234', + department: state.department, + affiliationType: 'employee', + companyCode: 'BARON', + tenant: { + id: 'tenant-1', + name: 'Baron', + slug: 'baron', + description: 'E2E tenant', + }, + }), + }); + return; + } + + if (path.endsWith('/api/v1/user/me') && method === 'PUT') { + const body = (request.postDataJSON() ?? {}) as Record; + state.putBodies.push(body); + const nextDepartment = String(body.department ?? '').trim(); + if (nextDepartment !== '') { + state.department = nextDepartment; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'success', + updatedAt: '2026-02-24T00:00:00Z', + }), + }); + return; + } + + if (path.endsWith('/api/v1/user/rp/linked')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [] }), + }); + return; + } + + if (path.endsWith('/api/v1/audit/auth/timeline')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], next_cursor: '' }), + }); + return; + } + + if (path.endsWith('/api/v1/client-log')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); +} + +async function openProfilePage(page: Page): Promise { + await page.goto('/ko/profile'); + await expect(page).toHaveURL(/\/ko\/profile$/); + await page.waitForTimeout(1200); +} + +async function waitForInitialProfileLoad(state: ProfileState): Promise { + await expect.poll(() => state.getMeCount).toBeGreaterThan(0); +} + +test.describe('UserFront WASM profile department editing', () => { + test.afterEach(async ({ page }) => { + await page.unroute('**/api/v1/**'); + }); + + test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({ + page, + }) => { + const state: ProfileState = { + department: 'QA', + getMeCount: 0, + putBodies: [], + }; + await seedTokenLogin(page); + await mockProfileApis(page, state); + await openProfilePage(page); + await waitForInitialProfileLoad(state); + + await openDepartmentEditor(page); + await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated'); + await blurDepartmentEditor(page); + + await expect.poll(() => state.putBodies.length).toBe(1); + expect(state.putBodies[0]?.department).toBe('QA-Updated'); + expect(state.department).toBe('QA-Updated'); + + const getCountBeforeReload = state.getMeCount; + await page.reload(); + await expect(page).toHaveURL(/\/ko\/profile$/); + await expect.poll(() => state.getMeCount).toBeGreaterThan(getCountBeforeReload); + }); + + test('재현: 소속 입력만 하고 즉시 새로고침하면 저장 요청이 전송되지 않는다', async ({ + page, + }) => { + const state: ProfileState = { + department: 'QA', + getMeCount: 0, + putBodies: [], + }; + await seedTokenLogin(page); + await mockProfileApis(page, state); + await openProfilePage(page); + await waitForInitialProfileLoad(state); + + await openDepartmentEditor(page); + await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro'); + + await page.reload(); + await expect(page).toHaveURL(/\/ko\/profile$/); + expect(state.putBodies).toHaveLength(0); + expect(state.department).toBe('QA'); + }); + + test('소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다', async ({ + page, + }) => { + const state: ProfileState = { + department: 'QA', + getMeCount: 0, + putBodies: [], + }; + await seedTokenLogin(page); + await mockProfileApis(page, state); + await openProfilePage(page); + await waitForInitialProfileLoad(state); + + await openDepartmentEditor(page); + await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA'); + await blurDepartmentEditor(page); + + expect(state.putBodies).toHaveLength(0); + }); + + test('소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다', async ({ page }) => { + const state: ProfileState = { + department: 'QA', + getMeCount: 0, + putBodies: [], + }; + await seedTokenLogin(page); + await mockProfileApis(page, state); + await openProfilePage(page); + await waitForInitialProfileLoad(state); + + await openDepartmentEditor(page); + await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, ''); + await blurDepartmentEditor(page); + + expect(state.putBodies).toHaveLength(0); + expect(state.department).toBe('QA'); + }); + + test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => { + const state: ProfileState = { + department: 'QA', + getMeCount: 0, + putBodies: [], + }; + await seedTokenLogin(page); + await mockProfileApis(page, state); + await openProfilePage(page); + await waitForInitialProfileLoad(state); + + await openDepartmentEditor(page); + await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1'); + await blurDepartmentEditor(page); + await expect.poll(() => state.putBodies.length).toBe(1); + + await page.reload(); + await expect(page).toHaveURL(/\/ko\/profile$/); + await page.waitForTimeout(1200); + + await openDepartmentEditor(page); + await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2'); + await blurDepartmentEditor(page); + await expect.poll(() => state.putBodies.length).toBe(2); + + expect(state.putBodies[0]?.department).toBe('QA-1'); + expect(state.putBodies[1]?.department).toBe('QA-2'); + expect(state.department).toBe('QA-2'); + }); +}); diff --git a/userfront-e2e/tests/route-inventory.spec.ts b/userfront-e2e/tests/route-inventory.spec.ts new file mode 100644 index 00000000..79f7b4b2 --- /dev/null +++ b/userfront-e2e/tests/route-inventory.spec.ts @@ -0,0 +1,320 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +async function seedTokenLogin(page: Page): Promise { + await page.addInitScript(() => { + window.localStorage.setItem('baron_auth_token', 'e30.e30.e30'); + window.localStorage.setItem('baron_auth_provider', 'ory'); + window.localStorage.removeItem('baron_auth_cookie_mode'); + window.localStorage.removeItem('baron_auth_pending_provider'); + }); +} + +async function mockInventoryApis(page: Page): Promise { + await page.route('**/api/v1/**', async (route: Route) => { + const requestUrl = new URL(route.request().url()); + const path = requestUrl.pathname; + const method = route.request().method().toUpperCase(); + + if (path.endsWith('/api/v1/user/me')) { + const authHeader = route.request().headers()['authorization'] ?? ''; + if (authHeader.startsWith('Bearer ')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'e2e-user', + email: 'e2e@example.com', + name: 'E2E User', + phone: '+821012341234', + department: 'QA', + affiliationType: 'employee', + companyCode: 'BARON', + tenant: { + id: 'tenant-1', + name: 'Baron', + slug: 'baron', + description: 'E2E tenant', + }, + }), + }); + return; + } + + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'unauthorized' }), + }); + return; + } + + if (path.endsWith('/api/v1/user/rp/linked')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [] }), + }); + return; + } + + if (path.endsWith('/api/v1/audit/auth/timeline')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], next_cursor: '' }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/password/policy')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + minLength: 12, + minCharacterTypes: 3, + lowercase: true, + uppercase: true, + number: true, + nonAlphanumeric: true, + }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/magic-link/verify')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'approved' }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/login/code/verify')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'approved' }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/login/code/verify-short')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'approved' }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/consent') && method === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + client: { + client_name: 'E2E Client', + client_id: 'e2e-client', + }, + requested_scope: ['openid'], + scope_details: { + openid: { + description: 'OpenID', + mandatory: true, + }, + }, + }), + }); + return; + } + + if (path.endsWith('/api/v1/auth/qr/approve')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + return; + } + + if (path.endsWith('/api/v1/client-log')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); +} + +test.describe('UserFront WASM route inventory (unauth)', () => { + test.beforeEach(async ({ page }) => { + await mockInventoryApis(page); + }); + + test('route: /', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/(ko|en)\/signin(?:\?.*)?$/); + }); + + test('route: /ko', async ({ page }) => { + await page.goto('/ko'); + await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); + }); + + test('route: /ko/dashboard', async ({ page }) => { + await page.goto('/ko/dashboard'); + await expect(page).toHaveURL(/\/ko\/signin$/); + }); + + test('route: /ko/profile', async ({ page }) => { + await page.goto('/ko/profile'); + await expect(page).toHaveURL(/\/ko\/signin$/); + }); + + test('route: /ko/admin/users', async ({ page }) => { + await page.goto('/ko/admin/users'); + await expect(page).toHaveURL(/\/ko\/signin$/); + }); + + test('route: /ko/scan', async ({ page }) => { + await page.goto('/ko/scan'); + await expect(page).toHaveURL(/\/ko\/signin$/); + }); + + test('route: /ko/signin', async ({ page }) => { + await page.goto('/ko/signin'); + await expect(page).toHaveURL(/\/ko\/signin$/); + }); + + test('route: /ko/login', async ({ page }) => { + await page.goto('/ko/login'); + await expect(page).toHaveURL(/\/ko\/login$/); + }); + + test('route: /ko/signup', async ({ page }) => { + await page.goto('/ko/signup'); + await expect(page).toHaveURL(/\/ko\/signup$/); + }); + + test('route: /ko/registration', async ({ page }) => { + await page.goto('/ko/registration'); + await expect(page).toHaveURL(/\/ko\/registration$/); + }); + + test('route: /ko/verify', async ({ page }) => { + await page.goto('/ko/verify'); + await expect(page).toHaveURL(/\/ko\/verify$/); + }); + + test('route: /ko/verify/:token', async ({ page }) => { + await page.goto('/ko/verify/e2e-token'); + await expect(page).toHaveURL(/\/ko\/verify\/e2e-token$/); + }); + + test('route: /ko/verification', async ({ page }) => { + await page.goto('/ko/verification'); + await expect(page).toHaveURL(/\/ko\/verification$/); + }); + + test('route: /ko/l/:shortCode', async ({ page }) => { + await page.goto('/ko/l/AB123456'); + await expect(page).toHaveURL(/\/ko\/l\/AB123456$/); + }); + + test('route: /ko/forgot-password', async ({ page }) => { + await page.goto('/ko/forgot-password'); + await expect(page).toHaveURL(/\/ko\/forgot-password$/); + }); + + test('route: /ko/recovery', async ({ page }) => { + await page.goto('/ko/recovery'); + await expect(page).toHaveURL(/\/ko\/recovery$/); + }); + + test('route: /ko/reset-password', async ({ page }) => { + await page.goto('/ko/reset-password?token=e2e-reset-token'); + await expect(page).toHaveURL(/\/ko\/reset-password\?token=e2e-reset-token$/); + }); + + test('route: /ko/error', async ({ page }) => { + await page.goto('/ko/error?error=invalid_request'); + await expect(page).toHaveURL(/\/ko\/error\?error=invalid_request$/); + }); + + test('route: /ko/settings', async ({ page }) => { + await page.goto('/ko/settings'); + await expect(page).toHaveURL(/\/ko\/settings$/); + }); + + test('route: /ko/consent (missing challenge)', async ({ page }) => { + await page.goto('/ko/consent'); + await expect(page).toHaveURL(/\/ko\/consent$/); + }); + + test('route: /ko/consent?consent_challenge=...', async ({ page }) => { + await page.goto('/ko/consent?consent_challenge=e2e-consent'); + await expect(page).toHaveURL(/\/ko\/consent\?consent_challenge=e2e-consent$/); + }); + + test('route: /ko/approve?ref=...', async ({ page }) => { + await page.goto('/ko/approve?ref=e2e-ref'); + await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/); + }); + + test('route: /ko/ql/:ref', async ({ page }) => { + await page.goto('/ko/ql/e2e-ref'); + await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/); + }); +}); + +test.describe('UserFront WASM route inventory (authed)', () => { + test.beforeEach(async ({ page }) => { + await seedTokenLogin(page); + await mockInventoryApis(page); + }); + + test('route: /ko -> /ko/dashboard', async ({ page }) => { + await page.goto('/ko'); + await expect(page).toHaveURL(/\/ko\/dashboard$/); + }); + + test('route: /ko/dashboard', async ({ page }) => { + await page.goto('/ko/dashboard'); + await expect(page).toHaveURL(/\/ko\/dashboard$/); + }); + + test('route: /ko/profile', async ({ page }) => { + await page.goto('/ko/profile'); + await expect(page).toHaveURL(/\/ko\/profile$/); + }); + + test('route: /ko/admin/users', async ({ page }) => { + await page.goto('/ko/admin/users'); + await expect(page).toHaveURL(/\/ko\/admin\/users$/); + }); + + test('route: /ko/scan', async ({ page }) => { + await page.goto('/ko/scan'); + await expect(page).toHaveURL(/\/ko\/scan$/); + }); + + test('route: /ko/approve?ref=... -> /ko/dashboard', async ({ page }) => { + await page.goto('/ko/approve?ref=e2e-ref'); + await expect(page).toHaveURL(/\/ko\/dashboard$/); + }); + + test('route: /ko/ql/:ref -> /ko/dashboard', async ({ page }) => { + await page.goto('/ko/ql/e2e-ref'); + await expect(page).toHaveURL(/\/ko\/dashboard$/); + }); +}); diff --git a/userfront-e2e/tsconfig.json b/userfront-e2e/tsconfig.json new file mode 100644 index 00000000..1bde1f2a --- /dev/null +++ b/userfront-e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node", "@playwright/test"], + "strict": true, + "noEmit": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["playwright.config.ts", "tests/**/*.ts", "scripts/**/*.mjs"] +} diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 20824c45..37f47071 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -54,12 +54,12 @@ empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." error = "연동 정보를 불러오지 못했습니다." [msg.userfront.dashboard.approved_session] -copy_click = "{label}: {id}\\\\\\\\n클릭하면 복사됩니다." -copy_tap = "{label}: {id}\\\\\\\\n탭하면 복사됩니다." +copy_click = "{label}: {id} \\n클릭하면 복사됩니다." +copy_tap = "{label}: {id} \\n탭하면 복사됩니다." none = "{label} 없음" [msg.userfront.dashboard.revoke] -confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다." +confirm = "{app} 앱과의 연동을 해지하시겠습니까? \\n해지하면 다음 로그인 시 다시 동의가 필요합니다." error = "해지 실패: {error}" success = "{app} 연동이 해지되었습니다." diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 59cd813b..43f51370 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -1361,6 +1361,9 @@ class _LoginScreenState extends ConsumerState child: Column( children: [ TextField( + key: const ValueKey( + 'password_login_id_input', + ), controller: _passwordLoginIdController, decoration: InputDecoration( labelText: tr( @@ -1375,6 +1378,9 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 16), TextField( + key: const ValueKey( + 'password_login_password_input', + ), controller: _passwordController, obscureText: true, decoration: InputDecoration( @@ -1390,6 +1396,9 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 24), FilledButton( + key: const ValueKey( + 'password_login_submit_button', + ), onPressed: _handlePasswordLogin, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index d2ccd716..ab470478 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -192,6 +192,7 @@ class _ResetPasswordScreenState extends State { ), const SizedBox(height: 40), TextFormField( + key: const ValueKey('reset_password_new_input'), controller: _passwordController, obscureText: _isPasswordObscured, decoration: InputDecoration( @@ -263,6 +264,7 @@ class _ResetPasswordScreenState extends State { ), const SizedBox(height: 16), TextFormField( + key: const ValueKey('reset_password_confirm_input'), controller: _confirmPasswordController, obscureText: _isConfirmPasswordObscured, decoration: InputDecoration( @@ -292,6 +294,7 @@ class _ResetPasswordScreenState extends State { ), const SizedBox(height: 24), FilledButton( + key: const ValueKey('reset_password_submit_button'), onPressed: _isLoading ? null : _handlePasswordReset, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index e89e1cfe..3e30856f 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -31,6 +32,9 @@ class _DashboardScreenState extends ConsumerState { static const _surface = Colors.white; static const _border = Color(0xFFE5E7EB); static const _subtle = Color(0xFFF7F8FA); + static const double _historySessionMinWidth = 92; + static const double _historyOtherColumnsBaselineWidth = 780; + static const int _historySessionMinVisibleChars = 8; final ScrollController _pageScrollController = ScrollController(); final ScrollController _rpScrollController = ScrollController(); @@ -1370,6 +1374,9 @@ class _DashboardScreenState extends ConsumerState { children: [ LayoutBuilder( builder: (context, constraints) { + final sessionColumnWidth = _historySessionColumnWidth( + constraints.maxWidth, + ); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( @@ -1379,10 +1386,13 @@ class _DashboardScreenState extends ConsumerState { horizontalMargin: 12, columns: [ DataColumn( - label: Text( - tr( - 'ui.userfront.audit.table.session_id', - fallback: 'Session ID', + label: SizedBox( + width: sessionColumnWidth, + child: Text( + tr( + 'ui.userfront.audit.table.session_id', + fallback: 'Session ID', + ), ), ), ), @@ -1426,10 +1436,14 @@ class _DashboardScreenState extends ConsumerState { return DataRow( cells: [ DataCell( - _selectableText( - log.sessionId.isEmpty - ? tr('ui.common.hyphen', fallback: '-') - : log.sessionId, + SizedBox( + width: sessionColumnWidth, + child: _buildHistorySessionIdCell( + log.sessionId.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : log.sessionId, + sessionColumnWidth, + ), ), ), DataCell( @@ -1474,6 +1488,36 @@ class _DashboardScreenState extends ConsumerState { ); } + double _historySessionColumnWidth(double maxWidth) { + return math.max( + _historySessionMinWidth, + maxWidth - _historyOtherColumnsBaselineWidth, + ); + } + + String _compactSessionId(String sessionId) { + if (sessionId.length <= _historySessionMinVisibleChars) { + return sessionId; + } + return '${sessionId.substring(0, _historySessionMinVisibleChars)}...'; + } + + Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) { + final compactMode = columnWidth <= _historySessionMinWidth + 0.5; + final displayText = compactMode ? _compactSessionId(sessionId) : sessionId; + final textWidget = Text( + displayText, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ); + + if (displayText == sessionId) { + return textWidget; + } + return Tooltip(message: sessionId, child: textWidget); + } + Widget _buildHistoryList(AuthTimelineState state) { return _buildHistoryContainer( child: Column( diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 861a7b88..82e9b196 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -41,6 +41,7 @@ class _ProfilePageState extends ConsumerState { bool _phoneTouched = false; bool _phoneCodeTouched = false; bool _isSavingField = false; + String? _skipAutoSaveField; String _initialPhone = ''; bool _isPhoneChanged = false; @@ -354,6 +355,10 @@ class _ProfilePageState extends ConsumerState { void _autoSaveIfEditing(UserProfile profile, String field) { if (_editingField != field) return; + if (_skipAutoSaveField == field) { + _skipAutoSaveField = null; + return; + } if (_isVerifying) return; if (_isSavingField) return; if (!_hasFieldChanged(profile, field)) { @@ -375,6 +380,10 @@ class _ProfilePageState extends ConsumerState { void _handlePhoneFocusChange(UserProfile profile) { if (_editingField != 'phone') return; + if (_skipAutoSaveField == 'phone') { + _skipAutoSaveField = null; + return; + } if (_isVerifying) return; if (_isSavingField) return; if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return; @@ -704,6 +713,7 @@ class _ProfilePageState extends ConsumerState { title: Text(label), subtitle: Text(displayValue), trailing: TextButton( + key: Key('profile-$field-edit-button'), onPressed: isUpdating ? null : () => _startEditing(field, profile), child: Text(tr('ui.common.edit')), ), @@ -720,6 +730,7 @@ class _ProfilePageState extends ConsumerState { children: [ Expanded( child: TextField( + key: Key('profile-$field-input'), controller: controller, focusNode: field == 'name' ? _nameFocus : _departmentFocus, textInputAction: TextInputAction.done, @@ -731,9 +742,15 @@ class _ProfilePageState extends ConsumerState { ), ), const SizedBox(width: 12), - OutlinedButton( - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), + Listener( + onPointerDown: (_) { + _skipAutoSaveField = field; + }, + child: OutlinedButton( + key: Key('profile-$field-cancel-button'), + onPressed: isUpdating ? null : () => _cancelEditing(profile), + child: Text(tr('ui.common.cancel')), + ), ), ], ), @@ -796,9 +813,14 @@ class _ProfilePageState extends ConsumerState { ), ), const SizedBox(width: 8), - OutlinedButton( - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), + Listener( + onPointerDown: (_) { + _skipAutoSaveField = 'phone'; + }, + child: OutlinedButton( + onPressed: isUpdating ? null : () => _cancelEditing(profile), + child: Text(tr('ui.common.cancel')), + ), ), ], ), diff --git a/userfront/test/profile_notifier_persistence_test.dart b/userfront/test/profile_notifier_persistence_test.dart new file mode 100644 index 00000000..f41b5186 --- /dev/null +++ b/userfront/test/profile_notifier_persistence_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:userfront/features/profile/data/models/user_profile_model.dart'; +import 'package:userfront/features/profile/data/repositories/profile_repository.dart'; +import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart'; + +class _FakeProfileRepository extends ProfileRepository { + _FakeProfileRepository({ + required this.initialProfile, + required this.persistUpdate, + }) : _profile = initialProfile; + + final UserProfile initialProfile; + final bool persistUpdate; + UserProfile _profile; + + int updateCount = 0; + String? lastRequestedDepartment; + + @override + Future getMyProfile() async { + return _profile; + } + + @override + Future updateMyProfile({ + required String name, + required String phone, + required String department, + }) async { + updateCount += 1; + lastRequestedDepartment = department; + if (!persistUpdate) { + return; + } + _profile = _profile.copyWith( + name: name, + phone: phone, + department: department, + ); + } +} + +UserProfile _seedProfile({required String department}) { + return UserProfile( + id: 'user-1', + email: 'qa@example.com', + name: 'QA User', + phone: '01012345678', + department: department, + affiliationType: 'employee', + companyCode: 'BARON', + ); +} + +void main() { + test('재현: 저장소가 소속 변경을 반영하지 않으면 loadProfile 이후 이전 값으로 보인다', () async { + final repository = _FakeProfileRepository( + initialProfile: _seedProfile(department: 'Old Dept'), + persistUpdate: false, + ); + final container = ProviderContainer( + overrides: [profileRepositoryProvider.overrideWithValue(repository)], + ); + addTearDown(container.dispose); + + final initial = await container.read(profileProvider.future); + expect(initial?.department, 'Old Dept'); + + await container + .read(profileProvider.notifier) + .updateProfile( + name: 'QA User', + phone: '01012345678', + department: 'New Dept', + ); + + expect(repository.updateCount, 1); + expect(repository.lastRequestedDepartment, 'New Dept'); + + await container.read(profileProvider.notifier).loadProfile(); + expect(container.read(profileProvider).value?.department, 'Old Dept'); + }); + + test('소속 변경이 저장소에 반영되면 loadProfile 이후에도 변경값이 유지된다', () async { + final repository = _FakeProfileRepository( + initialProfile: _seedProfile(department: 'Old Dept'), + persistUpdate: true, + ); + final container = ProviderContainer( + overrides: [profileRepositoryProvider.overrideWithValue(repository)], + ); + addTearDown(container.dispose); + + final initial = await container.read(profileProvider.future); + expect(initial?.department, 'Old Dept'); + + await container + .read(profileProvider.notifier) + .updateProfile( + name: 'QA User', + phone: '01012345678', + department: 'New Dept', + ); + + expect(repository.updateCount, 1); + expect(repository.lastRequestedDepartment, 'New Dept'); + + await container.read(profileProvider.notifier).loadProfile(); + expect(container.read(profileProvider).value?.department, 'New Dept'); + }); +}