forked from baron/baron-sso
e2e 구조변경
This commit is contained in:
@@ -24,6 +24,11 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
|
run_userfront_e2e_tests:
|
||||||
|
description: "Run userfront WASM Playwright E2E tests"
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
run_adminfront_tests:
|
run_adminfront_tests:
|
||||||
description: "Run adminfront Playwright tests"
|
description: "Run adminfront Playwright tests"
|
||||||
required: true
|
required: true
|
||||||
@@ -96,6 +101,10 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
args: --enable-only=gofmt,gofumpt
|
args: --enable-only=gofmt,gofumpt
|
||||||
|
|
||||||
|
- name: Sync userfront locales
|
||||||
|
run: |
|
||||||
|
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||||
|
|
||||||
- name: Format Flutter userfront
|
- name: Format Flutter userfront
|
||||||
run: |
|
run: |
|
||||||
cd userfront
|
cd userfront
|
||||||
@@ -196,6 +205,10 @@ jobs:
|
|||||||
channel: "stable"
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
- name: Sync userfront locales
|
||||||
|
run: |
|
||||||
|
/bin/sh ./scripts/sync_userfront_locales.sh
|
||||||
|
|
||||||
- name: Run userfront tests
|
- name: Run userfront tests
|
||||||
run: |
|
run: |
|
||||||
cd userfront
|
cd userfront
|
||||||
@@ -271,6 +284,222 @@ jobs:
|
|||||||
reports/userfront-test.log
|
reports/userfront-test.log
|
||||||
if-no-files-found: ignore
|
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:
|
adminfront-tests:
|
||||||
needs: lint
|
needs: lint
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||||
|
|||||||
23
Makefile
23
Makefile
@@ -107,9 +107,9 @@ logs-app:
|
|||||||
docker compose -f $(COMPOSE_APP) logs -f
|
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."
|
@echo "code-check complete."
|
||||||
|
|
||||||
code-check-i18n:
|
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/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
|
||||||
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
|
||||||
exit $$status
|
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
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -320,6 +320,7 @@ KETO_WRITE_URL = "http://keto:4467"
|
|||||||
- `run_lint`: Go/Flutter lint 실행 여부
|
- `run_lint`: Go/Flutter lint 실행 여부
|
||||||
- `run_backend_tests`: backend 테스트 실행 여부
|
- `run_backend_tests`: backend 테스트 실행 여부
|
||||||
- `run_userfront_tests`: userfront 테스트 실행 여부
|
- `run_userfront_tests`: userfront 테스트 실행 여부
|
||||||
|
- `run_userfront_e2e_tests`: userfront WASM Playwright E2E 실행 여부
|
||||||
- `run_adminfront_tests`: adminfront 테스트 실행 여부
|
- `run_adminfront_tests`: adminfront 테스트 실행 여부
|
||||||
- `run_devfront_tests`: devfront 테스트 실행 여부
|
- `run_devfront_tests`: devfront 테스트 실행 여부
|
||||||
|
|
||||||
@@ -327,6 +328,7 @@ KETO_WRITE_URL = "http://keto:4467"
|
|||||||
- `lint`
|
- `lint`
|
||||||
- `backend-tests`
|
- `backend-tests`
|
||||||
- `userfront-tests`
|
- `userfront-tests`
|
||||||
|
- `userfront-e2e-tests`
|
||||||
- `adminfront-tests`
|
- `adminfront-tests`
|
||||||
- `devfront-tests`
|
- `devfront-tests`
|
||||||
|
|
||||||
@@ -353,6 +355,17 @@ KETO_WRITE_URL = "http://keto:4467"
|
|||||||
- 단일 파일만 확인하려면 다음 명령을 사용합니다.
|
- 단일 파일만 확인하려면 다음 명령을 사용합니다.
|
||||||
- `flutter test test/locale_storage_platform_test.dart`
|
- `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)
|
### 로컬 개발 (Manual)
|
||||||
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
||||||
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
|||||||
error = "연동 정보를 불러오지 못했습니다."
|
error = "연동 정보를 불러오지 못했습니다."
|
||||||
|
|
||||||
[msg.userfront.dashboard.approved_session]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = "{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다."
|
copy_click = "{{label}}: {{id}} \\n클릭하면 복사됩니다."
|
||||||
copy_tap = "{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다."
|
copy_tap = "{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다."
|
||||||
none = "{{label}} 없음"
|
none = "{{label}} 없음"
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type apiKeyListResponse struct {
|
|||||||
|
|
||||||
func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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)
|
limit := c.QueryInt("limit", 50)
|
||||||
@@ -43,12 +43,12 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil {
|
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
|
var keys []domain.ApiKey
|
||||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&keys).Error; err != nil {
|
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))
|
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 {
|
func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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 {
|
var req struct {
|
||||||
@@ -81,11 +81,11 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
|||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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) == "" {
|
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)
|
// 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)
|
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
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{
|
apiKey := domain.ApiKey{
|
||||||
@@ -108,7 +108,7 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.Create(&apiKey).Error; err != nil {
|
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)
|
// 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 {
|
func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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")
|
id := c.Params("id")
|
||||||
if 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 {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ func NewAuditHandler(repo domain.AuditRepository) *AuditHandler {
|
|||||||
func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
||||||
var req domain.AuditLog
|
var req domain.AuditLog
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusBadRequest, "Cannot parse JSON")
|
||||||
"error": "Cannot parse JSON",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fill metadata if missing
|
// Auto-fill metadata if missing
|
||||||
@@ -43,16 +41,12 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.repo == nil {
|
if h.repo == nil {
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||||
"error": "Audit service unavailable",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.repo.Create(&req); err != nil {
|
if err := h.repo.Create(&req); err != nil {
|
||||||
// Log internal error but don't expose details
|
// Log internal error but don't expose details
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to save audit log")
|
||||||
"error": "Failed to save audit log",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||||
@@ -66,22 +60,16 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
|
|||||||
cursorRaw := c.Query("cursor")
|
cursorRaw := c.Query("cursor")
|
||||||
cursor, err := parseAuditCursor(cursorRaw)
|
cursor, err := parseAuditCursor(cursorRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor")
|
||||||
"error": "Invalid cursor",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.repo == nil {
|
if h.repo == nil {
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||||
"error": "Audit service unavailable",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logs, err := h.repo.FindPage(c.Context(), limit+1, cursor)
|
logs, err := h.repo.FindPage(c.Context(), limit+1, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs")
|
||||||
"error": "Failed to retrieve audit logs",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -121,3 +121,26 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) {
|
|||||||
json.NewDecoder(resp.Body).Decode(&initResp)
|
json.NewDecoder(resp.Body).Decode(&initResp)
|
||||||
assert.NotEmpty(t, initResp["userCode"])
|
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"])
|
||||||
|
}
|
||||||
|
|||||||
206
backend/internal/handler/auth_handler_login_code_test.go
Normal file
206
backend/internal/handler/auth_handler_login_code_test.go
Normal file
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
109
backend/internal/handler/auth_handler_profile_cache_test.go
Normal file
109
backend/internal/handler/auth_handler_profile_cache_test.go
Normal file
@@ -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"])
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ func TestQRLoginFlow_Success(t *testing.T) {
|
|||||||
var pollResp map[string]interface{}
|
var pollResp map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&pollResp)
|
json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||||
assert.Equal(t, "authorization_pending", pollResp["error"])
|
assert.Equal(t, "authorization_pending", pollResp["error"])
|
||||||
|
assert.Equal(t, "authorization_pending", pollResp["code"])
|
||||||
|
|
||||||
// 3. Mock Approval
|
// 3. Mock Approval
|
||||||
sessionData, _ := json.Marshal(map[string]string{
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/middleware"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -26,6 +27,13 @@ func newResetFlowTestApp(h *AuthHandler) *fiber.App {
|
|||||||
return 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 {
|
type testRedisRepo struct {
|
||||||
values map[string]string
|
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)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
return f(req)
|
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 {
|
func httpResponse(r *http.Request, code int, body string) *http.Response {
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
StatusCode: code,
|
StatusCode: code,
|
||||||
|
|||||||
@@ -275,15 +275,13 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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()
|
errMsg := err.Error()
|
||||||
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
|
||||||
"error": "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))
|
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 {
|
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||||
clientID := c.Params("id")
|
clientID := c.Params("id")
|
||||||
if clientID == "" {
|
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)
|
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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)
|
summary := h.mapClientSummary(*client)
|
||||||
@@ -323,10 +321,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
if summary.Type == "private" {
|
if summary.Type == "private" {
|
||||||
isAppManager, err := h.checkAppManagerPermission(c)
|
isAppManager, err := h.checkAppManagerPermission(c)
|
||||||
if err != nil {
|
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 {
|
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 {
|
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||||
clientID := c.Params("id")
|
clientID := c.Params("id")
|
||||||
if clientID == "" {
|
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 {
|
var req struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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))
|
status := strings.ToLower(strings.TrimSpace(req.Status))
|
||||||
if status != "active" && status != "inactive" {
|
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
|
// [Security] Check permission before patching
|
||||||
@@ -367,7 +365,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
if summary.Type == "private" {
|
if summary.Type == "private" {
|
||||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||||
if !isAppManager {
|
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)
|
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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)
|
summary := h.mapClientSummary(*updated)
|
||||||
@@ -396,7 +394,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||||
var req clientUpsertRequest
|
var req clientUpsertRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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, ""))
|
clientID := strings.TrimSpace(valueOr(req.ID, ""))
|
||||||
@@ -411,7 +409,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
redirectURIs := derefSlice(req.RedirectURIs, nil)
|
redirectURIs := derefSlice(req.RedirectURIs, nil)
|
||||||
if len(redirectURIs) == 0 {
|
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())
|
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")))
|
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
|
||||||
if clientType != "pkce" && clientType != "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
|
// [Security] Check permission for private clients
|
||||||
if clientType == "private" {
|
if clientType == "private" {
|
||||||
isAppManager, err := h.checkAppManagerPermission(c)
|
isAppManager, err := h.checkAppManagerPermission(c)
|
||||||
if err != nil {
|
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 {
|
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")))
|
status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
|
||||||
if status != "active" && status != "inactive" {
|
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)
|
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)
|
created, err := h.Hydra.CreateClient(c.Context(), clientReq)
|
||||||
if err != nil {
|
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
|
// 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 {
|
func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
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
|
var req clientUpsertRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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)
|
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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 := ""
|
clientType := ""
|
||||||
if req.Type != nil {
|
if req.Type != nil {
|
||||||
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||||
if clientType != "pkce" && clientType != "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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,10 +527,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
if currentSummary.Type == "private" || clientType == "private" {
|
if currentSummary.Type == "private" || clientType == "private" {
|
||||||
isAppManager, err := h.checkAppManagerPermission(c)
|
isAppManager, err := h.checkAppManagerPermission(c)
|
||||||
if err != nil {
|
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 {
|
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 {
|
if req.Status != nil {
|
||||||
status = strings.ToLower(strings.TrimSpace(*req.Status))
|
status = strings.ToLower(strings.TrimSpace(*req.Status))
|
||||||
if status != "active" && status != "inactive" {
|
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 {
|
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)
|
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)
|
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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)
|
summary := h.mapClientSummary(*updatedClient)
|
||||||
@@ -600,7 +598,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
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
|
// [Security] Check permission for private clients
|
||||||
@@ -610,16 +608,16 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
if summary.Type == "private" {
|
if summary.Type == "private" {
|
||||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||||
if !isAppManager {
|
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 err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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
|
// 1. Clean up PostgreSQL
|
||||||
@@ -638,7 +636,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
if clientID == "" {
|
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"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
@@ -678,7 +676,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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))
|
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 {
|
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
if 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"))
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
|
|
||||||
@@ -733,7 +731,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// 1. Revoke in Hydra
|
// 1. Revoke in Hydra
|
||||||
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
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)
|
// 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 {
|
func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||||
clientID := strings.TrimSpace(c.Params("id"))
|
clientID := strings.TrimSpace(c.Params("id"))
|
||||||
if clientID == "" {
|
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
|
// [Security] Check permission for private clients
|
||||||
@@ -757,7 +755,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|||||||
if summary.Type == "private" {
|
if summary.Type == "private" {
|
||||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||||
if !isAppManager {
|
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
|
// 1. Generate new secret
|
||||||
newSecret, err := generateRandomSecret(20)
|
newSecret, err := generateRandomSecret(20)
|
||||||
if err != nil {
|
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)
|
// 2. Get current client to preserve other fields (already fetched above)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
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
|
// 3. Update Hydra
|
||||||
current.ClientSecret = newSecret
|
current.ClientSecret = newSecret
|
||||||
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
||||||
if err != nil {
|
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)
|
// 4. Update Persistence (DB & Redis)
|
||||||
|
|||||||
17
backend/internal/handler/error_helper.go
Normal file
17
backend/internal/handler/error_helper.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -33,13 +33,13 @@ func (h *FederationHandler) InitiateOIDCLogin(c *fiber.Ctx) error {
|
|||||||
loginChallenge := c.Query("login_challenge")
|
loginChallenge := c.Query("login_challenge")
|
||||||
|
|
||||||
if providerID == "" || loginChallenge == "" {
|
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)
|
redirectURL, err := h.fedSvc.InitiateOIDCLogin(c.Context(), providerID, loginChallenge)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log the error properly in a real application
|
// 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)
|
return c.Redirect(redirectURL, fiber.StatusFound)
|
||||||
@@ -51,12 +51,12 @@ func (h *FederationHandler) HandleOIDCCallback(c *fiber.Ctx) error {
|
|||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
|
|
||||||
if code == "" || 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)
|
redirectURL, err := h.fedSvc.HandleOIDCCallback(c.Context(), code, state)
|
||||||
if err != nil {
|
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)
|
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 {
|
func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error {
|
||||||
clientID := c.Params("clientId")
|
clientID := c.Params("clientId")
|
||||||
if 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
|
var configs []domain.IdentityProviderConfig
|
||||||
if err := h.db.Where("client_id = ?", clientID).Find(&configs).Error; err != nil {
|
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)
|
return c.JSON(configs)
|
||||||
@@ -83,12 +83,12 @@ func (h *FederationHandler) ListIdpConfigsForClient(c *fiber.Ctx) error {
|
|||||||
func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
||||||
clientID := c.Params("clientId")
|
clientID := c.Params("clientId")
|
||||||
if 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
|
var req domain.IdentityProviderConfig
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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
|
// Assign clientID from path parameter
|
||||||
@@ -96,14 +96,14 @@ func (h *FederationHandler) CreateIdpConfigForClient(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if req.DisplayName == "" || req.ProviderType == "" {
|
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
|
// TODO: Optionally, validate if the clientID exists in Hydra
|
||||||
|
|
||||||
// Create in DB
|
// Create in DB
|
||||||
if err := h.db.Create(&req).Error; err != nil {
|
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)
|
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 {
|
func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error {
|
||||||
tenantID := c.Params("tenantId")
|
tenantID := c.Params("tenantId")
|
||||||
if 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.
|
// 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.
|
// Note: This now queries client_id, which is incorrect for tenants.
|
||||||
// This method is deprecated.
|
// This method is deprecated.
|
||||||
if err := h.db.Where("tenant_id = ?", tenantID).Find(&configs).Error; err != nil {
|
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)
|
return c.JSON(configs)
|
||||||
@@ -133,26 +133,26 @@ func (h *FederationHandler) ListIdpConfigsForTenant(c *fiber.Ctx) error {
|
|||||||
func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error {
|
func (h *FederationHandler) CreateIdpConfig(c *fiber.Ctx) error {
|
||||||
var req domain.IdentityProviderConfig
|
var req domain.IdentityProviderConfig
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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
|
// Basic validation - This is the old validation logic
|
||||||
if req.ClientID == "" || req.DisplayName == "" || req.ProviderType == "" {
|
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.
|
// This check is now incorrect and deprecated.
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
if err := h.db.First(&tenant, "id = ?", req.ClientID).Error; err != nil {
|
if err := h.db.First(&tenant, "id = ?", req.ClientID).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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
|
// Create in DB
|
||||||
if err := h.db.Create(&req).Error; err != nil {
|
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)
|
return c.Status(fiber.StatusCreated).JSON(req)
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ func NewRelyingPartyHandler(s service.RelyingPartyService, kratos *service.Krato
|
|||||||
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
|
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
|
||||||
tenantID := c.Params("tenantId")
|
tenantID := c.Params("tenantId")
|
||||||
if 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
|
var req domain.HydraClient
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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)
|
rp, err := h.Service.Create(c.Context(), tenantID, req)
|
||||||
if err != nil {
|
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)
|
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 {
|
func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
|
||||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if !ok {
|
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
|
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)
|
rps, err = h.Service.List(c.Context(), *profile.TenantID)
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", profile.Role)
|
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 {
|
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)
|
return c.JSON(rps)
|
||||||
@@ -64,12 +64,12 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
|
|||||||
func (h *RelyingPartyHandler) List(c *fiber.Ctx) error {
|
func (h *RelyingPartyHandler) List(c *fiber.Ctx) error {
|
||||||
tenantID := c.Params("tenantId")
|
tenantID := c.Params("tenantId")
|
||||||
if 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)
|
rps, err := h.Service.List(c.Context(), tenantID)
|
||||||
if err != nil {
|
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)
|
return c.JSON(rps)
|
||||||
@@ -79,7 +79,7 @@ func (h *RelyingPartyHandler) Get(c *fiber.Ctx) error {
|
|||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
rp, hydraClient, err := h.Service.Get(c.Context(), id)
|
rp, hydraClient, err := h.Service.Get(c.Context(), id)
|
||||||
if err != nil {
|
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{
|
return c.JSON(fiber.Map{
|
||||||
@@ -92,12 +92,12 @@ func (h *RelyingPartyHandler) Update(c *fiber.Ctx) error {
|
|||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
var req domain.HydraClient
|
var req domain.HydraClient
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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)
|
rp, err := h.Service.Update(c.Context(), id, req)
|
||||||
if err != nil {
|
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)
|
return c.JSON(rp)
|
||||||
@@ -106,7 +106,7 @@ func (h *RelyingPartyHandler) Update(c *fiber.Ctx) error {
|
|||||||
func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
|
func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
if err := h.Service.Delete(c.Context(), id); err != nil {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
|||||||
@@ -56,17 +56,17 @@ func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
|||||||
AdminEmail string `json:"adminEmail"`
|
AdminEmail string `json:"adminEmail"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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
|
// Basic validation
|
||||||
if req.Name == "" || req.Domain == "" || req.AdminEmail == "" {
|
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)
|
tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail)
|
||||||
if err != nil {
|
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{
|
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 {
|
func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
||||||
tenantID := c.Params("id")
|
tenantID := c.Params("id")
|
||||||
if tenantID == "" {
|
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 {
|
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"})
|
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 {
|
func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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)
|
limit := c.QueryInt("limit", 50)
|
||||||
@@ -104,12 +104,12 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil {
|
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
|
var tenants []domain.Tenant
|
||||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
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))
|
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 {
|
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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"))
|
tenantID := strings.TrimSpace(c.Params("id"))
|
||||||
if tenantID == "" {
|
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
|
var tenant domain.Tenant
|
||||||
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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))
|
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 {
|
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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 {
|
var req struct {
|
||||||
@@ -155,12 +155,12 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
Config map[string]any `json:"config"`
|
Config map[string]any `json:"config"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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)
|
name := strings.TrimSpace(req.Name)
|
||||||
if 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)
|
slug := normalizeTenantSlug(req.Slug)
|
||||||
@@ -168,7 +168,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
slug = normalizeTenantSlug(name)
|
slug = normalizeTenantSlug(name)
|
||||||
}
|
}
|
||||||
if slug == "" {
|
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)
|
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)
|
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
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 {
|
if req.Config != nil {
|
||||||
@@ -195,20 +195,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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"))
|
tenantID := strings.TrimSpace(c.Params("id"))
|
||||||
if tenantID == "" {
|
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
|
var tenant domain.Tenant
|
||||||
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
|
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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 {
|
var req struct {
|
||||||
@@ -220,27 +220,27 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
Config map[string]any `json:"config"`
|
Config map[string]any `json:"config"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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 {
|
if req.Name != nil {
|
||||||
name := strings.TrimSpace(*req.Name)
|
name := strings.TrimSpace(*req.Name)
|
||||||
if 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
|
tenant.Name = name
|
||||||
}
|
}
|
||||||
if req.Slug != nil {
|
if req.Slug != nil {
|
||||||
slug := normalizeTenantSlug(*req.Slug)
|
slug := normalizeTenantSlug(*req.Slug)
|
||||||
if 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 {
|
if slug != tenant.Slug {
|
||||||
var exists domain.Tenant
|
var exists domain.Tenant
|
||||||
if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil {
|
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) {
|
} 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
|
tenant.Slug = slug
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
status := normalizeTenantStatus(*req.Status)
|
status := normalizeTenantStatus(*req.Status)
|
||||||
if 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
|
tenant.Status = status
|
||||||
}
|
}
|
||||||
@@ -260,14 +260,14 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
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
|
// Update domains if provided
|
||||||
if req.Domains != nil {
|
if req.Domains != nil {
|
||||||
// Simple approach: Delete existing and recreate
|
// Simple approach: Delete existing and recreate
|
||||||
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
|
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 {
|
for _, d := range req.Domains {
|
||||||
if strings.TrimSpace(d) == "" {
|
if strings.TrimSpace(d) == "" {
|
||||||
@@ -275,7 +275,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
// Use repository for consistency
|
// Use repository for consistency
|
||||||
if err := repository.NewTenantRepository(h.DB).AddDomain(c.Context(), tenant.ID, strings.TrimSpace(d), true); err != nil {
|
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 {
|
func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
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"))
|
tenantID := strings.TrimSpace(c.Params("id"))
|
||||||
if tenantID == "" {
|
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
|
var tenant domain.Tenant
|
||||||
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
|
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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
|
// Rename slug to release it for reuse before soft delete
|
||||||
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
|
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
|
||||||
if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil {
|
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 {
|
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)
|
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 {
|
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
|
||||||
tenantID := c.Params("id")
|
tenantID := c.Params("id")
|
||||||
if tenantID == "" {
|
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
|
// Fetch admins from Keto
|
||||||
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "")
|
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminInfo struct {
|
type adminInfo struct {
|
||||||
@@ -372,11 +372,11 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
|
|||||||
tenantID := c.Params("id")
|
tenantID := c.Params("id")
|
||||||
userID := c.Params("userId")
|
userID := c.Params("userId")
|
||||||
if tenantID == "" || 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 {
|
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)
|
return c.SendStatus(fiber.StatusOK)
|
||||||
@@ -386,11 +386,11 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
|
|||||||
tenantID := c.Params("id")
|
tenantID := c.Params("id")
|
||||||
userID := c.Params("userId")
|
userID := c.Params("userId")
|
||||||
if tenantID == "" || 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 {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func (h *UserGroupHandler) List(c *fiber.Ctx) error {
|
|||||||
tenantID := c.Params("tenantId")
|
tenantID := c.Params("tenantId")
|
||||||
groups, err := h.Service.List(c.Context(), tenantID)
|
groups, err := h.Service.List(c.Context(), tenantID)
|
||||||
if err != nil {
|
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)
|
return c.JSON(groups)
|
||||||
}
|
}
|
||||||
@@ -28,12 +28,12 @@ func (h *UserGroupHandler) Create(c *fiber.Ctx) error {
|
|||||||
tenantID := c.Params("tenantId")
|
tenantID := c.Params("tenantId")
|
||||||
var group domain.UserGroup
|
var group domain.UserGroup
|
||||||
if err := c.BodyParser(&group); err != nil {
|
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
|
group.TenantID = tenantID
|
||||||
|
|
||||||
if err := h.Service.Create(c.Context(), &group); err != nil {
|
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)
|
return c.Status(fiber.StatusCreated).JSON(group)
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
|
|||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
group, err := h.Service.Get(c.Context(), id)
|
group, err := h.Service.Get(c.Context(), id)
|
||||||
if err != nil {
|
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)
|
return c.JSON(group)
|
||||||
}
|
}
|
||||||
@@ -51,12 +51,12 @@ func (h *UserGroupHandler) Update(c *fiber.Ctx) error {
|
|||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
var group domain.UserGroup
|
var group domain.UserGroup
|
||||||
if err := c.BodyParser(&group); err != nil {
|
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
|
group.ID = id
|
||||||
|
|
||||||
if err := h.Service.Update(c.Context(), &group); err != nil {
|
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)
|
return c.JSON(group)
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func (h *UserGroupHandler) Update(c *fiber.Ctx) error {
|
|||||||
func (h *UserGroupHandler) Delete(c *fiber.Ctx) error {
|
func (h *UserGroupHandler) Delete(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
if err := h.Service.Delete(c.Context(), id); err != nil {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -75,11 +75,11 @@ func (h *UserGroupHandler) AddMember(c *fiber.Ctx) error {
|
|||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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 {
|
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)
|
return c.SendStatus(fiber.StatusOK)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error {
|
|||||||
userID := c.Params("userId")
|
userID := c.Params("userId")
|
||||||
|
|
||||||
if err := h.Service.RemoveMember(c.Context(), groupID, userID); err != nil {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -101,11 +101,11 @@ func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
|
|||||||
Relation string `json:"relation"`
|
Relation string `json:"relation"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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 {
|
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)
|
return c.SendStatus(fiber.StatusOK)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func (h *UserGroupHandler) ListRoles(c *fiber.Ctx) error {
|
|||||||
groupID := c.Params("id")
|
groupID := c.Params("id")
|
||||||
roles, err := h.Service.ListRoles(c.Context(), groupID)
|
roles, err := h.Service.ListRoles(c.Context(), groupID)
|
||||||
if err != nil {
|
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)
|
return c.JSON(roles)
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error {
|
|||||||
relation := c.Params("relation")
|
relation := c.Params("relation")
|
||||||
|
|
||||||
if err := h.Service.RemoveRoleFromTenant(c.Context(), groupID, tenantID, relation); err != nil {
|
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)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
// Fetch from UserRepo
|
// Fetch from UserRepo
|
||||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search)
|
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search)
|
||||||
if err != nil {
|
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))
|
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 {
|
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||||
if h.KratosAdmin == nil {
|
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"))
|
userID := strings.TrimSpace(c.Params("id"))
|
||||||
if userID == "" {
|
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)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
if identity == nil {
|
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
|
// [New] Check access scope
|
||||||
@@ -175,7 +175,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
|||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
if requester.CompanyCode == "" || compCode != requester.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 {
|
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||||
if h.OryProvider == nil || h.KratosAdmin == nil {
|
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 {
|
var req struct {
|
||||||
@@ -198,19 +198,19 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
Metadata map[string]any `json:"metadata"`
|
Metadata map[string]any `json:"metadata"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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)
|
email := strings.TrimSpace(req.Email)
|
||||||
if 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, ".") {
|
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)
|
name := strings.TrimSpace(req.Name)
|
||||||
if 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)
|
password := strings.TrimSpace(req.Password)
|
||||||
@@ -230,13 +230,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
if password == "" {
|
if password == "" {
|
||||||
generated, genErr := utils.GeneratePasswordWithPolicy(policy)
|
generated, genErr := utils.GeneratePasswordWithPolicy(policy)
|
||||||
if genErr != nil {
|
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
|
password = generated
|
||||||
generatedPassword = generated
|
generatedPassword = generated
|
||||||
} else {
|
} else {
|
||||||
if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil {
|
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)
|
identityID, err := h.OryProvider.CreateUser(brokerUser, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
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
|
// [New] Local DB Sync
|
||||||
@@ -334,7 +334,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
if identity == nil {
|
if identity == nil {
|
||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
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 {
|
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||||
if h.KratosAdmin == nil {
|
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"))
|
userID := strings.TrimSpace(c.Params("id"))
|
||||||
if userID == "" {
|
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)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
if identity == nil {
|
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
|
// [New] Check access scope
|
||||||
@@ -370,7 +370,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
if requester.CompanyCode == "" || compCode != requester.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"`
|
Metadata map[string]any `json:"metadata"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
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
|
// [New] Tenant Admin restriction: Cannot change companyCode
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
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)
|
state := normalizeKratosState(req.Status)
|
||||||
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
||||||
if err != nil {
|
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
|
// [New] Local DB Sync
|
||||||
@@ -519,7 +519,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
if req.Password != nil && *req.Password != "" {
|
if req.Password != nil && *req.Password != "" {
|
||||||
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
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 {
|
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||||
if h.KratosAdmin == nil {
|
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"))
|
userID := strings.TrimSpace(c.Params("id"))
|
||||||
if userID == "" {
|
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
|
// [New] Check access scope before deletion
|
||||||
@@ -543,13 +543,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
if err == nil && identity != nil {
|
if err == nil && identity != nil {
|
||||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||||
if requester.CompanyCode == "" || compCode != requester.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 {
|
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)
|
// [Keto] Cleanup relations (Best effort)
|
||||||
|
|||||||
17
backend/internal/middleware/error_helper.go
Normal file
17
backend/internal/middleware/error_helper.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||||
if err != nil {
|
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
|
// Store profile in locals for further use in handlers
|
||||||
@@ -49,7 +49,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
|
|
||||||
if objectID == "" {
|
if objectID == "" {
|
||||||
slog.Error("RBAC Keto check failed: missing object id", "path", c.Path())
|
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)
|
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)
|
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
|
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 {
|
if !allowed {
|
||||||
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
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()
|
return c.Next()
|
||||||
@@ -85,9 +85,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
|||||||
|
|
||||||
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_role): "+err.Error())
|
||||||
"error": "unauthorized (trace:rbac_role): " + err.Error(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store profile in locals for further use in handlers
|
// Store profile in locals for further use in handlers
|
||||||
@@ -114,9 +112,7 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
|||||||
"allowedRoles", config.AllowedRoles,
|
"allowedRoles", config.AllowedRoles,
|
||||||
"path", c.Path(),
|
"path", c.Path(),
|
||||||
)
|
)
|
||||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions")
|
||||||
"error": "forbidden: insufficient permissions",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store profile in locals for further use in handlers
|
// 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)
|
profile, err := config.AuthHandler.GetEnrichedProfile(c)
|
||||||
if err != nil {
|
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
|
// Store profile in locals for further use in handlers
|
||||||
@@ -174,13 +170,11 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
|||||||
|
|
||||||
if !isAllowed {
|
if !isAllowed {
|
||||||
slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
|
slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
|
||||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: you do not have access to this tenant")
|
||||||
"error": "forbidden: you do not have access to this tenant",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"})
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- Backend(Go): **104개**
|
- Backend(Go): **104개**
|
||||||
- UserFront(Flutter): **47개**
|
- UserFront(Flutter): **47개**
|
||||||
- AdminFront/DevFront(Playwright): **4개**
|
- AdminFront/DevFront(Playwright): **4개**
|
||||||
|
- UserFront WASM Playwright E2E: **42개**
|
||||||
|
|
||||||
### Backend 패키지별 커버리지
|
### Backend 패키지별 커버리지
|
||||||
- `cmd/server`: 2.6%
|
- `cmd/server`: 2.6%
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
|
- UserFront 테스트 전수 목록: `docs/test-plan/userfront-test-inventory.md`
|
||||||
- AdminFront/DevFront E2E 전수 목록: `docs/test-plan/web-e2e-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-expansion-plan.md`
|
||||||
|
- UserFront WASM Playwright E2E 전수 목록: `docs/test-plan/userfront-wasm-e2e-route-inventory.md`
|
||||||
|
|
||||||
## 4) 실행 커맨드
|
## 4) 실행 커맨드
|
||||||
- Backend 전체 테스트: `cd backend && go test ./...`
|
- Backend 전체 테스트: `cd backend && go test ./...`
|
||||||
@@ -36,7 +38,8 @@
|
|||||||
- UserFront 테스트: `cd userfront && flutter test`
|
- UserFront 테스트: `cd userfront && flutter test`
|
||||||
- AdminFront E2E: `cd adminfront && npm test`
|
- AdminFront E2E: `cd adminfront && npm test`
|
||||||
- DevFront E2E: `cd devfront && 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) 유지 원칙
|
## 5) 유지 원칙
|
||||||
- 신규 기능은 관련 테스트를 반드시 추가합니다.
|
- 신규 기능은 관련 테스트를 반드시 추가합니다.
|
||||||
|
|||||||
@@ -58,12 +58,46 @@
|
|||||||
- 범위 6 구현
|
- 범위 6 구현
|
||||||
- null-check 복구 라우팅 검증
|
- 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 회귀군으로 자동화됩니다.
|
- 핵심 인증 플로우(로그인/새로고침/리다이렉트/QR)가 Playwright 회귀군으로 자동화됩니다.
|
||||||
- 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다.
|
- 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다.
|
||||||
- PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다.
|
- PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다.
|
||||||
|
|
||||||
## 5) 운영 원칙
|
## 6) 운영 원칙
|
||||||
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
|
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
|
||||||
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
|
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
|
||||||
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
|
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
|
||||||
|
- 테스트 하네스는 단계별로 초기화/정리합니다.
|
||||||
|
- 예: `beforeEach`에서 auth/mock state 재시드, `afterEach`에서 route mock 해제(`page.unroute`) 및 누수 상태 정리
|
||||||
|
|||||||
59
docs/test-plan/userfront-wasm-e2e-route-inventory.md
Normal file
59
docs/test-plan/userfront-wasm-e2e-route-inventory.md
Normal file
@@ -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)
|
||||||
@@ -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] 링크 클릭/코드 입력 로그인 실패 재현 및 수정`
|
||||||
@@ -18,6 +18,24 @@ saman = "Saman"
|
|||||||
[err.common]
|
[err.common]
|
||||||
unknown = "An unknown error occurred."
|
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]
|
||||||
|
|
||||||
[err.userfront.auth_proxy]
|
[err.userfront.auth_proxy]
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ saman = "삼안"
|
|||||||
[err.common]
|
[err.common]
|
||||||
unknown = "알 수 없는 오류가 발생했습니다."
|
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]
|
||||||
|
|
||||||
[err.userfront.auth_proxy]
|
[err.userfront.auth_proxy]
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ saman = ""
|
|||||||
[err.common]
|
[err.common]
|
||||||
unknown = ""
|
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]
|
||||||
|
|
||||||
[err.userfront.auth_proxy]
|
[err.userfront.auth_proxy]
|
||||||
|
|||||||
3
userfront-e2e/.gitignore
vendored
Normal file
3
userfront-e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
29
userfront-e2e/README.md
Normal file
29
userfront-e2e/README.md
Normal file
@@ -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`)
|
||||||
111
userfront-e2e/package-lock.json
generated
Normal file
111
userfront-e2e/package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
userfront-e2e/package.json
Normal file
18
userfront-e2e/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
userfront-e2e/playwright.config.ts
Normal file
39
userfront-e2e/playwright.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
68
userfront-e2e/scripts/serve-userfront-build.mjs
Normal file
68
userfront-e2e/scripts/serve-userfront-build.mjs
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
143
userfront-e2e/tests/auth-routing.spec.ts
Normal file
143
userfront-e2e/tests/auth-routing.spec.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
257
userfront-e2e/tests/password-and-reset.spec.ts
Normal file
257
userfront-e2e/tests/password-and-reset.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
type RequestCapture = {
|
||||||
|
loginBody?: Record<string, unknown>;
|
||||||
|
resetBody?: Record<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
275
userfront-e2e/tests/profile-department.spec.ts
Normal file
275
userfront-e2e/tests/profile-department.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
type ProfileState = {
|
||||||
|
department: string;
|
||||||
|
getMeCount: number;
|
||||||
|
putBodies: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
await page.goto('/ko/profile');
|
||||||
|
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForInitialProfileLoad(state: ProfileState): Promise<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
320
userfront-e2e/tests/route-inventory.spec.ts
Normal file
320
userfront-e2e/tests/route-inventory.spec.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
async function seedTokenLogin(page: Page): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
userfront-e2e/tsconfig.json
Normal file
14
userfront-e2e/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
@@ -54,12 +54,12 @@ empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
|||||||
error = "연동 정보를 불러오지 못했습니다."
|
error = "연동 정보를 불러오지 못했습니다."
|
||||||
|
|
||||||
[msg.userfront.dashboard.approved_session]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = "{label}: {id}\\\\\\\\n클릭하면 복사됩니다."
|
copy_click = "{label}: {id} \\n클릭하면 복사됩니다."
|
||||||
copy_tap = "{label}: {id}\\\\\\\\n탭하면 복사됩니다."
|
copy_tap = "{label}: {id} \\n탭하면 복사됩니다."
|
||||||
none = "{label} 없음"
|
none = "{label} 없음"
|
||||||
|
|
||||||
[msg.userfront.dashboard.revoke]
|
[msg.userfront.dashboard.revoke]
|
||||||
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
|
confirm = "{app} 앱과의 연동을 해지하시겠습니까? \\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
|
||||||
error = "해지 실패: {error}"
|
error = "해지 실패: {error}"
|
||||||
success = "{app} 연동이 해지되었습니다."
|
success = "{app} 연동이 해지되었습니다."
|
||||||
|
|
||||||
|
|||||||
@@ -1361,6 +1361,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
|
key: const ValueKey(
|
||||||
|
'password_login_id_input',
|
||||||
|
),
|
||||||
controller: _passwordLoginIdController,
|
controller: _passwordLoginIdController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
@@ -1375,6 +1378,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
|
key: const ValueKey(
|
||||||
|
'password_login_password_input',
|
||||||
|
),
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -1390,6 +1396,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
|
key: const ValueKey(
|
||||||
|
'password_login_submit_button',
|
||||||
|
),
|
||||||
onPressed: _handlePasswordLogin,
|
onPressed: _handlePasswordLogin,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
|
key: const ValueKey('reset_password_new_input'),
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: _isPasswordObscured,
|
obscureText: _isPasswordObscured,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -263,6 +264,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
|
key: const ValueKey('reset_password_confirm_input'),
|
||||||
controller: _confirmPasswordController,
|
controller: _confirmPasswordController,
|
||||||
obscureText: _isConfirmPasswordObscured,
|
obscureText: _isConfirmPasswordObscured,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -292,6 +294,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
|
key: const ValueKey('reset_password_submit_button'),
|
||||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -31,6 +32,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
static const _surface = Colors.white;
|
static const _surface = Colors.white;
|
||||||
static const _border = Color(0xFFE5E7EB);
|
static const _border = Color(0xFFE5E7EB);
|
||||||
static const _subtle = Color(0xFFF7F8FA);
|
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 _pageScrollController = ScrollController();
|
||||||
final ScrollController _rpScrollController = ScrollController();
|
final ScrollController _rpScrollController = ScrollController();
|
||||||
@@ -1370,6 +1374,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final sessionColumnWidth = _historySessionColumnWidth(
|
||||||
|
constraints.maxWidth,
|
||||||
|
);
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -1379,10 +1386,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
horizontalMargin: 12,
|
horizontalMargin: 12,
|
||||||
columns: [
|
columns: [
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: SizedBox(
|
||||||
tr(
|
width: sessionColumnWidth,
|
||||||
'ui.userfront.audit.table.session_id',
|
child: Text(
|
||||||
fallback: 'Session ID',
|
tr(
|
||||||
|
'ui.userfront.audit.table.session_id',
|
||||||
|
fallback: 'Session ID',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1426,10 +1436,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return DataRow(
|
return DataRow(
|
||||||
cells: [
|
cells: [
|
||||||
DataCell(
|
DataCell(
|
||||||
_selectableText(
|
SizedBox(
|
||||||
log.sessionId.isEmpty
|
width: sessionColumnWidth,
|
||||||
? tr('ui.common.hyphen', fallback: '-')
|
child: _buildHistorySessionIdCell(
|
||||||
: log.sessionId,
|
log.sessionId.isEmpty
|
||||||
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
|
: log.sessionId,
|
||||||
|
sessionColumnWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataCell(
|
DataCell(
|
||||||
@@ -1474,6 +1488,36 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
Widget _buildHistoryList(AuthTimelineState state) {
|
||||||
return _buildHistoryContainer(
|
return _buildHistoryContainer(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
bool _phoneTouched = false;
|
bool _phoneTouched = false;
|
||||||
bool _phoneCodeTouched = false;
|
bool _phoneCodeTouched = false;
|
||||||
bool _isSavingField = false;
|
bool _isSavingField = false;
|
||||||
|
String? _skipAutoSaveField;
|
||||||
|
|
||||||
String _initialPhone = '';
|
String _initialPhone = '';
|
||||||
bool _isPhoneChanged = false;
|
bool _isPhoneChanged = false;
|
||||||
@@ -354,6 +355,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
|
|
||||||
void _autoSaveIfEditing(UserProfile profile, String field) {
|
void _autoSaveIfEditing(UserProfile profile, String field) {
|
||||||
if (_editingField != field) return;
|
if (_editingField != field) return;
|
||||||
|
if (_skipAutoSaveField == field) {
|
||||||
|
_skipAutoSaveField = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_isVerifying) return;
|
if (_isVerifying) return;
|
||||||
if (_isSavingField) return;
|
if (_isSavingField) return;
|
||||||
if (!_hasFieldChanged(profile, field)) {
|
if (!_hasFieldChanged(profile, field)) {
|
||||||
@@ -375,6 +380,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
|
|
||||||
void _handlePhoneFocusChange(UserProfile profile) {
|
void _handlePhoneFocusChange(UserProfile profile) {
|
||||||
if (_editingField != 'phone') return;
|
if (_editingField != 'phone') return;
|
||||||
|
if (_skipAutoSaveField == 'phone') {
|
||||||
|
_skipAutoSaveField = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_isVerifying) return;
|
if (_isVerifying) return;
|
||||||
if (_isSavingField) return;
|
if (_isSavingField) return;
|
||||||
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return;
|
||||||
@@ -704,6 +713,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
title: Text(label),
|
title: Text(label),
|
||||||
subtitle: Text(displayValue),
|
subtitle: Text(displayValue),
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
|
key: Key('profile-$field-edit-button'),
|
||||||
onPressed: isUpdating ? null : () => _startEditing(field, profile),
|
onPressed: isUpdating ? null : () => _startEditing(field, profile),
|
||||||
child: Text(tr('ui.common.edit')),
|
child: Text(tr('ui.common.edit')),
|
||||||
),
|
),
|
||||||
@@ -720,6 +730,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
key: Key('profile-$field-input'),
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
@@ -731,9 +742,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
OutlinedButton(
|
Listener(
|
||||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
onPointerDown: (_) {
|
||||||
child: Text(tr('ui.common.cancel')),
|
_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<ProfilePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton(
|
Listener(
|
||||||
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
onPointerDown: (_) {
|
||||||
child: Text(tr('ui.common.cancel')),
|
_skipAutoSaveField = 'phone';
|
||||||
|
},
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||||
|
child: Text(tr('ui.common.cancel')),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
112
userfront/test/profile_notifier_persistence_test.dart
Normal file
112
userfront/test/profile_notifier_persistence_test.dart
Normal file
@@ -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<UserProfile> getMyProfile() async {
|
||||||
|
return _profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user