forked from baron/baron-sso
Merge branch 'dev' into feature/issue-919-audit-logs-e2e
This commit is contained in:
@@ -36,6 +36,11 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
userfront_e2e_workers:
|
||||||
|
description: "Playwright worker count for userfront E2E tests"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
default: "2"
|
||||||
run_adminfront_tests:
|
run_adminfront_tests:
|
||||||
description: "Run adminfront Playwright tests"
|
description: "Run adminfront Playwright tests"
|
||||||
required: true
|
required: true
|
||||||
@@ -61,8 +66,109 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
any: ${{ steps.filter.outputs.any }}
|
||||||
|
lint: ${{ steps.filter.outputs.lint }}
|
||||||
|
biome: ${{ steps.filter.outputs.biome }}
|
||||||
|
backend: ${{ steps.filter.outputs.backend }}
|
||||||
|
userfront: ${{ steps.filter.outputs.userfront }}
|
||||||
|
userfront_e2e: ${{ steps.filter.outputs.userfront_e2e }}
|
||||||
|
front_coverage: ${{ steps.filter.outputs.front_coverage }}
|
||||||
|
adminfront: ${{ steps.filter.outputs.adminfront }}
|
||||||
|
devfront: ${{ steps.filter.outputs.devfront }}
|
||||||
|
orgfront: ${{ steps.filter.outputs.orgfront }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect changed areas
|
||||||
|
id: filter
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
set_output() {
|
||||||
|
echo "$1=$2" >> "$GITHUB_OUTPUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
for key in any lint biome backend userfront userfront_e2e front_coverage adminfront devfront orgfront; do
|
||||||
|
set_output "$key" true
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
base="${{ github.event.before }}"
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
base="${{ github.event.pull_request.base.sha }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||||
|
base="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$base" ]; then
|
||||||
|
changed_files="$(git diff --name-only "$base" HEAD)"
|
||||||
|
else
|
||||||
|
changed_files="$(git ls-files)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
printf '%s\n' "$changed_files"
|
||||||
|
|
||||||
|
matches() {
|
||||||
|
printf '%s\n' "$changed_files" | grep -Eq "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)'
|
||||||
|
front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)'
|
||||||
|
i18n_shared='^(common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)'
|
||||||
|
|
||||||
|
backend=false
|
||||||
|
userfront=false
|
||||||
|
userfront_e2e=false
|
||||||
|
adminfront=false
|
||||||
|
devfront=false
|
||||||
|
orgfront=false
|
||||||
|
front_coverage=false
|
||||||
|
biome=false
|
||||||
|
|
||||||
|
if matches "$global|^backend/"; then backend=true; fi
|
||||||
|
if matches "$global|$i18n_shared|^userfront/"; then userfront=true; fi
|
||||||
|
if matches "$global|$i18n_shared|^userfront/|^userfront-e2e/"; then userfront_e2e=true; fi
|
||||||
|
if matches "$front_shared|^adminfront/"; then adminfront=true; fi
|
||||||
|
if matches "$front_shared|^devfront/"; then devfront=true; fi
|
||||||
|
if matches "$front_shared|^orgfront/"; then orgfront=true; fi
|
||||||
|
if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then front_coverage=true; fi
|
||||||
|
if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi
|
||||||
|
|
||||||
|
lint=false
|
||||||
|
if [ "$backend" = true ] || [ "$userfront" = true ] || [ "$adminfront" = true ] || [ "$devfront" = true ] || [ "$orgfront" = true ] || matches "$i18n_shared"; then
|
||||||
|
lint=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
any=false
|
||||||
|
for value in "$lint" "$biome" "$backend" "$userfront" "$userfront_e2e" "$front_coverage" "$adminfront" "$devfront" "$orgfront"; do
|
||||||
|
if [ "$value" = true ]; then any=true; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
set_output any "$any"
|
||||||
|
set_output lint "$lint"
|
||||||
|
set_output biome "$biome"
|
||||||
|
set_output backend "$backend"
|
||||||
|
set_output userfront "$userfront"
|
||||||
|
set_output userfront_e2e "$userfront_e2e"
|
||||||
|
set_output front_coverage "$front_coverage"
|
||||||
|
set_output adminfront "$adminfront"
|
||||||
|
set_output devfront "$devfront"
|
||||||
|
set_output orgfront "$orgfront"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.lint == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_lint == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -162,7 +268,8 @@ jobs:
|
|||||||
flutter analyze --no-fatal-warnings --no-fatal-infos
|
flutter analyze --no-fatal-warnings --no-fatal-infos
|
||||||
|
|
||||||
biome-check:
|
biome-check:
|
||||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.biome == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_lint == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -210,8 +317,10 @@ jobs:
|
|||||||
npx biome check . --linter-enabled=false --assist-enabled=false
|
npx biome check . --linter-enabled=false --assist-enabled=false
|
||||||
|
|
||||||
backend-tests:
|
backend-tests:
|
||||||
needs: lint
|
needs:
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true) }}
|
- changes
|
||||||
|
- lint
|
||||||
|
if: ${{ always() && needs.changes.outputs.backend == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
@@ -286,8 +395,10 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
userfront-tests:
|
userfront-tests:
|
||||||
needs: lint
|
needs:
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true) }}
|
- changes
|
||||||
|
- lint
|
||||||
|
if: ${{ always() && needs.changes.outputs.userfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -379,12 +490,15 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
userfront-e2e-tests:
|
userfront-e2e-tests:
|
||||||
needs: lint
|
needs:
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_e2e_tests == true) }}
|
- changes
|
||||||
|
- lint
|
||||||
|
if: ${{ always() && needs.changes.outputs.userfront_e2e == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_e2e_tests == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
env:
|
env:
|
||||||
USERFRONT_E2E_FULL: ${{ github.event_name == 'workflow_dispatch' && inputs.run_userfront_e2e_full == true }}
|
USERFRONT_E2E_FULL: ${{ github.event_name == 'workflow_dispatch' && inputs.run_userfront_e2e_full == true }}
|
||||||
|
USERFRONT_E2E_WORKERS: ${{ github.event_name == 'workflow_dispatch' && inputs.userfront_e2e_workers || '2' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -529,8 +643,12 @@ jobs:
|
|||||||
else
|
else
|
||||||
test_command="npm test -- --project=chromium-desktop --project=chromium-mobile-webapp"
|
test_command="npm test -- --project=chromium-desktop --project=chromium-mobile-webapp"
|
||||||
fi
|
fi
|
||||||
echo "[userfront-e2e] $test_command" | tee ../reports/userfront-e2e-test.log
|
workers="${USERFRONT_E2E_WORKERS:-2}"
|
||||||
$test_command 2>&1 | tee -a ../reports/userfront-e2e-test.log
|
case "$workers" in
|
||||||
|
''|*[!0-9]*|0) workers=2 ;;
|
||||||
|
esac
|
||||||
|
echo "[userfront-e2e] PLAYWRIGHT_WORKERS=$workers $test_command" | tee ../reports/userfront-e2e-test.log
|
||||||
|
PLAYWRIGHT_WORKERS="$workers" $test_command 2>&1 | tee -a ../reports/userfront-e2e-test.log
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
cd ..
|
cd ..
|
||||||
set -e
|
set -e
|
||||||
@@ -632,8 +750,10 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
front-vitest-coverage:
|
front-vitest-coverage:
|
||||||
needs: lint
|
needs:
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
|
- changes
|
||||||
|
- lint
|
||||||
|
if: ${{ always() && needs.changes.outputs.front_coverage == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -815,8 +935,10 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
adminfront-tests:
|
adminfront-tests:
|
||||||
needs: lint
|
needs:
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
- changes
|
||||||
|
- lint
|
||||||
|
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -908,8 +1030,10 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
devfront-tests:
|
devfront-tests:
|
||||||
needs: lint
|
needs:
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
- changes
|
||||||
|
- lint
|
||||||
|
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -1089,8 +1213,10 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
orgfront-tests:
|
orgfront-tests:
|
||||||
needs: lint
|
needs:
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
|
- changes
|
||||||
|
- lint
|
||||||
|
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -1274,6 +1400,7 @@ jobs:
|
|||||||
|
|
||||||
badge-updater:
|
badge-updater:
|
||||||
needs:
|
needs:
|
||||||
|
- changes
|
||||||
- lint
|
- lint
|
||||||
- biome-check
|
- biome-check
|
||||||
- backend-tests
|
- backend-tests
|
||||||
@@ -1283,7 +1410,7 @@ jobs:
|
|||||||
- adminfront-tests
|
- adminfront-tests
|
||||||
- devfront-tests
|
- devfront-tests
|
||||||
- orgfront-tests
|
- orgfront-tests
|
||||||
if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/dev' }}
|
if: ${{ always() && needs.changes.outputs.any == 'true' && github.event_name != 'pull_request' && github.ref == 'refs/heads/dev' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -1315,19 +1442,49 @@ jobs:
|
|||||||
ADMINFRONT_RESULT: ${{ needs['adminfront-tests'].result }}
|
ADMINFRONT_RESULT: ${{ needs['adminfront-tests'].result }}
|
||||||
DEVFRONT_RESULT: ${{ needs['devfront-tests'].result }}
|
DEVFRONT_RESULT: ${{ needs['devfront-tests'].result }}
|
||||||
ORGFRONT_RESULT: ${{ needs['orgfront-tests'].result }}
|
ORGFRONT_RESULT: ${{ needs['orgfront-tests'].result }}
|
||||||
|
BADGE_SOURCE_BRANCH: dev
|
||||||
|
BADGE_SOURCE_SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
node scripts/update_code_check_badges.mjs
|
node scripts/update_code_check_badges.mjs
|
||||||
cat docs/badges/badges.json
|
cat docs/badges/badges.json
|
||||||
|
|
||||||
- name: Commit badge updates
|
- name: Publish badge assets
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$(git status --porcelain docs/badges)" ]; then
|
if [ -z "$(git status --porcelain docs/badges)" ]; then
|
||||||
echo "No badge changes."
|
echo "No badge changes."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
BADGE_BRANCH=badges
|
||||||
|
BADGE_WORKTREE="$(mktemp -d)"
|
||||||
|
BADGE_LATEST_DIR="${BADGE_WORKTREE}/latest"
|
||||||
|
BADGE_SHA_DIR="${BADGE_WORKTREE}/dev/${GITHUB_SHA}"
|
||||||
|
trap 'rm -rf "${BADGE_WORKTREE}"' EXIT
|
||||||
|
|
||||||
git config user.name "gitea-actions"
|
git config user.name "gitea-actions"
|
||||||
git config user.email "gitea-actions@hmac.kr"
|
git config user.email "gitea-actions@hmac.kr"
|
||||||
git add docs/badges
|
|
||||||
git commit -m "chore: update code check badges [skip ci]"
|
git fetch origin "+refs/heads/${BADGE_BRANCH}:refs/remotes/origin/${BADGE_BRANCH}" || true
|
||||||
git push
|
if git show-ref --verify --quiet "refs/remotes/origin/${BADGE_BRANCH}"; then
|
||||||
|
git worktree add --detach "${BADGE_WORKTREE}" "origin/${BADGE_BRANCH}"
|
||||||
|
else
|
||||||
|
git worktree add --detach "${BADGE_WORKTREE}"
|
||||||
|
git -C "${BADGE_WORKTREE}" checkout --orphan "${BADGE_BRANCH}"
|
||||||
|
git -C "${BADGE_WORKTREE}" rm -rf . || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "${BADGE_WORKTREE}" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
|
||||||
|
mkdir -p "${BADGE_LATEST_DIR}" "${BADGE_SHA_DIR}"
|
||||||
|
cp docs/badges/*.svg "${BADGE_LATEST_DIR}/"
|
||||||
|
cp docs/badges/badges.json "${BADGE_LATEST_DIR}/badges.json"
|
||||||
|
cp docs/badges/*.svg "${BADGE_SHA_DIR}/"
|
||||||
|
cp docs/badges/badges.json "${BADGE_SHA_DIR}/badges.json"
|
||||||
|
|
||||||
|
git -C "${BADGE_WORKTREE}" add .
|
||||||
|
if [ -z "$(git -C "${BADGE_WORKTREE}" status --porcelain)" ]; then
|
||||||
|
echo "No published badge changes."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "${BADGE_WORKTREE}" commit -m "chore: publish code check badges [skip ci]"
|
||||||
|
git -C "${BADGE_WORKTREE}" push origin HEAD:${BADGE_BRANCH}
|
||||||
|
|||||||
210
.gitea/workflows/userfront_e2e_full_nightly.yml
Normal file
210
.gitea/workflows/userfront_e2e_full_nightly.yml
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
name: Userfront E2E Full Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 18 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: "stable"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Run common lint checks
|
||||||
|
run: |
|
||||||
|
make code-check-lint
|
||||||
|
|
||||||
|
full-test-policy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_run: ${{ steps.policy.outputs.should_run }}
|
||||||
|
reason: ${{ steps.policy.outputs.reason }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide whether full E2E is needed
|
||||||
|
id: policy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
target_sha="${GITHUB_SHA}"
|
||||||
|
should_run="true"
|
||||||
|
reason="manual-dispatch"
|
||||||
|
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
|
||||||
|
reason="missing-full-result"
|
||||||
|
git fetch origin "+refs/heads/badges:refs/remotes/origin/badges" || true
|
||||||
|
if git show-ref --verify --quiet refs/remotes/origin/badges && \
|
||||||
|
git cat-file -e "refs/remotes/origin/badges:dev/${target_sha}/badges.json" 2>/dev/null; then
|
||||||
|
full_message="$(
|
||||||
|
git show "refs/remotes/origin/badges:dev/${target_sha}/badges.json" |
|
||||||
|
node -e "let input=''; process.stdin.on('data', c => input += c); process.stdin.on('end', () => { const data = JSON.parse(input); process.stdout.write(data.badges?.['userfront-e2e-full']?.message || 'unknown'); });"
|
||||||
|
)"
|
||||||
|
if [ -n "${full_message}" ] && [ "${full_message}" != "unknown" ]; then
|
||||||
|
should_run="false"
|
||||||
|
reason="full-result-exists:${full_message}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "should_run=${should_run}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "reason=${reason}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "target_sha=${target_sha}"
|
||||||
|
echo "should_run=${should_run}"
|
||||||
|
echo "reason=${reason}"
|
||||||
|
|
||||||
|
userfront-e2e-full:
|
||||||
|
needs:
|
||||||
|
- lint
|
||||||
|
- full-test-policy
|
||||||
|
if: ${{ needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 80
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
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: |
|
||||||
|
cd userfront-e2e
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Build userfront WASM
|
||||||
|
run: |
|
||||||
|
cd userfront
|
||||||
|
flutter build web --wasm --release
|
||||||
|
cd ..
|
||||||
|
node userfront/scripts/optimize-web-build.mjs userfront/build/web
|
||||||
|
|
||||||
|
- name: Provision full browser matrix
|
||||||
|
run: |
|
||||||
|
cd userfront-e2e
|
||||||
|
npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Run full userfront-e2e tests
|
||||||
|
run: |
|
||||||
|
cd userfront-e2e
|
||||||
|
npm test
|
||||||
|
|
||||||
|
- name: Upload userfront-e2e full artifacts
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: userfront-e2e-full-report
|
||||||
|
path: |
|
||||||
|
userfront-e2e/playwright-report
|
||||||
|
userfront-e2e/test-results
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
badge-updater:
|
||||||
|
needs:
|
||||||
|
- lint
|
||||||
|
- full-test-policy
|
||||||
|
- userfront-e2e-full
|
||||||
|
if: ${{ always() && needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' && github.ref == 'refs/heads/dev' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: Update full E2E badge files
|
||||||
|
env:
|
||||||
|
USERFRONT_E2E_RESULT: ${{ needs.userfront-e2e-full.result }}
|
||||||
|
USERFRONT_E2E_FULL: "true"
|
||||||
|
BADGE_UPDATE_CODE_CHECK: "false"
|
||||||
|
BADGE_SOURCE_BRANCH: dev
|
||||||
|
BADGE_SOURCE_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
node scripts/update_code_check_badges.mjs
|
||||||
|
cat docs/badges/badges.json
|
||||||
|
|
||||||
|
- name: Publish full E2E badge assets
|
||||||
|
run: |
|
||||||
|
if [ -z "$(git status --porcelain docs/badges)" ]; then
|
||||||
|
echo "No badge changes."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BADGE_BRANCH=badges
|
||||||
|
BADGE_WORKTREE="$(mktemp -d)"
|
||||||
|
BADGE_LATEST_DIR="${BADGE_WORKTREE}/latest"
|
||||||
|
BADGE_SHA_DIR="${BADGE_WORKTREE}/dev/${GITHUB_SHA}"
|
||||||
|
trap 'rm -rf "${BADGE_WORKTREE}"' EXIT
|
||||||
|
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "gitea-actions@hmac.kr"
|
||||||
|
|
||||||
|
git fetch origin "+refs/heads/${BADGE_BRANCH}:refs/remotes/origin/${BADGE_BRANCH}" || true
|
||||||
|
if git show-ref --verify --quiet "refs/remotes/origin/${BADGE_BRANCH}"; then
|
||||||
|
git worktree add --detach "${BADGE_WORKTREE}" "origin/${BADGE_BRANCH}"
|
||||||
|
else
|
||||||
|
git worktree add --detach "${BADGE_WORKTREE}"
|
||||||
|
git -C "${BADGE_WORKTREE}" checkout --orphan "${BADGE_BRANCH}"
|
||||||
|
git -C "${BADGE_WORKTREE}" rm -rf . || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "${BADGE_WORKTREE}" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
|
||||||
|
mkdir -p "${BADGE_LATEST_DIR}" "${BADGE_SHA_DIR}"
|
||||||
|
cp docs/badges/*.svg "${BADGE_LATEST_DIR}/"
|
||||||
|
cp docs/badges/badges.json "${BADGE_LATEST_DIR}/badges.json"
|
||||||
|
cp docs/badges/*.svg "${BADGE_SHA_DIR}/"
|
||||||
|
cp docs/badges/badges.json "${BADGE_SHA_DIR}/badges.json"
|
||||||
|
|
||||||
|
git -C "${BADGE_WORKTREE}" add .
|
||||||
|
if [ -z "$(git -C "${BADGE_WORKTREE}" status --porcelain)" ]; then
|
||||||
|
echo "No published badge changes."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "${BADGE_WORKTREE}" commit -m "chore: publish userfront e2e full badge [skip ci]"
|
||||||
|
git -C "${BADGE_WORKTREE}" push origin HEAD:${BADGE_BRANCH}
|
||||||
12
Makefile
12
Makefile
@@ -276,18 +276,18 @@ code-check-front-lint:
|
|||||||
@echo "==> adminfront biome lint/format check"
|
@echo "==> adminfront biome lint/format check"
|
||||||
rm -rf adminfront/playwright-report adminfront/test-results
|
rm -rf adminfront/playwright-report adminfront/test-results
|
||||||
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts
|
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts
|
||||||
cd adminfront && npx @biomejs/biome check . --formatter-enabled=false --assist-enabled=false
|
cd adminfront && npx biome lint .
|
||||||
cd adminfront && npx @biomejs/biome check . --linter-enabled=false --assist-enabled=false
|
cd adminfront && npx biome format .
|
||||||
@echo "==> devfront biome lint/format check"
|
@echo "==> devfront biome lint/format check"
|
||||||
rm -rf devfront/playwright-report devfront/test-results
|
rm -rf devfront/playwright-report devfront/test-results
|
||||||
cd devfront && npm ci --ignore-scripts
|
cd devfront && npm ci --ignore-scripts
|
||||||
cd devfront && npx @biomejs/biome check . --formatter-enabled=false --assist-enabled=false
|
cd devfront && npx biome lint .
|
||||||
cd devfront && npx @biomejs/biome check . --linter-enabled=false --assist-enabled=false
|
cd devfront && npx biome format .
|
||||||
@echo "==> orgfront biome lint/format check"
|
@echo "==> orgfront biome lint/format check"
|
||||||
rm -rf orgfront/playwright-report orgfront/test-results
|
rm -rf orgfront/playwright-report orgfront/test-results
|
||||||
cd orgfront && npm ci --ignore-scripts
|
cd orgfront && npm ci --ignore-scripts
|
||||||
cd orgfront && npx @biomejs/biome check . --formatter-enabled=false --assist-enabled=false
|
cd orgfront && npx biome lint .
|
||||||
cd orgfront && npx @biomejs/biome check . --linter-enabled=false --assist-enabled=false
|
cd orgfront && npx biome format .
|
||||||
|
|
||||||
code-check-backend-tests:
|
code-check-backend-tests:
|
||||||
@echo "==> backend tests"
|
@echo "==> backend tests"
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,14 +1,15 @@
|
|||||||
# Baron SSO
|
# Baron SSO
|
||||||
|
|
||||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
[](https://gitea.hmac.kr/baron/baron-sso/src/branch/dev)
|
||||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||||
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||||
|
[](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
|
||||||
|
|
||||||
badge는 `Code Check`가 dev 브랜치에서 갱신합니다. 최신 HTML/LCOV/JSON summary는 Gitea `Code Check`의 `front-vitest-coverage-report` artifact에서 확인할 수 있습니다.
|
badge는 `Code Check`가 `badges` 브랜치의 `latest/`와 `dev/<commit-sha>/`에 발행합니다. 최신 HTML/LCOV/JSON summary는 Gitea `Code Check`의 `front-vitest-coverage-report` artifact에서 확인할 수 있습니다.
|
||||||
|
|
||||||
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||||
|
|
||||||
|
|||||||
145
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
145
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import AppLayout from "./AppLayout";
|
||||||
|
|
||||||
|
const authState = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: {
|
||||||
|
access_token: "access-token",
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||||
|
profile: {
|
||||||
|
sub: "admin-1",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signinSilent: vi.fn(async () => undefined),
|
||||||
|
removeUser: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => authState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "admin-1",
|
||||||
|
name: "Fetched Admin",
|
||||||
|
email: "fetched@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
manageableTenants: [
|
||||||
|
{
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "GPDTDC",
|
||||||
|
slug: "gpdtdc",
|
||||||
|
type: "COMPANY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-2",
|
||||||
|
name: "기술연구팀",
|
||||||
|
slug: "gpdtdc-rnd",
|
||||||
|
type: "ORGANIZATION",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderLayout(entry = "/users") {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[entry]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppLayout />}>
|
||||||
|
<Route path="users" element={<div>Users outlet</div>} />
|
||||||
|
<Route path="users/:id" element={<div>User detail outlet</div>} />
|
||||||
|
<Route
|
||||||
|
path="tenants/:tenantId"
|
||||||
|
element={<div>Tenant outlet</div>}
|
||||||
|
/>
|
||||||
|
<Route path="login" element={<div>Login outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("admin AppLayout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = true;
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||||
|
authState.signinSilent.mockClear();
|
||||||
|
authState.removeUser.mockClear();
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders admin navigation, fetched profile, and outlet content", async () => {
|
||||||
|
renderLayout();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Fetched Admin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Admin Control")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Users outlet")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||||
|
renderLayout();
|
||||||
|
|
||||||
|
const themeButton = await screen.findByRole("button", {
|
||||||
|
name: "테마 전환",
|
||||||
|
});
|
||||||
|
fireEvent.click(themeButton);
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||||
|
expect(screen.getByText("Manageable Tenants")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const sessionSwitch = screen.getByRole("switch");
|
||||||
|
fireEvent.click(sessionSwitch);
|
||||||
|
expect(window.localStorage.getItem("baron_session_expiry_enabled")).toBe(
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("기술연구팀"));
|
||||||
|
expect(await screen.findByText("Tenant outlet")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||||
|
fireEvent.click(screen.getAllByText("내 정보")[0]);
|
||||||
|
expect(await screen.findByText("User detail outlet")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||||
|
fireEvent.click(screen.getAllByText("Logout")[1]);
|
||||||
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
|
expect(authState.removeUser).toHaveBeenCalled();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it("attempts silent renewal on user activity when session is near expiry", async () => {
|
||||||
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||||
|
|
||||||
|
renderLayout();
|
||||||
|
await screen.findByText("Fetched Admin");
|
||||||
|
fireEvent.keyDown(window, { key: "Tab" });
|
||||||
|
|
||||||
|
expect(authState.signinSilent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -148,11 +148,7 @@ function AppLayout() {
|
|||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||||
);
|
);
|
||||||
const {
|
const { data: profile } = useQuery({
|
||||||
data: profile,
|
|
||||||
isLoading: _isProfileLoading,
|
|
||||||
error: _profileError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
debugLog("[AppLayout] Fetching profile...");
|
debugLog("[AppLayout] Fetching profile...");
|
||||||
|
|||||||
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
const render = async (element: React.ReactElement) => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(element);
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) {
|
||||||
|
container.remove();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Avatar", () => {
|
||||||
|
it("renders image and fallback with merged classes", async () => {
|
||||||
|
const root = await render(
|
||||||
|
<Avatar className="custom-root" data-testid="avatar">
|
||||||
|
<AvatarImage
|
||||||
|
alt="Admin user"
|
||||||
|
className="custom-image"
|
||||||
|
src="/avatar.png"
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="custom-fallback">AU</AvatarFallback>
|
||||||
|
</Avatar>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatar = container?.querySelector("[data-testid='avatar']");
|
||||||
|
const fallback = container?.textContent;
|
||||||
|
|
||||||
|
expect(avatar?.className).toContain("custom-root");
|
||||||
|
expect(fallback).toContain("AU");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
41
adminfront/src/components/ui/separator.test.tsx
Normal file
41
adminfront/src/components/ui/separator.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { Separator } from "./separator";
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
const render = async (element: React.ReactElement) => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(element);
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) {
|
||||||
|
container.remove();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Separator", () => {
|
||||||
|
it("renders a horizontal separator with custom classes", async () => {
|
||||||
|
const root = await render(
|
||||||
|
<Separator className="custom-separator" data-testid="separator" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const separator = container?.querySelector("[data-testid='separator']");
|
||||||
|
|
||||||
|
expect(separator?.className).toContain("h-px");
|
||||||
|
expect(separator?.className).toContain("custom-separator");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
192
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
192
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||||
|
import AuthCallbackPage from "../auth/AuthCallbackPage";
|
||||||
|
import AuthGuard from "../auth/AuthGuard";
|
||||||
|
|
||||||
|
const authState = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
activeNavigator: undefined as string | undefined,
|
||||||
|
error: null as Error | null,
|
||||||
|
user: {
|
||||||
|
access_token: "access-token",
|
||||||
|
state: undefined as unknown,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => authState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../../../common/core/components/audit", () => ({
|
||||||
|
AuditLogTable: ({
|
||||||
|
logs,
|
||||||
|
}: {
|
||||||
|
logs: Array<{ user_id: string; event_type: string }>;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div key={`${log.user_id}-${log.event_type}`}>
|
||||||
|
<span>{log.user_id}</span>
|
||||||
|
<span>{log.event_type}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchAuditLogs: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
event_id: "event-1",
|
||||||
|
timestamp: "2026-05-01T00:00:00Z",
|
||||||
|
user_id: "admin-1",
|
||||||
|
event_type: "USER_UPDATE",
|
||||||
|
status: "success",
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
user_agent: "Vitest",
|
||||||
|
details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: "event-2",
|
||||||
|
timestamp: "2026-05-01T01:00:00Z",
|
||||||
|
user_id: "admin-2",
|
||||||
|
event_type: "LOGIN_FAILED",
|
||||||
|
status: "failure",
|
||||||
|
ip_address: "127.0.0.2",
|
||||||
|
user_agent: "Vitest",
|
||||||
|
details: "{}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limit: 50,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("admin audit and auth coverage smoke", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = false;
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.activeNavigator = undefined;
|
||||||
|
authState.error = null;
|
||||||
|
authState.user = {
|
||||||
|
access_token: "access-token",
|
||||||
|
state: undefined,
|
||||||
|
};
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders audit log table with fetched events", async () => {
|
||||||
|
renderWithProviders(<AuditLogsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("감사 로그")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("admin-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("USER_UPDATE")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => {
|
||||||
|
authState.isLoading = true;
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/secure" element={<AuthGuard />}>
|
||||||
|
<Route index element={<div>Secure outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>,
|
||||||
|
"/secure",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||||
|
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.error = new Error("OIDC failed");
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/secure" element={<AuthGuard />}>
|
||||||
|
<Route index element={<div>Secure outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>,
|
||||||
|
"/secure",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("인증 오류")).toBeInTheDocument();
|
||||||
|
|
||||||
|
authState.error = null;
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/secure" element={<AuthGuard />}>
|
||||||
|
<Route index element={<div>Secure outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" element={<div>Login outlet</div>} />
|
||||||
|
</Routes>,
|
||||||
|
"/secure?x=1",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Login outlet")).toBeInTheDocument();
|
||||||
|
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = true;
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/secure" element={<AuthGuard />}>
|
||||||
|
<Route index element={<div>Secure outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>,
|
||||||
|
"/secure",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Secure outlet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores callback token and navigates by auth result", async () => {
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
authState.user = {
|
||||||
|
access_token: "callback-token",
|
||||||
|
state: { returnTo: "/users" },
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||||
|
<Route path="/users" element={<div>Users outlet</div>} />
|
||||||
|
<Route path="/login" element={<div>Login outlet</div>} />
|
||||||
|
</Routes>,
|
||||||
|
"/auth/callback",
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("Users outlet")).toBeInTheDocument();
|
||||||
|
expect(window.localStorage.getItem("admin_session")).toBe("callback-token");
|
||||||
|
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
authState.error = new Error("callback failed");
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||||
|
<Route path="/login" element={<div>Login outlet</div>} />
|
||||||
|
</Routes>,
|
||||||
|
"/auth/callback",
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("Login outlet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
297
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
297
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
||||||
|
import TenantListPage from "../tenants/routes/TenantListPage";
|
||||||
|
import UserCreatePage from "../users/UserCreatePage";
|
||||||
|
import UserDetailPage from "../users/UserDetailPage";
|
||||||
|
|
||||||
|
const tenantItems = [
|
||||||
|
{
|
||||||
|
id: "tenant-root",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
name: "한맥 가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
description: "root",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-company",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: "tenant-root",
|
||||||
|
name: "GPDTDC",
|
||||||
|
slug: "gpdtdc",
|
||||||
|
description: "company",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 2,
|
||||||
|
config: {
|
||||||
|
userSchema: [
|
||||||
|
{
|
||||||
|
key: "employee_id",
|
||||||
|
label: "사번",
|
||||||
|
type: "text",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-leaf",
|
||||||
|
type: "ORGANIZATION",
|
||||||
|
parentId: "tenant-company",
|
||||||
|
name: "기술연구팀",
|
||||||
|
slug: "gpdtdc-rnd",
|
||||||
|
description: "leaf",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 1,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const userDetail = {
|
||||||
|
id: "user-1",
|
||||||
|
email: "engineer@example.com",
|
||||||
|
name: "Engineer User",
|
||||||
|
phone: "010-0000-0000",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
tenantSlug: "gpdtdc-rnd",
|
||||||
|
tenantId: "tenant-leaf",
|
||||||
|
department: "기술연구팀",
|
||||||
|
grade: "책임",
|
||||||
|
position: "팀장",
|
||||||
|
jobTitle: "Backend",
|
||||||
|
metadata: {
|
||||||
|
employee_id: "EMP001",
|
||||||
|
sub_email: ["engineer.sub@example.com"],
|
||||||
|
},
|
||||||
|
tenant: tenantItems[2],
|
||||||
|
appointments: [
|
||||||
|
{
|
||||||
|
tenantId: "tenant-leaf",
|
||||||
|
tenantSlug: "gpdtdc-rnd",
|
||||||
|
tenantName: "기술연구팀",
|
||||||
|
isPrimary: true,
|
||||||
|
isOwner: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isManager: true,
|
||||||
|
department: "기술연구팀",
|
||||||
|
grade: "책임",
|
||||||
|
position: "팀장",
|
||||||
|
jobTitle: "Backend",
|
||||||
|
metadata: { employee_id: "EMP001" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../components/auth/RoleGuard", () => ({
|
||||||
|
RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "admin-1",
|
||||||
|
role: "super_admin",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
})),
|
||||||
|
fetchAllTenants: vi.fn(async () => ({
|
||||||
|
items: tenantItems,
|
||||||
|
total: tenantItems.length,
|
||||||
|
})),
|
||||||
|
fetchTenants: vi.fn(async () => ({
|
||||||
|
items: tenantItems,
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
total: tenantItems.length,
|
||||||
|
nextCursor: null,
|
||||||
|
})),
|
||||||
|
fetchTenant: vi.fn(async (id: string) => {
|
||||||
|
return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1];
|
||||||
|
}),
|
||||||
|
createUser: vi.fn(async () => ({
|
||||||
|
id: "created-user",
|
||||||
|
email: "created@example.com",
|
||||||
|
generatedPassword: "GeneratedPassword!1",
|
||||||
|
})),
|
||||||
|
fetchUser: vi.fn(async () => userDetail),
|
||||||
|
fetchUserRpHistory: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
client_id: "orgfront",
|
||||||
|
client_name: "OrgFront",
|
||||||
|
last_login_at: "2026-05-01T00:00:00Z",
|
||||||
|
login_count: 3,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
fetchPasswordPolicy: vi.fn(async () => ({
|
||||||
|
minLength: 12,
|
||||||
|
lowercase: true,
|
||||||
|
uppercase: true,
|
||||||
|
number: true,
|
||||||
|
nonAlphanumeric: true,
|
||||||
|
minCharacterTypes: 3,
|
||||||
|
})),
|
||||||
|
updateUser: vi.fn(async () => userDetail),
|
||||||
|
deleteUser: vi.fn(async () => undefined),
|
||||||
|
updateTenant: vi.fn(async () => tenantItems[1]),
|
||||||
|
deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })),
|
||||||
|
exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])),
|
||||||
|
importTenantsCSV: vi.fn(async () => ({
|
||||||
|
created: 1,
|
||||||
|
updated: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
})),
|
||||||
|
fetchWorksmobileOverview: vi.fn(async () => ({
|
||||||
|
tenant: tenantItems[1],
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
tokenConfigured: true,
|
||||||
|
adminTenantId: "works-admin",
|
||||||
|
domainMappings: { "example.com": 1001 },
|
||||||
|
},
|
||||||
|
recentJobs: [
|
||||||
|
{
|
||||||
|
id: "job-1",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: "user-1",
|
||||||
|
action: "SYNC",
|
||||||
|
status: "failed",
|
||||||
|
retryCount: 1,
|
||||||
|
lastError: "temporary failure",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:10:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
fetchWorksmobileComparison: vi.fn(async () => ({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
baronId: "user-1",
|
||||||
|
baronName: "Engineer User",
|
||||||
|
baronEmail: "engineer@example.com",
|
||||||
|
baronPrimaryOrgId: "tenant-leaf",
|
||||||
|
baronPrimaryOrgName: "기술연구팀",
|
||||||
|
worksmobileId: "works-user-1",
|
||||||
|
worksmobileName: "Engineer User",
|
||||||
|
worksmobileEmail: "engineer@example.com",
|
||||||
|
worksmobilePrimaryOrgId: "works-org-1",
|
||||||
|
worksmobilePrimaryOrgName: "기술연구팀",
|
||||||
|
status: "matched",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
baronId: "user-2",
|
||||||
|
baronName: "New User",
|
||||||
|
baronEmail: "new@example.com",
|
||||||
|
status: "baron_only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
resourceType: "ORG_UNIT",
|
||||||
|
baronId: "tenant-leaf",
|
||||||
|
baronSlug: "gpdtdc-rnd",
|
||||||
|
baronName: "기술연구팀",
|
||||||
|
worksmobileId: "works-org-1",
|
||||||
|
worksmobileName: "기술연구팀",
|
||||||
|
status: "needs_update",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
||||||
|
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
||||||
|
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => new Blob(["id"])),
|
||||||
|
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
||||||
|
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
||||||
|
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("adminfront large page coverage smoke", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders user creation form with tenant context", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/users/new" element={<UserCreatePage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/users/new?tenantSlug=gpdtdc-rnd",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("사용자 추가")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders user detail form and RP history", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/users/user-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tenant list hierarchy", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/tenants" element={<TenantListPage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/tenants",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("GPDTDC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("기술연구팀")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders worksmobile comparison screens", async () => {
|
||||||
|
cleanup();
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/worksmobile"
|
||||||
|
element={<TenantWorksmobilePage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/worksmobile",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import TenantCreatePage from "../tenants/routes/TenantCreatePage";
|
||||||
|
import { TenantProfilePage } from "../tenants/routes/TenantProfilePage";
|
||||||
|
import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage";
|
||||||
|
|
||||||
|
const tenants = [
|
||||||
|
{
|
||||||
|
id: "tenant-root",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
name: "한맥 가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 0,
|
||||||
|
domains: ["hmac.kr"],
|
||||||
|
config: { visibility: "public" },
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-company",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: "tenant-root",
|
||||||
|
name: "GPDTDC",
|
||||||
|
slug: "gpdtdc",
|
||||||
|
description: "실 조직",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 2,
|
||||||
|
domains: ["gpdtdc.example.com"],
|
||||||
|
config: {
|
||||||
|
visibility: "public",
|
||||||
|
userSchema: [
|
||||||
|
{
|
||||||
|
key: "employee_id",
|
||||||
|
label: "사번",
|
||||||
|
type: "text",
|
||||||
|
required: false,
|
||||||
|
adminOnly: false,
|
||||||
|
isLoginId: true,
|
||||||
|
indexed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "admin-1",
|
||||||
|
role: "super_admin",
|
||||||
|
})),
|
||||||
|
fetchAllTenants: vi.fn(async () => ({
|
||||||
|
items: tenants,
|
||||||
|
total: tenants.length,
|
||||||
|
})),
|
||||||
|
fetchTenant: vi.fn(async (id: string) => {
|
||||||
|
return tenants.find((tenant) => tenant.id === id) ?? tenants[1];
|
||||||
|
}),
|
||||||
|
createTenant: vi.fn(async () => tenants[1]),
|
||||||
|
updateTenant: vi.fn(async () => tenants[1]),
|
||||||
|
deleteTenant: vi.fn(async () => undefined),
|
||||||
|
approveTenant: vi.fn(async () => tenants[1]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("admin tenant detail page coverage smoke", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tenant create page with parent context", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/tenants/new" element={<TenantCreatePage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/new?parentId=tenant-root",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("테넌트 생성")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Tenant Profile")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("정책 메모")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tenant profile and schema management pages", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<TenantProfilePage />
|
||||||
|
<TenantSchemaPage />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
116
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import TenantGroupsPage from "../tenants/routes/TenantGroupsPage";
|
||||||
|
|
||||||
|
const tenant = {
|
||||||
|
id: "tenant-company",
|
||||||
|
type: "COMPANY",
|
||||||
|
name: "GPDTDC",
|
||||||
|
slug: "gpdtdc",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 2,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const members = [
|
||||||
|
{
|
||||||
|
id: "user-1",
|
||||||
|
name: "Member User",
|
||||||
|
email: "member@example.com",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchTenant: vi.fn(async () => tenant),
|
||||||
|
fetchUsers: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "user-1",
|
||||||
|
name: "Member User",
|
||||||
|
email: "member@example.com",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-2",
|
||||||
|
name: "Candidate User",
|
||||||
|
email: "candidate@example.com",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
})),
|
||||||
|
fetchGroups: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: "group-root",
|
||||||
|
tenantId: "tenant-company",
|
||||||
|
name: "연구소",
|
||||||
|
description: "root group",
|
||||||
|
members,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "group-child",
|
||||||
|
tenantId: "tenant-company",
|
||||||
|
parentId: "group-root",
|
||||||
|
name: "플랫폼팀",
|
||||||
|
description: "child group",
|
||||||
|
members: [],
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
createGroup: vi.fn(async () => undefined),
|
||||||
|
deleteGroup: vi.fn(async () => undefined),
|
||||||
|
addGroupMember: vi.fn(async () => undefined),
|
||||||
|
removeGroupMember: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TenantGroupsPage coverage smoke", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders group hierarchy and selected group members", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/groups"
|
||||||
|
element={<TenantGroupsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/groups",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("새 그룹 생성")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
162
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
162
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
|
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||||
|
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||||
|
|
||||||
|
const tenants = [
|
||||||
|
{
|
||||||
|
id: "tenant-root",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
name: "한맥 가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 0,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-company",
|
||||||
|
type: "COMPANY",
|
||||||
|
parentId: "tenant-root",
|
||||||
|
name: "GPDTDC",
|
||||||
|
slug: "gpdtdc",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 2,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-leaf",
|
||||||
|
type: "ORGANIZATION",
|
||||||
|
parentId: "tenant-company",
|
||||||
|
name: "기술연구팀",
|
||||||
|
slug: "gpdtdc-rnd",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
memberCount: 1,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
id: "user-owner",
|
||||||
|
name: "Owner User",
|
||||||
|
email: "owner@example.com",
|
||||||
|
role: "tenant_admin",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-admin",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "tenant_admin",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-member",
|
||||||
|
name: "Member User",
|
||||||
|
email: "member@example.com",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
tenantSlug: "gpdtdc-rnd",
|
||||||
|
tenant: tenants[2],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: {
|
||||||
|
profile: {
|
||||||
|
sub: "admin-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||||
|
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||||
|
addTenantOwner: vi.fn(async () => undefined),
|
||||||
|
addTenantAdmin: vi.fn(async () => undefined),
|
||||||
|
removeTenantOwner: vi.fn(async () => undefined),
|
||||||
|
removeTenantAdmin: vi.fn(async () => undefined),
|
||||||
|
fetchUsers: vi.fn(async () => ({
|
||||||
|
items: users,
|
||||||
|
total: users.length,
|
||||||
|
})),
|
||||||
|
fetchAllTenants: vi.fn(async () => ({
|
||||||
|
items: tenants,
|
||||||
|
total: tenants.length,
|
||||||
|
})),
|
||||||
|
updateTenant: vi.fn(async () => tenants[2]),
|
||||||
|
updateUser: vi.fn(async () => users[2]),
|
||||||
|
exportTenantsCSV: vi.fn(async () => ({
|
||||||
|
blob: new Blob(["name,slug"]),
|
||||||
|
filename: "tenants.csv",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("admin tenant tab coverage smoke", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tenant owners and admins lists", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/permissions"
|
||||||
|
element={<TenantAdminsAndOwnersTab />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/permissions",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Owner User")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Admin User")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("owner@example.com")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tenant hierarchy and selected organization members", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/organization"
|
||||||
|
element={<TenantUserGroupsTab />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/organization",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||||
|
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,7 +79,6 @@ import {
|
|||||||
import { toast } from "../../../components/ui/use-toast";
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import type { UserProfileResponse } from "../../../lib/adminApi";
|
import type { UserProfileResponse } from "../../../lib/adminApi";
|
||||||
import {
|
import {
|
||||||
deleteTenant,
|
|
||||||
deleteTenantsBulk,
|
deleteTenantsBulk,
|
||||||
exportTenantsCSV,
|
exportTenantsCSV,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
@@ -326,13 +325,6 @@ function TenantListPage() {
|
|||||||
(profile?.manageableTenants?.length ?? 0) > 1),
|
(profile?.manageableTenants?.length ?? 0) > 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
|
||||||
onSuccess: () => {
|
|
||||||
query.refetch();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteBulkMutation = useMutation({
|
const deleteBulkMutation = useMutation({
|
||||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -711,25 +703,6 @@ function TenantListPage() {
|
|||||||
importMutation.mutate(file);
|
importMutation.mutate(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
|
||||||
const tenant = allTenants.find((item) => item.id === tenantId);
|
|
||||||
if (tenant && isSeedTenant(tenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
t(
|
|
||||||
"msg.admin.tenants.delete_confirm",
|
|
||||||
'테넌트 "{{name}}"를 삭제할까요?',
|
|
||||||
{ name: tenantName },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteMutation.mutate(tenantId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -948,8 +921,6 @@ function TenantListPage() {
|
|||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
onDelete={handleDelete}
|
|
||||||
isDeletePending={deleteMutation.isPending}
|
|
||||||
search={search}
|
search={search}
|
||||||
deletableTenants={deletableTenants}
|
deletableTenants={deletableTenants}
|
||||||
statusMutation={statusMutation}
|
statusMutation={statusMutation}
|
||||||
@@ -1333,8 +1304,6 @@ const TenantHierarchyView: React.FC<{
|
|||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
onSelect: (tenant: TenantSummary, checked: boolean) => void;
|
onSelect: (tenant: TenantSummary, checked: boolean) => void;
|
||||||
onSelectAll: (checked: boolean) => void;
|
onSelectAll: (checked: boolean) => void;
|
||||||
onDelete: (tenantId: string, tenantName: string) => void;
|
|
||||||
isDeletePending: boolean;
|
|
||||||
search: string;
|
search: string;
|
||||||
deletableTenants: TenantSummary[];
|
deletableTenants: TenantSummary[];
|
||||||
statusMutation: UseMutationResult<
|
statusMutation: UseMutationResult<
|
||||||
@@ -1354,8 +1323,6 @@ const TenantHierarchyView: React.FC<{
|
|||||||
selectedIds,
|
selectedIds,
|
||||||
onSelect,
|
onSelect,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
onDelete: _onDelete,
|
|
||||||
isDeletePending: _isDeletePending,
|
|
||||||
search,
|
search,
|
||||||
deletableTenants,
|
deletableTenants,
|
||||||
statusMutation,
|
statusMutation,
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ function TenantUserGroupsTab() {
|
|||||||
queryFn: () => fetchAllTenants(),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentBase, subTree: _subTree } = useMemo(() => {
|
const { currentBase } = useMemo(() => {
|
||||||
const allItems = allTenantsData?.items ?? [];
|
const allItems = allTenantsData?.items ?? [];
|
||||||
return buildTenantFullTree(allItems, tenantId);
|
return buildTenantFullTree(allItems, tenantId);
|
||||||
}, [allTenantsData, tenantId]);
|
}, [allTenantsData, tenantId]);
|
||||||
|
|||||||
@@ -662,6 +662,7 @@ function UserCreatePage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute right-1 top-1 h-8 text-xs font-bold"
|
className="absolute right-1 top-1 h-8 text-xs font-bold"
|
||||||
|
data-testid="add-sub-email-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const value = newSubEmail.trim().replace(/,/g, "");
|
const value = newSubEmail.trim().replace(/,/g, "");
|
||||||
if (
|
if (
|
||||||
@@ -678,7 +679,7 @@ function UserCreatePage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("ui.common.add", "추가")}
|
{t("ui.common.add", "추가")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
* 여러 개 입력 가능. 입력 후 엔터를 눌러 추가하세요.
|
* 여러 개 입력 가능. 입력 후 엔터를 눌러 추가하세요.
|
||||||
@@ -877,6 +878,7 @@ function UserCreatePage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={addAppointment}
|
onClick={addAppointment}
|
||||||
|
data-testid="add-appointment-btn"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t("ui.common.add", "추가")}
|
{t("ui.common.add", "추가")}
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ import { resolvePersonalTenant } from "./utils/personalTenant";
|
|||||||
|
|
||||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||||
email: string;
|
email: string;
|
||||||
metadata: Record<string, Record<string, string | number | boolean>> & {
|
metadata: Record<string, unknown> & {
|
||||||
sub_email?: string[];
|
sub_email?: string | string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
type UserCategory = "hanmac" | "external" | "personal";
|
type UserCategory = "hanmac" | "external" | "personal";
|
||||||
@@ -108,6 +108,44 @@ type AppointmentDraft = UserAppointment & {
|
|||||||
|
|
||||||
const PASSWORD_RESET_MIN_LENGTH = 12;
|
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||||
|
|
||||||
|
function isMetadataRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanMetadataValue(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (isMetadataRecord(value)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).filter(
|
||||||
|
([_, fieldValue]) =>
|
||||||
|
fieldValue !== undefined && fieldValue !== null && fieldValue !== "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSubEmails(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.includes("@"));
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
return value
|
||||||
|
.split(/[;,\n\r\t]/)
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter((email) => email.includes("@"));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function createDraftId() {
|
function createDraftId() {
|
||||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||||
}
|
}
|
||||||
@@ -773,15 +811,17 @@ function UserDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: UserFormValues) => {
|
const onSubmit = async (data: UserFormValues) => {
|
||||||
// Filter out undefined/null/empty strings from metadata
|
|
||||||
const cleanMetadata = Object.fromEntries(
|
const cleanMetadata = Object.fromEntries(
|
||||||
Object.entries(data.metadata).map(([tenantId, fields]) => {
|
Object.entries(data.metadata ?? {}).flatMap(([key, value]) => {
|
||||||
const cleanFields = Object.fromEntries(
|
const cleanedValue = cleanMetadataValue(value);
|
||||||
Object.entries(fields).filter(
|
if (
|
||||||
([_, v]) => v !== undefined && v !== null && v !== "",
|
cleanedValue === undefined ||
|
||||||
),
|
cleanedValue === null ||
|
||||||
);
|
cleanedValue === ""
|
||||||
return [tenantId, cleanFields];
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [[key, cleanedValue]];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -791,22 +831,11 @@ function UserDetailPage() {
|
|||||||
sub_email: rawSubEmail,
|
sub_email: rawSubEmail,
|
||||||
...safeMetadata
|
...safeMetadata
|
||||||
} = cleanMetadata;
|
} = cleanMetadata;
|
||||||
|
const subEmail = normalizeSubEmails(rawSubEmail);
|
||||||
// Parse sub_email
|
|
||||||
let sub_email: string[] = [];
|
|
||||||
if (
|
|
||||||
typeof rawSubEmail === "string" &&
|
|
||||||
(rawSubEmail as string).trim() !== ""
|
|
||||||
) {
|
|
||||||
sub_email = (rawSubEmail as string)
|
|
||||||
.split(/[;,\n\r\t]/)
|
|
||||||
.map((e: string) => e.trim())
|
|
||||||
.filter((e: string) => e.includes("@"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
...safeMetadata,
|
...safeMetadata,
|
||||||
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
|
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload: UserUpdateRequest = {
|
const payload: UserUpdateRequest = {
|
||||||
@@ -991,16 +1020,14 @@ function UserDetailPage() {
|
|||||||
<Mail size={14} className="text-primary/70" />
|
<Mail size={14} className="text-primary/70" />
|
||||||
{user.email}
|
{user.email}
|
||||||
</div>
|
</div>
|
||||||
{!!user.metadata?.sub_email &&
|
{normalizeSubEmails(user.metadata?.sub_email).length > 0 && (
|
||||||
Array.isArray(user.metadata.sub_email) &&
|
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
||||||
(user.metadata.sub_email as unknown[]).length > 0 && (
|
<Mail size={14} className="text-primary/40" />
|
||||||
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
<span className="text-[10px] font-bold">
|
||||||
<Mail size={14} className="text-primary/40" />
|
+{normalizeSubEmails(user.metadata?.sub_email).length}
|
||||||
<span className="text-[10px] font-bold">
|
</span>
|
||||||
+{(user.metadata.sub_email as unknown[]).length}
|
</div>
|
||||||
</span>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{user.phone && (
|
{user.phone && (
|
||||||
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
||||||
<Shield size={14} className="text-primary/70" />
|
<Shield size={14} className="text-primary/70" />
|
||||||
@@ -1166,6 +1193,7 @@ function UserDetailPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute right-1 top-1 h-9 text-xs font-bold"
|
className="absolute right-1 top-1 h-9 text-xs font-bold"
|
||||||
|
data-testid="add-sub-email-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const value = newSubEmail.trim().replace(/,/g, "");
|
const value = newSubEmail.trim().replace(/,/g, "");
|
||||||
if (
|
if (
|
||||||
@@ -1319,6 +1347,7 @@ function UserDetailPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={addAppointment}
|
onClick={addAppointment}
|
||||||
|
data-testid="add-appointment-btn"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t("ui.common.add", "추가")}
|
{t("ui.common.add", "추가")}
|
||||||
|
|||||||
@@ -354,10 +354,12 @@ function applySecondaryEmailMetadata(
|
|||||||
value: string,
|
value: string,
|
||||||
) {
|
) {
|
||||||
const emails = splitEmailTokens(value);
|
const emails = splitEmailTokens(value);
|
||||||
item.metadata.sub_email = uniqueEmails([
|
const uniqueSecondaryEmails = uniqueEmails([
|
||||||
...metadataEmailList(item.metadata.sub_email),
|
...metadataEmailList(item.metadata.secondary_emails),
|
||||||
...emails,
|
...emails,
|
||||||
]);
|
]);
|
||||||
|
item.metadata.sub_email = value;
|
||||||
|
item.metadata.secondary_emails = uniqueSecondaryEmails;
|
||||||
addWorksmobileAliasEmails(item, emails);
|
addWorksmobileAliasEmails(item, emails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
185
adminfront/src/lib/adminApi.contract.test.ts
Normal file
185
adminfront/src/lib/adminApi.contract.test.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const apiClient = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAllCursorPages = vi.fn(async () => ({
|
||||||
|
items: [{ id: "tenant-1", name: "Tenant", slug: "tenant" }],
|
||||||
|
total: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./apiClient", () => ({
|
||||||
|
default: apiClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./auth", () => ({
|
||||||
|
userManager: {
|
||||||
|
getUser: vi.fn(async () => ({ access_token: "access-token" })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../common/core/pagination", () => ({
|
||||||
|
fetchAllCursorPages,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("adminApi endpoint contracts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiClient.get.mockReset();
|
||||||
|
apiClient.post.mockReset();
|
||||||
|
apiClient.put.mockReset();
|
||||||
|
apiClient.patch.mockReset();
|
||||||
|
apiClient.delete.mockReset();
|
||||||
|
|
||||||
|
apiClient.get.mockResolvedValue({
|
||||||
|
data: { ok: true },
|
||||||
|
headers: { "content-disposition": 'attachment; filename="export.csv"' },
|
||||||
|
});
|
||||||
|
apiClient.post.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.put.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.patch.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.delete.mockResolvedValue({ data: { ok: true } });
|
||||||
|
fetchAllCursorPages.mockClear();
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes read APIs to their documented admin endpoints", async () => {
|
||||||
|
const adminApi = await import("./adminApi");
|
||||||
|
|
||||||
|
await adminApi.fetchAuditLogs(10, "cursor-a");
|
||||||
|
await adminApi.fetchAdminOverviewStats();
|
||||||
|
await adminApi.fetchDataIntegrityReport();
|
||||||
|
await adminApi.fetchOrphanUserLoginIDs();
|
||||||
|
await adminApi.fetchUserProjectionStatus();
|
||||||
|
await adminApi.fetchAdminRPUsageDaily({
|
||||||
|
days: 30,
|
||||||
|
period: "week",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
});
|
||||||
|
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
|
||||||
|
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
|
||||||
|
await adminApi.fetchTenant("tenant-1");
|
||||||
|
await adminApi.fetchTenantAdmins("tenant-1");
|
||||||
|
await adminApi.fetchTenantOwners("tenant-1");
|
||||||
|
await adminApi.fetchGroups("tenant-1");
|
||||||
|
await adminApi.fetchGroup("tenant-1", "group-1");
|
||||||
|
await adminApi.fetchGroupRoles("tenant-1", "group-1");
|
||||||
|
await adminApi.fetchApiKeys(20, 40);
|
||||||
|
await adminApi.fetchUsers(30, 60, "admin", "tenant");
|
||||||
|
await adminApi.fetchUser("user-1");
|
||||||
|
await adminApi.fetchWorksmobileOverview("tenant-1");
|
||||||
|
await adminApi.fetchWorksmobileComparison("tenant-1", true);
|
||||||
|
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
|
||||||
|
await adminApi.fetchPasswordPolicy();
|
||||||
|
await adminApi.fetchUserRpHistory("user-1");
|
||||||
|
await adminApi.fetchMe();
|
||||||
|
await adminApi.fetchRelyingParties("tenant-1");
|
||||||
|
await adminApi.fetchAllRelyingParties();
|
||||||
|
await adminApi.fetchRelyingParty("client-1");
|
||||||
|
await adminApi.fetchRPOwners("client-1");
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
|
||||||
|
params: { limit: 10, cursor: "cursor-a" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
|
||||||
|
params: {
|
||||||
|
limit: 25,
|
||||||
|
offset: 50,
|
||||||
|
parentId: "parent-1",
|
||||||
|
cursor: "cursor-b",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fetchAllCursorPages).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "/v1/admin/tenants",
|
||||||
|
pageSize: 200,
|
||||||
|
params: { parentId: "parent-1" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
|
||||||
|
{ params: { includeMatched: true } },
|
||||||
|
);
|
||||||
|
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
|
||||||
|
filename: "export.csv",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await adminApi.exportUsersCSV("admin", "tenant", true),
|
||||||
|
).toMatchObject({
|
||||||
|
filename: "export.csv",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes mutation APIs to their documented admin endpoints", async () => {
|
||||||
|
const adminApi = await import("./adminApi");
|
||||||
|
|
||||||
|
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
|
||||||
|
await adminApi.reconcileUserProjection();
|
||||||
|
await adminApi.resetUserProjection();
|
||||||
|
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
|
||||||
|
await adminApi.updateTenant("tenant-1", { status: "inactive" });
|
||||||
|
await adminApi.deleteTenant("tenant-1");
|
||||||
|
await adminApi.deleteTenantsBulk(["tenant-1"]);
|
||||||
|
await adminApi.importTenantsCSV(new File(["name"], "tenants.csv"));
|
||||||
|
await adminApi.approveTenant("tenant-1");
|
||||||
|
await adminApi.addTenantAdmin("tenant-1", "user-1");
|
||||||
|
await adminApi.removeTenantAdmin("tenant-1", "user-1");
|
||||||
|
await adminApi.addTenantOwner("tenant-1", "user-1");
|
||||||
|
await adminApi.removeTenantOwner("tenant-1", "user-1");
|
||||||
|
await adminApi.createGroup("tenant-1", { name: "Group" });
|
||||||
|
await adminApi.deleteGroup("tenant-1", "group-1");
|
||||||
|
await adminApi.addGroupMember("tenant-1", "group-1", "user-1");
|
||||||
|
await adminApi.removeGroupMember("tenant-1", "group-1", "user-1");
|
||||||
|
await adminApi.assignGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||||
|
await adminApi.removeGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||||
|
await adminApi.createApiKey({ name: "key", scopes: ["read"] });
|
||||||
|
await adminApi.updateApiKeyScopes("key-1", { scopes: ["write"] });
|
||||||
|
await adminApi.rotateApiKeySecret("key-1");
|
||||||
|
await adminApi.deleteApiKey("key-1");
|
||||||
|
await adminApi.createUser({ email: "user@example.com", name: "User" });
|
||||||
|
await adminApi.bulkCreateUsers([
|
||||||
|
{ email: "user@example.com", name: "User", metadata: {} },
|
||||||
|
]);
|
||||||
|
await adminApi.enqueueWorksmobileBackfillDryRun("tenant-1");
|
||||||
|
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
|
||||||
|
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
|
||||||
|
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
|
||||||
|
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
|
||||||
|
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
|
||||||
|
await adminApi.bulkDeleteUsers(["user-1"]);
|
||||||
|
await adminApi.updateUser("user-1", { status: "active" });
|
||||||
|
await adminApi.deleteUser("user-1");
|
||||||
|
await adminApi.createRelyingParty("tenant-1", {
|
||||||
|
client_name: "RP",
|
||||||
|
redirect_uris: ["https://rp.example/callback"],
|
||||||
|
});
|
||||||
|
await adminApi.updateRelyingParty("client-1", {
|
||||||
|
client_name: "RP",
|
||||||
|
redirect_uris: ["https://rp.example/callback"],
|
||||||
|
});
|
||||||
|
await adminApi.deleteRelyingParty("client-1");
|
||||||
|
await adminApi.addRPOwner("client-1", "User:user-1");
|
||||||
|
await adminApi.removeRPOwner("client-1", "User:user-1");
|
||||||
|
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/integrity/orphan-user-login-ids",
|
||||||
|
{ data: { ids: ["orphan-1"] } },
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/projections/users/reconcile",
|
||||||
|
);
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
|
||||||
|
);
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/relying-parties/client-1/owners/User:user-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cn } from "./utils";
|
import { cn, generateSecurePassword } from "./utils";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
describe("cn utility", () => {
|
describe("cn utility", () => {
|
||||||
it("merges class names correctly", () => {
|
it("merges class names correctly", () => {
|
||||||
@@ -11,3 +16,23 @@ describe("cn utility", () => {
|
|||||||
expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4");
|
expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("generateSecurePassword", () => {
|
||||||
|
it("uses crypto random values when available", () => {
|
||||||
|
vi.stubGlobal("crypto", {
|
||||||
|
getRandomValues: vi.fn((values: Uint32Array) => {
|
||||||
|
values.set([0, 1, 2, 3]);
|
||||||
|
return values;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(generateSecurePassword(4)).toBe("abcd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Math.random when crypto is unavailable", () => {
|
||||||
|
vi.stubGlobal("crypto", undefined);
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||||
|
|
||||||
|
expect(generateSecurePassword(3)).toBe("aaa");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -721,10 +721,7 @@ test.describe("User Management", () => {
|
|||||||
await expect(page.locator("input#department")).toHaveCount(0);
|
await expect(page.locator("input#department")).toHaveCount(0);
|
||||||
|
|
||||||
await expect(page.getByText(/대표 소속/i)).toHaveCount(0);
|
await expect(page.getByText(/대표 소속/i)).toHaveCount(0);
|
||||||
await page
|
await page.getByTestId("add-appointment-btn").click();
|
||||||
.getByRole("tabpanel")
|
|
||||||
.getByRole("button", { name: /^추가$/i })
|
|
||||||
.click();
|
|
||||||
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
|
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("appointment-tenant-owner-line-0"),
|
page.getByTestId("appointment-tenant-owner-line-0"),
|
||||||
|
|||||||
@@ -302,7 +302,10 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
const payload = JSON.parse(bulkPayload);
|
const payload = JSON.parse(bulkPayload);
|
||||||
expect(payload.users[0].tenantSlug).toBe("primary-tenant");
|
expect(payload.users[0].tenantSlug).toBe("primary-tenant");
|
||||||
expect(payload.users[0].metadata.employee_id).toBe("EMP001");
|
expect(payload.users[0].metadata.employee_id).toBe("EMP001");
|
||||||
expect(payload.users[0].metadata.sub_email).toEqual([
|
expect(payload.users[0].metadata.sub_email).toBe(
|
||||||
|
"dual.alias@hanmaceng.co.kr",
|
||||||
|
);
|
||||||
|
expect(payload.users[0].metadata.secondary_emails).toEqual([
|
||||||
"dual.alias@hanmaceng.co.kr",
|
"dual.alias@hanmaceng.co.kr",
|
||||||
]);
|
]);
|
||||||
expect(payload.users[0].metadata.aliasEmails).toEqual([
|
expect(payload.users[0].metadata.aliasEmails).toEqual([
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
type BulkUsersRequest = {
|
||||||
|
users: Array<{
|
||||||
|
metadata: {
|
||||||
|
sub_email?: string[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
test.describe("Users Bulk Upload Secondary Emails", () => {
|
test.describe("Users Bulk Upload Secondary Emails", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -58,11 +66,11 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
|
|||||||
test("should parse secondary_emails and send to backend", async ({
|
test("should parse secondary_emails and send to backend", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let bulkPayload: Record<string, unknown> | null = null;
|
let bulkPayload: BulkUsersRequest | null = null;
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
await page.route("**/api/v1/admin/users/bulk", async (route) => {
|
||||||
if (route.request().method() === "POST") {
|
if (route.request().method() === "POST") {
|
||||||
bulkPayload = route.request().postDataJSON() as Record<string, unknown>;
|
bulkPayload = route.request().postDataJSON() as BulkUsersRequest;
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
results: [
|
results: [
|
||||||
@@ -107,11 +115,7 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
|
|||||||
await expect(page.getByText(/성공|Success/i)).toBeVisible();
|
await expect(page.getByText(/성공|Success/i)).toBeVisible();
|
||||||
|
|
||||||
expect(bulkPayload).not.toBeNull();
|
expect(bulkPayload).not.toBeNull();
|
||||||
|
expect(bulkPayload.users).toHaveLength(1);
|
||||||
const payloadUsers = bulkPayload?.users as Array<{
|
|
||||||
metadata: { sub_email: string[] };
|
|
||||||
}>;
|
|
||||||
expect(payloadUsers).toHaveLength(1);
|
|
||||||
|
|
||||||
// The most important check - does it parse to the metadata
|
// The most important check - does it parse to the metadata
|
||||||
expect(payloadUsers[0].metadata.sub_email).toContain("sub1@test.com");
|
expect(payloadUsers[0].metadata.sub_email).toContain("sub1@test.com");
|
||||||
|
|||||||
@@ -729,14 +729,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
req.CompanyCode = tenant.Slug
|
req.CompanyCode = tenant.Slug
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect and sync all custom login IDs based on tenant schemas
|
|
||||||
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
|
|
||||||
|
|
||||||
attributes["role"] = role
|
attributes["role"] = role
|
||||||
if tenantID != "" {
|
if tenantID != "" {
|
||||||
attributes["tenant_id"] = tenantID
|
attributes["tenant_id"] = tenantID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect and sync all custom login IDs based on tenant schemas
|
||||||
|
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
|
||||||
|
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil {
|
if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil {
|
||||||
if strings.Contains(err.Error(), "한맥가족") {
|
if strings.Contains(err.Error(), "한맥가족") {
|
||||||
@@ -2534,20 +2534,23 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
|
|||||||
var allCustomIDs []string
|
var allCustomIDs []string
|
||||||
idSet := make(map[string]bool)
|
idSet := make(map[string]bool)
|
||||||
|
|
||||||
|
normalizeCustomLoginIDsTrait(traits)
|
||||||
|
|
||||||
// Collect tenant IDs to check schemas for
|
// Collect tenant IDs to check schemas for
|
||||||
tenantIDsToCheck := make(map[string]bool)
|
tenantIDsToCheck := make(map[string]bool)
|
||||||
|
primaryTenantID := extractTraitString(traits, "tenant_id")
|
||||||
for k, v := range metadata {
|
for k, v := range metadata {
|
||||||
// Heuristic: if it's a map, it's likely namespaced metadata for a tenant
|
if isTenantMetadataNamespace(k, v, primaryTenantID) {
|
||||||
if _, ok := v.(map[string]any); ok {
|
|
||||||
tenantIDsToCheck[k] = true
|
|
||||||
} else if _, ok := v.(map[string]interface{}); ok {
|
|
||||||
tenantIDsToCheck[k] = true
|
tenantIDsToCheck[k] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also check primary tenant if available
|
// Also check primary tenant if available
|
||||||
if tid := extractTraitString(traits, "tenant_id"); tid != "" {
|
if tid := primaryTenantID; tid != "" && (len(metadata) > 0 || isMetadataMap(traits[tid])) {
|
||||||
tenantIDsToCheck[tid] = true
|
tenantIDsToCheck[tid] = true
|
||||||
}
|
}
|
||||||
|
if len(tenantIDsToCheck) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for tid := range tenantIDsToCheck {
|
for tid := range tenantIDsToCheck {
|
||||||
tenant, err := tenantService.GetTenant(ctx, tid)
|
tenant, err := tenantService.GetTenant(ctx, tid)
|
||||||
@@ -2629,6 +2632,66 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
|
|||||||
return loginIDRecords
|
return loginIDRecords
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTenantMetadataNamespace(key string, value any, primaryTenantID string) bool {
|
||||||
|
return isTenantMetadataNamespaceKey(key, primaryTenantID) && isMetadataMap(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTenantMetadataNamespaceKey(key string, primaryTenantID string) bool {
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if primaryTenantID != "" && key == primaryTenantID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(key) != 36 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index, char := range key {
|
||||||
|
switch index {
|
||||||
|
case 8, 13, 18, 23:
|
||||||
|
if char != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMetadataMap(value any) bool {
|
||||||
|
if _, ok := value.(map[string]any); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := value.(map[string]interface{}); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
|
||||||
|
raw, exists := traits["custom_login_ids"]
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch values := raw.(type) {
|
||||||
|
case []string:
|
||||||
|
return
|
||||||
|
case []interface{}:
|
||||||
|
normalized := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||||
|
normalized = append(normalized, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(normalized) > 0 {
|
||||||
|
traits["custom_login_ids"] = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func formatTime(value time.Time) string {
|
func formatTime(value time.Time) string {
|
||||||
if value.IsZero() {
|
if value.IsZero() {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -707,13 +707,6 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
|||||||
Status: domain.TenantStatusActive,
|
Status: domain.TenantStatusActive,
|
||||||
Config: domain.JSONMap{},
|
Config: domain.JSONMap{},
|
||||||
}, nil).Once()
|
}, nil).Once()
|
||||||
mockTenant.On("GetTenant", mock.Anything, "t-saman").Return(&domain.Tenant{
|
|
||||||
ID: "t-saman",
|
|
||||||
Slug: "saman",
|
|
||||||
Name: "삼안",
|
|
||||||
Status: domain.TenantStatusActive,
|
|
||||||
Config: domain.JSONMap{},
|
|
||||||
}, nil)
|
|
||||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||||
_, hasCompanyCode := user.Attributes["companyCode"]
|
_, hasCompanyCode := user.Attributes["companyCode"]
|
||||||
@@ -1183,6 +1176,41 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
|
|||||||
mockKratos.AssertExpectations(t)
|
mockKratos.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
tenantID := "tenant-uuid"
|
||||||
|
|
||||||
|
mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Slug: "test-tenant",
|
||||||
|
Config: domain.JSONMap{
|
||||||
|
"userSchema": []interface{}{
|
||||||
|
map[string]interface{}{"key": "emp_no", "isLoginId": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
traits := map[string]interface{}{
|
||||||
|
"tenant_id": tenantID,
|
||||||
|
}
|
||||||
|
metadata := map[string]any{
|
||||||
|
tenantID: map[string]interface{}{
|
||||||
|
"emp_no": "E1001",
|
||||||
|
},
|
||||||
|
"worksmobileAliasEmails": map[string]interface{}{
|
||||||
|
"0": "alias@hanmaceng.co.kr",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
records := syncCustomLoginIDs(context.Background(), mockTenant, traits, metadata, "user-1")
|
||||||
|
|
||||||
|
require.Len(t, records, 1)
|
||||||
|
require.Equal(t, tenantID, records[0].TenantID)
|
||||||
|
require.Equal(t, "E1001", records[0].LoginID)
|
||||||
|
mockTenant.AssertNotCalled(t, "GetTenant", mock.Anything, "worksmobileAliasEmails")
|
||||||
|
mockTenant.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
||||||
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
@@ -1764,7 +1792,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
|||||||
ID: "new-tenant-id",
|
ID: "new-tenant-id",
|
||||||
Slug: "new-tenant",
|
Slug: "new-tenant",
|
||||||
Config: domain.JSONMap{},
|
Config: domain.JSONMap{},
|
||||||
}, nil).Twice()
|
}, nil).Once()
|
||||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
|
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||||
_, hasCompanyCode := traits["companyCode"]
|
_, hasCompanyCode := traits["companyCode"]
|
||||||
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
||||||
|
|||||||
77
devfront/src/components/common/ForbiddenMessage.test.tsx
Normal file
77
devfront/src/components/common/ForbiddenMessage.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ForbiddenMessage } from "./ForbiddenMessage";
|
||||||
|
|
||||||
|
const authState = {
|
||||||
|
user: {
|
||||||
|
profile: {
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => authState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||||
|
let text = fallback ?? key;
|
||||||
|
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||||
|
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderMessage(resourceToken: "audit" | "clients" | "consents") {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ForbiddenMessage resourceToken={resourceToken} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ForbiddenMessage", () => {
|
||||||
|
it("renders resource-specific user guidance", async () => {
|
||||||
|
authState.user.profile.role = "user";
|
||||||
|
|
||||||
|
const audit = await renderMessage("audit");
|
||||||
|
expect(audit.textContent).toContain("Audit Logs");
|
||||||
|
expect(audit.textContent).toContain("audit read relationship");
|
||||||
|
|
||||||
|
const consents = await renderMessage("consents");
|
||||||
|
expect(consents.textContent).toContain("User Consent Grants");
|
||||||
|
expect(consents.textContent).toContain("consent read");
|
||||||
|
|
||||||
|
const clients = await renderMessage("clients");
|
||||||
|
expect(clients.textContent).toContain("Connected Applications");
|
||||||
|
expect(clients.textContent).toContain("target RP");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders role-specific administrator guidance", async () => {
|
||||||
|
authState.user.profile.role = "rp_admin";
|
||||||
|
const rpAdmin = await renderMessage("clients");
|
||||||
|
expect(rpAdmin.textContent).toContain("RP administrators");
|
||||||
|
|
||||||
|
authState.user.profile.role = "tenant_admin";
|
||||||
|
const tenantAdmin = await renderMessage("clients");
|
||||||
|
expect(tenantAdmin.textContent).toContain("tenant administrator");
|
||||||
|
});
|
||||||
|
});
|
||||||
166
devfront/src/components/layout/AppLayout.test.tsx
Normal file
166
devfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import AppLayout from "./AppLayout";
|
||||||
|
|
||||||
|
const authState = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
activeNavigator: undefined as string | undefined,
|
||||||
|
error: null as Error | null,
|
||||||
|
user: {
|
||||||
|
access_token: "access-token",
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||||
|
profile: {
|
||||||
|
sub: "user-1",
|
||||||
|
name: "Dev Admin",
|
||||||
|
email: "dev@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signinSilent: vi.fn(),
|
||||||
|
removeUser: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => authState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../features/auth/authApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "user-1",
|
||||||
|
name: "Fetched Dev Admin",
|
||||||
|
email: "fetched@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||||
|
let text = fallback ?? key;
|
||||||
|
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||||
|
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.activeNavigator = undefined;
|
||||||
|
authState.error = null;
|
||||||
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||||
|
authState.signinSilent.mockReset();
|
||||||
|
authState.signinSilent.mockResolvedValue(undefined);
|
||||||
|
authState.removeUser.mockReset();
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderLayout(initialEntry = "/clients") {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[initialEntry]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppLayout />}>
|
||||||
|
<Route path="clients" element={<div>Client outlet</div>} />
|
||||||
|
<Route path="profile" element={<div>Profile outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("devfront AppLayout", () => {
|
||||||
|
it("renders shell navigation, profile summary, and outlet content", async () => {
|
||||||
|
const container = await renderLayout();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Developer Console");
|
||||||
|
expect(container.textContent).toContain("Clients");
|
||||||
|
expect(container.textContent).toContain("Client outlet");
|
||||||
|
expect(container.textContent).toContain("Fetched Dev Admin");
|
||||||
|
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
||||||
|
const container = await renderLayout();
|
||||||
|
|
||||||
|
const themeButton = container.querySelector(
|
||||||
|
'button[aria-label="Toggle theme"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
themeButton.click();
|
||||||
|
});
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
|
||||||
|
const profileButton = container.querySelector(
|
||||||
|
'button[aria-label="Open account menu"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
profileButton.click();
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("My Profile");
|
||||||
|
|
||||||
|
const profileMenuItem = Array.from(
|
||||||
|
container.querySelectorAll('button[role="menuitem"]'),
|
||||||
|
).find((button) => button.textContent?.includes("My Profile"));
|
||||||
|
await act(async () => {
|
||||||
|
(profileMenuItem as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("Profile outlet");
|
||||||
|
|
||||||
|
const logoutButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent?.includes("Logout"),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
(logoutButton as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
|
expect(authState.removeUser).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts silent renewal after user action when the session is expiring", async () => {
|
||||||
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||||
|
await renderLayout();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authState.signinSilent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
161
devfront/src/features/auth/authPages.test.tsx
Normal file
161
devfront/src/features/auth/authPages.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import AuthCallbackPage from "./AuthCallbackPage";
|
||||||
|
import AuthGuard from "./AuthGuard";
|
||||||
|
import AuthPage from "./AuthPage";
|
||||||
|
import LoginPage from "./LoginPage";
|
||||||
|
|
||||||
|
const authState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
activeNavigator: undefined as string | undefined,
|
||||||
|
error: null as Error | null,
|
||||||
|
user: undefined as
|
||||||
|
| {
|
||||||
|
state?: unknown;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
signinRedirect: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => authState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/auth", () => ({
|
||||||
|
userManager: {
|
||||||
|
signinPopupCallback: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.activeNavigator = undefined;
|
||||||
|
authState.error = null;
|
||||||
|
authState.user = undefined;
|
||||||
|
authState.signinRedirect.mockReset();
|
||||||
|
authState.signinRedirect.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderWithRouter(
|
||||||
|
element: React.ReactElement,
|
||||||
|
{
|
||||||
|
entry = "/",
|
||||||
|
path = "*",
|
||||||
|
}: {
|
||||||
|
entry?: string;
|
||||||
|
path?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter initialEntries={[entry]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path={path} element={element}>
|
||||||
|
<Route index element={<div>Protected outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" element={<div>Login route</div>} />
|
||||||
|
<Route path="/clients" element={<div>Clients route</div>} />
|
||||||
|
<Route path="/profile" element={<div>Profile route</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("devfront auth pages", () => {
|
||||||
|
it("renders the static auth planning page", async () => {
|
||||||
|
const container = await renderWithRouter(<AuthPage />);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Admin auth guardrails");
|
||||||
|
expect(container.textContent).toContain("Device approval");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders login page and starts SSO redirect from the action button", async () => {
|
||||||
|
const container = await renderWithRouter(<LoginPage />, {
|
||||||
|
entry: "/login?returnTo=/profile",
|
||||||
|
path: "/login",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("개발자 포털 로그인");
|
||||||
|
|
||||||
|
const loginButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent?.includes("SSO 계정으로 로그인"),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
(loginButton as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authState.signinRedirect).toHaveBeenCalledWith({
|
||||||
|
state: { returnTo: "/clients" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => {
|
||||||
|
authState.isLoading = true;
|
||||||
|
const loading = await renderWithRouter(<AuthGuard />);
|
||||||
|
expect(loading.textContent).toContain("Loading...");
|
||||||
|
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.error = new Error("OIDC failed");
|
||||||
|
const error = await renderWithRouter(<AuthGuard />);
|
||||||
|
expect(error.textContent).toContain("Authentication Error");
|
||||||
|
|
||||||
|
const retryButton = error.querySelector("button") as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
retryButton.click();
|
||||||
|
});
|
||||||
|
expect(authState.signinRedirect).toHaveBeenCalled();
|
||||||
|
|
||||||
|
authState.error = null;
|
||||||
|
const redirected = await renderWithRouter(<AuthGuard />);
|
||||||
|
expect(redirected.textContent).toContain("Login route");
|
||||||
|
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
const protectedPage = await renderWithRouter(<AuthGuard />);
|
||||||
|
expect(protectedPage.textContent).toContain("Protected outlet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates from callback by auth result and stored return target", async () => {
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
authState.user = { state: { returnTo: "/profile" } };
|
||||||
|
|
||||||
|
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
|
||||||
|
entry: "/auth/callback",
|
||||||
|
path: "/auth/callback",
|
||||||
|
});
|
||||||
|
expect(authenticated.textContent).toContain("Profile route");
|
||||||
|
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
authState.error = new Error("callback failed");
|
||||||
|
const failed = await renderWithRouter(<AuthCallbackPage />, {
|
||||||
|
entry: "/auth/callback",
|
||||||
|
path: "/auth/callback",
|
||||||
|
});
|
||||||
|
expect(failed.textContent).toContain("Login route");
|
||||||
|
});
|
||||||
|
});
|
||||||
54
devfront/src/features/coverage/commonSort.test.ts
Normal file
54
devfront/src/features/coverage/commonSort.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
compareNullableValues,
|
||||||
|
sortItems,
|
||||||
|
toggleSort,
|
||||||
|
} from "../../../../common/core/utils/sort";
|
||||||
|
|
||||||
|
describe("common sort utilities in devfront coverage", () => {
|
||||||
|
it("keeps nullish values last and compares normalized primitive values", () => {
|
||||||
|
expect(compareNullableValues(null, "alpha", "asc")).toBe(1);
|
||||||
|
expect(compareNullableValues("alpha", undefined, "asc")).toBe(-1);
|
||||||
|
expect(compareNullableValues("Beta", "alpha", "asc")).toBe(1);
|
||||||
|
expect(compareNullableValues("Beta", "alpha", "desc")).toBe(-1);
|
||||||
|
expect(compareNullableValues(true, false, "asc")).toBe(1);
|
||||||
|
expect(
|
||||||
|
compareNullableValues(
|
||||||
|
new Date("2026-05-02T00:00:00Z"),
|
||||||
|
new Date("2026-05-01T00:00:00Z"),
|
||||||
|
"asc",
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles sort direction and sorts with default and custom resolvers", () => {
|
||||||
|
const firstSort = toggleSort(null, "name");
|
||||||
|
expect(firstSort).toEqual({ key: "name", direction: "asc" });
|
||||||
|
expect(toggleSort(firstSort, "name")).toEqual({
|
||||||
|
key: "name",
|
||||||
|
direction: "desc",
|
||||||
|
});
|
||||||
|
expect(toggleSort(firstSort, "createdAt")).toEqual({
|
||||||
|
key: "createdAt",
|
||||||
|
direction: "asc",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ name: "charlie", rank: 3, nested: { score: 20 } },
|
||||||
|
{ name: "Alpha", rank: 1, nested: { score: 30 } },
|
||||||
|
{ name: "bravo", rank: 2, nested: { score: 10 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sortItems(rows, { key: "name", direction: "asc" }).map(
|
||||||
|
(row) => row.name,
|
||||||
|
),
|
||||||
|
).toEqual(["Alpha", "bravo", "charlie"]);
|
||||||
|
expect(
|
||||||
|
sortItems(rows, { key: "score", direction: "desc" }, {
|
||||||
|
score: (row) => row.nested.score,
|
||||||
|
}).map((row) => row.name),
|
||||||
|
).toEqual(["Alpha", "charlie", "bravo"]);
|
||||||
|
expect(sortItems(rows, null)).not.toBe(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
383
devfront/src/features/coverage/pageSmoke.test.tsx
Normal file
383
devfront/src/features/coverage/pageSmoke.test.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||||
|
import ClientConsentsPage from "../clients/ClientConsentsPage";
|
||||||
|
import ClientDetailsPage from "../clients/ClientDetailsPage";
|
||||||
|
import ClientGeneralPage from "../clients/ClientGeneralPage";
|
||||||
|
import ClientRelationsPage from "../clients/ClientRelationsPage";
|
||||||
|
import ClientsPage from "../clients/ClientsPage";
|
||||||
|
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
|
||||||
|
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
|
||||||
|
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
|
||||||
|
import ProfilePage from "../profile/ProfilePage";
|
||||||
|
|
||||||
|
const authProfile = {
|
||||||
|
sub: "user-1",
|
||||||
|
role: "super_admin",
|
||||||
|
tenant_id: "tenant-1",
|
||||||
|
companyCode: "HANMAC",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: {
|
||||||
|
access_token: "access-token",
|
||||||
|
profile: authProfile,
|
||||||
|
},
|
||||||
|
signinRedirect: vi.fn(),
|
||||||
|
removeUser: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||||
|
let text = fallback ?? key;
|
||||||
|
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||||
|
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clientSummary = {
|
||||||
|
id: "client-a",
|
||||||
|
name: "Console App",
|
||||||
|
type: "private" as const,
|
||||||
|
status: "active" as const,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
redirectUris: ["https://app.example/callback"],
|
||||||
|
scopes: ["openid", "profile"],
|
||||||
|
tokenEndpointAuthMethod: "client_secret_basic",
|
||||||
|
metadata: {
|
||||||
|
headless_login_enabled: true,
|
||||||
|
headless_login_jwks_uri: "https://app.example/jwks.json",
|
||||||
|
id_token_claims: [
|
||||||
|
{
|
||||||
|
namespace: "rp_claims",
|
||||||
|
key: "employee_id",
|
||||||
|
value: "E001",
|
||||||
|
valueType: "text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientDetail = {
|
||||||
|
client: {
|
||||||
|
...clientSummary,
|
||||||
|
clientSecret: "secret-value",
|
||||||
|
jwksUri: "https://app.example/jwks.json",
|
||||||
|
backchannelLogoutUri: "https://app.example/logout",
|
||||||
|
backchannelLogoutSessionRequired: true,
|
||||||
|
grantTypes: ["authorization_code"],
|
||||||
|
responseTypes: ["code"],
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
discovery: "https://sso.example/.well-known/openid-configuration",
|
||||||
|
issuer: "https://sso.example",
|
||||||
|
authorization: "https://sso.example/oauth2/auth",
|
||||||
|
token: "https://sso.example/oauth2/token",
|
||||||
|
userinfo: "https://sso.example/userinfo",
|
||||||
|
},
|
||||||
|
headlessJwksCache: {
|
||||||
|
clientId: "client-a",
|
||||||
|
jwksUri: "https://app.example/jwks.json",
|
||||||
|
cachedAt: "2026-05-01T00:00:00Z",
|
||||||
|
expiresAt: "2026-05-02T00:00:00Z",
|
||||||
|
lastCheckedAt: "2026-05-01T01:00:00Z",
|
||||||
|
lastSuccessfulVerificationAt: "2026-05-01T01:00:00Z",
|
||||||
|
lastRefreshStatus: "success" as const,
|
||||||
|
cachedKids: ["kid-1"],
|
||||||
|
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../lib/devApi", () => ({
|
||||||
|
fetchClients: vi.fn(async () => ({
|
||||||
|
items: [clientSummary],
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
})),
|
||||||
|
fetchDevStats: vi.fn(async () => ({
|
||||||
|
total_clients: 1,
|
||||||
|
active_sessions: 12,
|
||||||
|
auth_failures_24h: 2,
|
||||||
|
})),
|
||||||
|
fetchDevRPUsageDaily: vi.fn(async () => ({
|
||||||
|
days: 14,
|
||||||
|
period: "day",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
date: "2026-05-01",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantType: "COMPANY",
|
||||||
|
tenantName: "Hanmac",
|
||||||
|
clientId: "client-a",
|
||||||
|
clientName: "Console App",
|
||||||
|
loginRequests: 10,
|
||||||
|
otherRequests: 4,
|
||||||
|
uniqueSubjects: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-05-08",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantType: "COMPANY",
|
||||||
|
tenantName: "Hanmac",
|
||||||
|
clientId: "client-a",
|
||||||
|
clientName: "Console App",
|
||||||
|
loginRequests: 8,
|
||||||
|
otherRequests: 5,
|
||||||
|
uniqueSubjects: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
fetchClient: vi.fn(async () => clientDetail),
|
||||||
|
fetchClientRelations: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
relation: "admins",
|
||||||
|
subject: "User:user-1",
|
||||||
|
subjectType: "User",
|
||||||
|
subjectId: "user-1",
|
||||||
|
userName: "Dev Admin",
|
||||||
|
userEmail: "dev@example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
fetchDevUsers: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "user-2",
|
||||||
|
name: "Editor User",
|
||||||
|
email: "editor@example.com",
|
||||||
|
loginId: "editor",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
addClientRelation: vi.fn(async () => ({
|
||||||
|
relation: "admins",
|
||||||
|
subject: "User:user-2",
|
||||||
|
subjectType: "User",
|
||||||
|
subjectId: "user-2",
|
||||||
|
})),
|
||||||
|
removeClientRelation: vi.fn(async () => undefined),
|
||||||
|
updateClientStatus: vi.fn(async () => clientDetail),
|
||||||
|
createClient: vi.fn(async () => clientDetail),
|
||||||
|
updateClient: vi.fn(async () => clientDetail),
|
||||||
|
rotateClientSecret: vi.fn(async () => clientDetail),
|
||||||
|
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
|
||||||
|
revokeHeadlessJwksCache: vi.fn(async () => undefined),
|
||||||
|
deleteClient: vi.fn(async () => undefined),
|
||||||
|
fetchConsents: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
subject: "user-1",
|
||||||
|
userName: "Consent User",
|
||||||
|
clientId: "client-a",
|
||||||
|
clientName: "Console App",
|
||||||
|
grantedScopes: ["openid", "profile"],
|
||||||
|
authenticatedAt: "2026-05-01T02:00:00Z",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
status: "active",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantName: "Hanmac",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
revokeConsent: vi.fn(async () => undefined),
|
||||||
|
listIdpConfigsForClient: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: "idp-1",
|
||||||
|
client_id: "client-a",
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "Workspace OIDC",
|
||||||
|
status: "active",
|
||||||
|
issuer_url: "https://accounts.example",
|
||||||
|
oidc_client_id: "oidc-client",
|
||||||
|
scopes: "openid email profile",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
createIdpConfigForClient: vi.fn(async (payload) => ({
|
||||||
|
id: "idp-1",
|
||||||
|
...payload,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
})),
|
||||||
|
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
|
||||||
|
id: idpId,
|
||||||
|
client_id: "client-a",
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "Provider",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
...payload,
|
||||||
|
})),
|
||||||
|
deleteIdpConfig: vi.fn(async () => undefined),
|
||||||
|
fetchDevAuditLogs: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
event_id: "event-1",
|
||||||
|
timestamp: "2026-05-01T00:00:00Z",
|
||||||
|
user_id: "user-1",
|
||||||
|
event_type: "client.update",
|
||||||
|
status: "success",
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
user_agent: "Vitest",
|
||||||
|
details: "{\"client_id\":\"client-a\"}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limit: 50,
|
||||||
|
})),
|
||||||
|
fetchMyTenants: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "Hanmac",
|
||||||
|
slug: "hanmac",
|
||||||
|
type: "COMPANY",
|
||||||
|
status: "active",
|
||||||
|
description: "",
|
||||||
|
memberCount: 10,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
|
||||||
|
requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
|
||||||
|
fetchDeveloperRequests: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: "user-3",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
name: "Requester",
|
||||||
|
organization: "Hanmac",
|
||||||
|
email: "requester@example.com",
|
||||||
|
reason: "Need RP access",
|
||||||
|
status: "pending",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
|
||||||
|
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
|
||||||
|
cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../auth/authApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "user-1",
|
||||||
|
email: "dev@example.com",
|
||||||
|
name: "Dev Admin",
|
||||||
|
role: "super_admin",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderPage(
|
||||||
|
element: React.ReactElement,
|
||||||
|
{
|
||||||
|
path = "/",
|
||||||
|
entry = path,
|
||||||
|
}: {
|
||||||
|
path?: string;
|
||||||
|
entry?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[entry]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path={path} element={element} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("devfront coverage smoke pages", () => {
|
||||||
|
it("renders overview, client list, audit, developer request, and profile pages", async () => {
|
||||||
|
const overview = await renderPage(<GlobalOverviewPage />);
|
||||||
|
expect(overview.textContent).toContain("Console App");
|
||||||
|
|
||||||
|
const clients = await renderPage(<ClientsPage />);
|
||||||
|
expect(clients.textContent).toContain("Console App");
|
||||||
|
|
||||||
|
const audit = await renderPage(<AuditLogsPage />);
|
||||||
|
expect(audit.textContent).toContain("client.update");
|
||||||
|
|
||||||
|
const requests = await renderPage(<DeveloperRequestPage />);
|
||||||
|
expect(requests.textContent).toContain("Requester");
|
||||||
|
|
||||||
|
const profile = await renderPage(<ProfilePage />);
|
||||||
|
expect(profile.textContent).toContain("Dev Admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders client detail, settings, consent, federation, and relationship pages", async () => {
|
||||||
|
const details = await renderPage(<ClientDetailsPage />, {
|
||||||
|
path: "/clients/:id",
|
||||||
|
entry: "/clients/client-a",
|
||||||
|
});
|
||||||
|
expect(details.textContent).toContain("Console App");
|
||||||
|
|
||||||
|
const settings = await renderPage(<ClientGeneralPage />, {
|
||||||
|
path: "/clients/:id/settings",
|
||||||
|
entry: "/clients/client-a/settings",
|
||||||
|
});
|
||||||
|
expect(settings.textContent).toContain("Console App");
|
||||||
|
|
||||||
|
const consents = await renderPage(<ClientConsentsPage />, {
|
||||||
|
path: "/clients/:id/consents",
|
||||||
|
entry: "/clients/client-a/consents",
|
||||||
|
});
|
||||||
|
expect(consents.textContent).toContain("Consent User");
|
||||||
|
|
||||||
|
const federation = await renderPage(<ClientFederationPage />, {
|
||||||
|
path: "/clients/:id/federation",
|
||||||
|
entry: "/clients/client-a/federation",
|
||||||
|
});
|
||||||
|
expect(federation.textContent).toContain("Workspace OIDC");
|
||||||
|
|
||||||
|
const relations = await renderPage(<ClientRelationsPage />, {
|
||||||
|
path: "/clients/:id/relationships",
|
||||||
|
entry: "/clients/client-a/relationships",
|
||||||
|
});
|
||||||
|
expect(relations.textContent).toContain("Dev Admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
250
devfront/src/lib/devApi.test.ts
Normal file
250
devfront/src/lib/devApi.test.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const apiClient = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("./apiClient", () => ({
|
||||||
|
default: apiClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("devApi", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiClient.get.mockReset();
|
||||||
|
apiClient.post.mockReset();
|
||||||
|
apiClient.put.mockReset();
|
||||||
|
apiClient.patch.mockReset();
|
||||||
|
apiClient.delete.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches list and detail resources with expected query parameters", async () => {
|
||||||
|
const {
|
||||||
|
fetchClients,
|
||||||
|
fetchDevStats,
|
||||||
|
fetchDevRPUsageDaily,
|
||||||
|
fetchTenants,
|
||||||
|
fetchClient,
|
||||||
|
fetchClientRelations,
|
||||||
|
fetchDevUsers,
|
||||||
|
fetchConsents,
|
||||||
|
fetchDevAuditLogs,
|
||||||
|
fetchMyTenants,
|
||||||
|
fetchDeveloperRequestStatus,
|
||||||
|
fetchDeveloperRequests,
|
||||||
|
listIdpConfigsForClient,
|
||||||
|
} = await import("./devApi");
|
||||||
|
apiClient.get.mockResolvedValue({ data: { ok: true } });
|
||||||
|
|
||||||
|
await fetchClients();
|
||||||
|
await fetchDevStats();
|
||||||
|
await fetchDevRPUsageDaily({ days: 30, period: "week" });
|
||||||
|
await fetchTenants(25, 50, "tenant-parent");
|
||||||
|
await fetchClient("client-a");
|
||||||
|
await fetchClientRelations("client-a");
|
||||||
|
await fetchDevUsers("admin", 5, "client-a");
|
||||||
|
await fetchConsents("user-a", "client-a", "active");
|
||||||
|
await fetchDevAuditLogs(10, "cursor-a", {
|
||||||
|
action: "client.update",
|
||||||
|
client_id: "client-a",
|
||||||
|
status: "success",
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
});
|
||||||
|
await fetchMyTenants();
|
||||||
|
await fetchDeveloperRequestStatus("tenant-a");
|
||||||
|
await fetchDeveloperRequests("pending");
|
||||||
|
await listIdpConfigsForClient("client-a");
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/stats");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
|
||||||
|
params: { days: 30, period: "week" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/tenants", {
|
||||||
|
params: { limit: 25, offset: 50, parentId: "tenant-parent" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/relations",
|
||||||
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/users", {
|
||||||
|
params: { search: "admin", limit: 5, clientId: "client-a" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a", client_id: "client-a", status: "active" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", {
|
||||||
|
params: {
|
||||||
|
limit: 10,
|
||||||
|
cursor: "cursor-a",
|
||||||
|
action: "client.update",
|
||||||
|
client_id: "client-a",
|
||||||
|
status: "success",
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
"/dev/developer-request/status",
|
||||||
|
{
|
||||||
|
params: { tenantId: "tenant-a" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/developer-request/list", {
|
||||||
|
params: { status: "pending" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits optional consent filters when they are empty or all", async () => {
|
||||||
|
const { fetchConsents, revokeConsent } = await import("./devApi");
|
||||||
|
apiClient.get.mockResolvedValue({ data: { items: [] } });
|
||||||
|
apiClient.delete.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
await fetchConsents("user-a", undefined, "all");
|
||||||
|
await revokeConsent("user-a");
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a" },
|
||||||
|
});
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends mutation requests to the documented dev endpoints", async () => {
|
||||||
|
const {
|
||||||
|
addClientRelation,
|
||||||
|
removeClientRelation,
|
||||||
|
updateClientStatus,
|
||||||
|
createClient,
|
||||||
|
updateClient,
|
||||||
|
rotateClientSecret,
|
||||||
|
refreshHeadlessJwksCache,
|
||||||
|
revokeHeadlessJwksCache,
|
||||||
|
deleteClient,
|
||||||
|
revokeConsent,
|
||||||
|
createIdpConfigForClient,
|
||||||
|
updateIdpConfig,
|
||||||
|
deleteIdpConfig,
|
||||||
|
requestDeveloperAccess,
|
||||||
|
approveDeveloperRequest,
|
||||||
|
rejectDeveloperRequest,
|
||||||
|
cancelDeveloperRequestApproval,
|
||||||
|
} = await import("./devApi");
|
||||||
|
apiClient.post.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.put.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.patch.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.delete.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
await addClientRelation("client-a", {
|
||||||
|
relation: "admins",
|
||||||
|
userId: "user-a",
|
||||||
|
});
|
||||||
|
await removeClientRelation("client-a", "admins", "User:user-a");
|
||||||
|
await updateClientStatus("client-a", "inactive");
|
||||||
|
await createClient({ id: "client-a", name: "Console App" });
|
||||||
|
await updateClient("client-a", { name: "Console App Updated" });
|
||||||
|
await rotateClientSecret("client-a");
|
||||||
|
await refreshHeadlessJwksCache("client-a");
|
||||||
|
await revokeHeadlessJwksCache("client-a");
|
||||||
|
await deleteClient("client-a");
|
||||||
|
await revokeConsent("user-a", "client-a");
|
||||||
|
await createIdpConfigForClient({
|
||||||
|
client_id: "client-a",
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "OIDC Provider",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
await updateIdpConfig("client-a", "idp-a", { status: "inactive" });
|
||||||
|
await deleteIdpConfig("client-a", "idp-a");
|
||||||
|
await requestDeveloperAccess({
|
||||||
|
name: "Dev User",
|
||||||
|
organization: "Hanmac",
|
||||||
|
reason: "Need RP access",
|
||||||
|
tenantId: "tenant-a",
|
||||||
|
});
|
||||||
|
await approveDeveloperRequest(1, "approved");
|
||||||
|
await rejectDeveloperRequest(2, "rejected");
|
||||||
|
await cancelDeveloperRequestApproval(3, "cancelled");
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/relations",
|
||||||
|
{ relation: "admins", userId: "user-a" },
|
||||||
|
);
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/relations",
|
||||||
|
{
|
||||||
|
params: { relation: "admins", subject: "User:user-a" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/status",
|
||||||
|
{
|
||||||
|
status: "inactive",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", {
|
||||||
|
id: "client-a",
|
||||||
|
name: "Console App",
|
||||||
|
});
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", {
|
||||||
|
name: "Console App Updated",
|
||||||
|
});
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/secret/rotate",
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/headless-jwks/refresh",
|
||||||
|
);
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/headless-jwks/cache",
|
||||||
|
);
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith("/dev/clients/client-a");
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a", client_id: "client-a" },
|
||||||
|
});
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients/client-a/idps", {
|
||||||
|
client_id: "client-a",
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "OIDC Provider",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/idps/idp-a",
|
||||||
|
{
|
||||||
|
status: "inactive",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/idps/idp-a",
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith("/dev/developer-request", {
|
||||||
|
name: "Dev User",
|
||||||
|
organization: "Hanmac",
|
||||||
|
reason: "Need RP access",
|
||||||
|
tenantId: "tenant-a",
|
||||||
|
});
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/dev/developer-request/1/approve",
|
||||||
|
{
|
||||||
|
adminNotes: "approved",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/dev/developer-request/2/reject",
|
||||||
|
{
|
||||||
|
adminNotes: "rejected",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/dev/developer-request/3/cancel-approval",
|
||||||
|
{
|
||||||
|
adminNotes: "cancelled",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
166
orgfront/src/components/layout/AppLayout.test.tsx
Normal file
166
orgfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import AppLayout from "./AppLayout";
|
||||||
|
|
||||||
|
const authState = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
activeNavigator: undefined as string | undefined,
|
||||||
|
error: null as Error | null,
|
||||||
|
user: {
|
||||||
|
access_token: "access-token",
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||||
|
profile: {
|
||||||
|
sub: "user-1",
|
||||||
|
name: "Org Admin",
|
||||||
|
email: "org@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signinSilent: vi.fn(),
|
||||||
|
removeUser: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => authState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../features/auth/authApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "user-1",
|
||||||
|
name: "Fetched Org Admin",
|
||||||
|
email: "fetched@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||||
|
let text = fallback ?? key;
|
||||||
|
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||||
|
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authState.isAuthenticated = true;
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.activeNavigator = undefined;
|
||||||
|
authState.error = null;
|
||||||
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||||
|
authState.signinSilent.mockReset();
|
||||||
|
authState.signinSilent.mockResolvedValue(undefined);
|
||||||
|
authState.removeUser.mockReset();
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderLayout(initialEntry = "/clients") {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[initialEntry]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppLayout />}>
|
||||||
|
<Route path="clients" element={<div>Client outlet</div>} />
|
||||||
|
<Route path="profile" element={<div>Profile outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("orgfront AppLayout", () => {
|
||||||
|
it("renders shell navigation, profile summary, and outlet content", async () => {
|
||||||
|
const container = await renderLayout();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Developer Console");
|
||||||
|
expect(container.textContent).toContain("Clients");
|
||||||
|
expect(container.textContent).toContain("Client outlet");
|
||||||
|
expect(container.textContent).toContain("Fetched Org Admin");
|
||||||
|
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
||||||
|
const container = await renderLayout();
|
||||||
|
|
||||||
|
const themeButton = container.querySelector(
|
||||||
|
'button[aria-label="테마 전환"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
themeButton.click();
|
||||||
|
});
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
|
||||||
|
const profileButton = container.querySelector(
|
||||||
|
'button[aria-label="계정 메뉴 열기"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
profileButton.click();
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("Account");
|
||||||
|
|
||||||
|
const profileMenuItem = Array.from(
|
||||||
|
container.querySelectorAll('button[role="menuitem"]'),
|
||||||
|
).find((button) => button.textContent?.includes("내 정보"));
|
||||||
|
await act(async () => {
|
||||||
|
(profileMenuItem as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("Profile outlet");
|
||||||
|
|
||||||
|
const logoutButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent?.includes("Logout"),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
(logoutButton as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
|
expect(authState.removeUser).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts silent renewal after user action when the session is expiring", async () => {
|
||||||
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||||
|
await renderLayout();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authState.signinSilent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
orgfront/src/components/ui/basic.test.tsx
Normal file
95
orgfront/src/components/ui/basic.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
||||||
|
import { Badge } from "./badge";
|
||||||
|
import { Input } from "./input";
|
||||||
|
import { Label } from "./label";
|
||||||
|
import { Separator } from "./separator";
|
||||||
|
import { Switch } from "./switch";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "./table";
|
||||||
|
import { Textarea } from "./textarea";
|
||||||
|
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
const render = async (element: React.ReactElement) => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(element);
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) {
|
||||||
|
container.remove();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orgfront UI wrappers", () => {
|
||||||
|
it("renders form, badge, avatar, switch, separator, and table wrappers", async () => {
|
||||||
|
const root = await render(
|
||||||
|
<div>
|
||||||
|
<Badge className="custom-badge" variant="secondary">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
<Avatar className="custom-avatar">
|
||||||
|
<AvatarImage alt="Org user" src="/avatar.png" />
|
||||||
|
<AvatarFallback>OU</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Label className="custom-label" htmlFor="name">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input id="name" className="custom-input" defaultValue="Org User" />
|
||||||
|
<Textarea className="custom-textarea" defaultValue="Memo" />
|
||||||
|
<Switch className="custom-switch" defaultChecked />
|
||||||
|
<Separator className="custom-separator" />
|
||||||
|
<Table className="custom-table">
|
||||||
|
<TableCaption>Members</TableCaption>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Org User</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Total</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain("Active");
|
||||||
|
expect(container?.textContent).toContain("OU");
|
||||||
|
expect(container?.querySelector(".custom-input")).not.toBeNull();
|
||||||
|
expect(container?.querySelector(".custom-switch")).not.toBeNull();
|
||||||
|
expect(container?.querySelector(".custom-separator")).not.toBeNull();
|
||||||
|
expect(container?.textContent).toContain("Members");
|
||||||
|
expect(container?.textContent).toContain("Total");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
307
orgfront/src/features/coverage/pageSmoke.test.tsx
Normal file
307
orgfront/src/features/coverage/pageSmoke.test.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
|
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||||
|
import AuthPage from "../auth/AuthPage";
|
||||||
|
import ClientConsentsPage from "../clients/ClientConsentsPage";
|
||||||
|
import ClientDetailsPage from "../clients/ClientDetailsPage";
|
||||||
|
import ClientGeneralPage from "../clients/ClientGeneralPage";
|
||||||
|
import ClientsPage from "../clients/ClientsPage";
|
||||||
|
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
|
||||||
|
import DashboardPage from "../dashboard/DashboardPage";
|
||||||
|
import ProfilePage from "../profile/ProfilePage";
|
||||||
|
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user: {
|
||||||
|
access_token: "access-token",
|
||||||
|
profile: {
|
||||||
|
sub: "user-1",
|
||||||
|
role: "super_admin",
|
||||||
|
tenant_id: "tenant-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signinRedirect: vi.fn(),
|
||||||
|
removeUser: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/i18n", () => ({
|
||||||
|
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||||
|
let text = fallback ?? key;
|
||||||
|
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||||
|
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clientSummary = {
|
||||||
|
id: "client-a",
|
||||||
|
name: "Console App",
|
||||||
|
type: "private" as const,
|
||||||
|
status: "active" as const,
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
redirectUris: ["https://app.example/callback"],
|
||||||
|
scopes: ["openid", "profile"],
|
||||||
|
tokenEndpointAuthMethod: "client_secret_basic",
|
||||||
|
metadata: {
|
||||||
|
headless_login_enabled: true,
|
||||||
|
headless_login_jwks_uri: "https://app.example/jwks.json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientDetail = {
|
||||||
|
client: {
|
||||||
|
...clientSummary,
|
||||||
|
clientSecret: "secret-value",
|
||||||
|
jwksUri: "https://app.example/jwks.json",
|
||||||
|
grantTypes: ["authorization_code"],
|
||||||
|
responseTypes: ["code"],
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
discovery: "https://sso.example/.well-known/openid-configuration",
|
||||||
|
issuer: "https://sso.example",
|
||||||
|
authorization: "https://sso.example/oauth2/auth",
|
||||||
|
token: "https://sso.example/oauth2/token",
|
||||||
|
userinfo: "https://sso.example/userinfo",
|
||||||
|
},
|
||||||
|
headlessJwksCache: {
|
||||||
|
clientId: "client-a",
|
||||||
|
jwksUri: "https://app.example/jwks.json",
|
||||||
|
cachedAt: "2026-05-01T00:00:00Z",
|
||||||
|
expiresAt: "2026-05-02T00:00:00Z",
|
||||||
|
lastRefreshStatus: "success" as const,
|
||||||
|
cachedKids: ["kid-1"],
|
||||||
|
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../lib/devApi", () => ({
|
||||||
|
fetchClients: vi.fn(async () => ({
|
||||||
|
items: [clientSummary],
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
})),
|
||||||
|
fetchDevStats: vi.fn(async () => ({
|
||||||
|
total_clients: 1,
|
||||||
|
active_sessions: 12,
|
||||||
|
auth_failures_24h: 2,
|
||||||
|
})),
|
||||||
|
fetchClient: vi.fn(async () => clientDetail),
|
||||||
|
updateClientStatus: vi.fn(async () => clientDetail),
|
||||||
|
createClient: vi.fn(async () => clientDetail),
|
||||||
|
updateClient: vi.fn(async () => clientDetail),
|
||||||
|
rotateClientSecret: vi.fn(async () => clientDetail),
|
||||||
|
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
|
||||||
|
revokeHeadlessJwksCache: vi.fn(async () => undefined),
|
||||||
|
deleteClient: vi.fn(async () => undefined),
|
||||||
|
fetchConsents: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
subject: "user-1",
|
||||||
|
userName: "Consent User",
|
||||||
|
clientId: "client-a",
|
||||||
|
clientName: "Console App",
|
||||||
|
grantedScopes: ["openid", "profile"],
|
||||||
|
authenticatedAt: "2026-05-01T02:00:00Z",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
status: "active",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenantName: "Hanmac",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
revokeConsent: vi.fn(async () => undefined),
|
||||||
|
listIdpConfigsForClient: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: "idp-1",
|
||||||
|
client_id: "client-a",
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "Workspace OIDC",
|
||||||
|
status: "active",
|
||||||
|
issuer_url: "https://accounts.example",
|
||||||
|
oidc_client_id: "oidc-client",
|
||||||
|
scopes: "openid email profile",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
createIdpConfigForClient: vi.fn(async (payload) => ({
|
||||||
|
id: "idp-1",
|
||||||
|
...payload,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
})),
|
||||||
|
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
|
||||||
|
id: idpId,
|
||||||
|
client_id: "client-a",
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "Provider",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T00:00:00Z",
|
||||||
|
...payload,
|
||||||
|
})),
|
||||||
|
deleteIdpConfig: vi.fn(async () => undefined),
|
||||||
|
fetchDevAuditLogs: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
event_id: "event-1",
|
||||||
|
timestamp: "2026-05-01T00:00:00Z",
|
||||||
|
user_id: "user-1",
|
||||||
|
event_type: "client.update",
|
||||||
|
status: "success",
|
||||||
|
ip_address: "127.0.0.1",
|
||||||
|
user_agent: "vitest",
|
||||||
|
details: JSON.stringify({
|
||||||
|
action: "client.update",
|
||||||
|
target_id: "client-a",
|
||||||
|
tenant_id: "tenant-1",
|
||||||
|
request_id: "req-1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limit: 50,
|
||||||
|
})),
|
||||||
|
fetchMyTenants: vi.fn(async () => [
|
||||||
|
{ id: "tenant-1", name: "Hanmac", slug: "hanmac" },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../auth/authApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({
|
||||||
|
id: "user-1",
|
||||||
|
name: "Org User",
|
||||||
|
email: "org@example.com",
|
||||||
|
phone: "010-0000-0000",
|
||||||
|
role: "super_admin",
|
||||||
|
department: "Platform",
|
||||||
|
tenantId: "tenant-1",
|
||||||
|
tenant: { id: "tenant-1", name: "Hanmac", slug: "hanmac" },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderWithProviders(
|
||||||
|
element: React.ReactElement,
|
||||||
|
initialEntry = "/",
|
||||||
|
) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[initialEntry]}>{element}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("orgfront page smoke coverage", () => {
|
||||||
|
it("renders the dashboard content", async () => {
|
||||||
|
const container = await renderWithProviders(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("RP 등록 현황");
|
||||||
|
expect(container.textContent).toContain("Stack readiness");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders static auth guidance and forbidden messages", async () => {
|
||||||
|
const auth = await renderWithProviders(<AuthPage />);
|
||||||
|
expect(auth.textContent).toContain("Admin auth guardrails");
|
||||||
|
expect(auth.textContent).toContain("TTL discipline");
|
||||||
|
|
||||||
|
const forbidden = await renderWithProviders(
|
||||||
|
<ForbiddenMessage resourceToken="clients" />,
|
||||||
|
);
|
||||||
|
expect(forbidden.textContent).toContain("연동 앱 접근 권한 없음");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders client list, detail, edit, consent, and federation pages", async () => {
|
||||||
|
const clients = await renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/clients" element={<ClientsPage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/clients",
|
||||||
|
);
|
||||||
|
expect(clients.textContent).toContain("Console App");
|
||||||
|
|
||||||
|
const details = await renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/clients/:id" element={<ClientDetailsPage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/clients/client-a",
|
||||||
|
);
|
||||||
|
expect(details.textContent).toContain("Client Secret");
|
||||||
|
expect(details.textContent).toContain("https://sso.example/oauth2/token");
|
||||||
|
|
||||||
|
const general = await renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/clients/:id/edit" element={<ClientGeneralPage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/clients/client-a/edit",
|
||||||
|
);
|
||||||
|
expect(general.textContent).toContain("Console App");
|
||||||
|
|
||||||
|
const consents = await renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/clients/:id/consents" element={<ClientConsentsPage />} />
|
||||||
|
</Routes>,
|
||||||
|
"/clients/client-a/consents",
|
||||||
|
);
|
||||||
|
expect(consents.textContent).toContain("Consent User");
|
||||||
|
|
||||||
|
const federation = await renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/clients/:id/federation"
|
||||||
|
element={<ClientFederationPage />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/clients/client-a/federation",
|
||||||
|
);
|
||||||
|
expect(federation.textContent).toContain("Workspace OIDC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders audit logs and profile pages", async () => {
|
||||||
|
const auditLogs = await renderWithProviders(<AuditLogsPage />);
|
||||||
|
expect(auditLogs.textContent).toContain("client.update");
|
||||||
|
expect(auditLogs.textContent).toContain("Loaded 1 rows");
|
||||||
|
|
||||||
|
const profile = await renderWithProviders(<ProfilePage />);
|
||||||
|
expect(profile.textContent).toContain("Org User");
|
||||||
|
expect(profile.textContent).toContain("Hanmac");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,161 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const apiClient = {
|
const apiClient = {
|
||||||
|
get: vi.fn(),
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
put: vi.fn(),
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAllCursorPages = vi.fn(async () => ({
|
||||||
|
items: [{ id: "tenant-1", name: "Tenant", slug: "tenant" }],
|
||||||
|
total: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./apiClient", () => ({
|
vi.mock("./apiClient", () => ({
|
||||||
default: apiClient,
|
default: apiClient,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./auth", () => ({
|
||||||
|
userManager: {
|
||||||
|
getUser: vi.fn(async () => ({ access_token: "access-token" })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../common/core/pagination", () => ({
|
||||||
|
fetchAllCursorPages,
|
||||||
|
}));
|
||||||
|
|
||||||
describe("orgfront adminApi user tenant payloads", () => {
|
describe("orgfront adminApi user tenant payloads", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
apiClient.get.mockReset();
|
||||||
apiClient.post.mockReset();
|
apiClient.post.mockReset();
|
||||||
apiClient.put.mockReset();
|
apiClient.put.mockReset();
|
||||||
|
apiClient.delete.mockReset();
|
||||||
|
|
||||||
|
apiClient.get.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.post.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.put.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.delete.mockResolvedValue({ data: { ok: true } });
|
||||||
|
fetchAllCursorPages.mockClear();
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes read APIs to their documented orgfront admin endpoints", async () => {
|
||||||
|
const adminApi = await import("./adminApi");
|
||||||
|
|
||||||
|
await adminApi.fetchAuditLogs(10, "cursor-a");
|
||||||
|
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
|
||||||
|
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
|
||||||
|
await adminApi.fetchTenant("tenant-1");
|
||||||
|
await adminApi.fetchTenantAdmins("tenant-1");
|
||||||
|
await adminApi.fetchTenantOwners("tenant-1");
|
||||||
|
await adminApi.fetchGroups("tenant-1");
|
||||||
|
await adminApi.fetchGroup("tenant-1", "group-1");
|
||||||
|
await adminApi.fetchImportProgress("tenant-1", "progress-1");
|
||||||
|
await adminApi.fetchGroupRoles("tenant-1", "group-1");
|
||||||
|
await adminApi.fetchApiKeys(20, 40);
|
||||||
|
await adminApi.fetchUsers(30, 60, "admin", "tenant");
|
||||||
|
await adminApi.fetchUser("user-1");
|
||||||
|
await adminApi.fetchPasswordPolicy();
|
||||||
|
await adminApi.fetchUserRpHistory("user-1");
|
||||||
|
await adminApi.fetchMe();
|
||||||
|
await adminApi.fetchRelyingParties("tenant-1");
|
||||||
|
await adminApi.fetchAllRelyingParties();
|
||||||
|
await adminApi.fetchRelyingParty("client-1");
|
||||||
|
await adminApi.fetchRPOwners("client-1");
|
||||||
|
await adminApi.fetchPublicOrgChart("public-token");
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
|
||||||
|
params: { limit: 10, cursor: "cursor-a" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
|
||||||
|
params: {
|
||||||
|
limit: 25,
|
||||||
|
offset: 50,
|
||||||
|
parentId: "parent-1",
|
||||||
|
cursor: "cursor-b",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fetchAllCursorPages).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "/v1/admin/tenants",
|
||||||
|
pageSize: 200,
|
||||||
|
params: { parentId: "parent-1" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/organization/group-1/roles",
|
||||||
|
);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/v1/public/orgchart", {
|
||||||
|
params: { token: "public-token" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes mutation APIs to their documented orgfront admin endpoints", async () => {
|
||||||
|
const adminApi = await import("./adminApi");
|
||||||
|
|
||||||
|
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
|
||||||
|
await adminApi.updateTenant("tenant-1", { status: "inactive" });
|
||||||
|
await adminApi.deleteTenant("tenant-1");
|
||||||
|
await adminApi.deleteTenantsBulk(["tenant-1"]);
|
||||||
|
await adminApi.approveTenant("tenant-1");
|
||||||
|
await adminApi.addTenantAdmin("tenant-1", "user-1");
|
||||||
|
await adminApi.removeTenantAdmin("tenant-1", "user-1");
|
||||||
|
await adminApi.addTenantOwner("tenant-1", "user-1");
|
||||||
|
await adminApi.removeTenantOwner("tenant-1", "user-1");
|
||||||
|
await adminApi.createGroup("tenant-1", { name: "Group" });
|
||||||
|
await adminApi.deleteGroup("tenant-1", "group-1");
|
||||||
|
await adminApi.addGroupMember("tenant-1", "group-1", "user-1");
|
||||||
|
await adminApi.removeGroupMember("tenant-1", "group-1", "user-1");
|
||||||
|
await adminApi.importOrgChart(
|
||||||
|
"tenant-1",
|
||||||
|
new File(["name"], "org.csv"),
|
||||||
|
"progress-1",
|
||||||
|
);
|
||||||
|
await adminApi.assignGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||||
|
await adminApi.removeGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||||
|
await adminApi.createApiKey({ name: "key", scopes: ["read"] });
|
||||||
|
await adminApi.deleteApiKey("key-1");
|
||||||
|
await adminApi.createUser({ email: "user@example.com", name: "User" });
|
||||||
|
await adminApi.bulkCreateUsers([
|
||||||
|
{ email: "user@example.com", name: "User", metadata: {} },
|
||||||
|
]);
|
||||||
|
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
|
||||||
|
await adminApi.bulkDeleteUsers(["user-1"]);
|
||||||
|
await adminApi.updateUser("user-1", { status: "active" });
|
||||||
|
await adminApi.deleteUser("user-1");
|
||||||
|
await adminApi.createRelyingParty("tenant-1", {
|
||||||
|
client_name: "RP",
|
||||||
|
redirect_uris: ["https://rp.example/callback"],
|
||||||
|
});
|
||||||
|
await adminApi.updateRelyingParty("client-1", {
|
||||||
|
client_name: "RP",
|
||||||
|
redirect_uris: ["https://rp.example/callback"],
|
||||||
|
});
|
||||||
|
await adminApi.deleteRelyingParty("client-1");
|
||||||
|
await adminApi.addRPOwner("client-1", "User:user-1");
|
||||||
|
await adminApi.removeRPOwner("client-1", "User:user-1");
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/organization/group-1/members",
|
||||||
|
{ userId: "user-1" },
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/tenants/tenant-1/organization/import?progressId=progress-1",
|
||||||
|
expect.any(FormData),
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||||
|
"/v1/admin/relying-parties/client-1/owners/User:user-1",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
|
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
|
||||||
const { createUser } = await import("./adminApi");
|
const { createUser } = await import("./adminApi");
|
||||||
apiClient.post.mockResolvedValue({ data: {} });
|
|
||||||
|
|
||||||
await createUser({
|
await createUser({
|
||||||
email: "user@test.com",
|
email: "user@test.com",
|
||||||
@@ -34,7 +172,6 @@ describe("orgfront adminApi user tenant payloads", () => {
|
|||||||
|
|
||||||
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
|
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
|
||||||
const { updateUser } = await import("./adminApi");
|
const { updateUser } = await import("./adminApi");
|
||||||
apiClient.put.mockResolvedValue({ data: {} });
|
|
||||||
|
|
||||||
await updateUser("user-id", { tenantSlug: "new-tenant" });
|
await updateUser("user-id", { tenantSlug: "new-tenant" });
|
||||||
|
|
||||||
@@ -47,8 +184,6 @@ describe("orgfront adminApi user tenant payloads", () => {
|
|||||||
|
|
||||||
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
|
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
|
||||||
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
|
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
|
||||||
apiClient.post.mockResolvedValue({ data: {} });
|
|
||||||
apiClient.put.mockResolvedValue({ data: {} });
|
|
||||||
|
|
||||||
await bulkCreateUsers([
|
await bulkCreateUsers([
|
||||||
{
|
{
|
||||||
|
|||||||
139
orgfront/src/lib/devApi.test.ts
Normal file
139
orgfront/src/lib/devApi.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const apiClient = {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("./apiClient", () => ({
|
||||||
|
default: apiClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("orgfront devApi", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiClient.get.mockReset();
|
||||||
|
apiClient.post.mockReset();
|
||||||
|
apiClient.put.mockReset();
|
||||||
|
apiClient.patch.mockReset();
|
||||||
|
apiClient.delete.mockReset();
|
||||||
|
|
||||||
|
apiClient.get.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.post.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.put.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.patch.mockResolvedValue({ data: { ok: true } });
|
||||||
|
apiClient.delete.mockResolvedValue({ data: { ok: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches dev resources with expected query parameters", async () => {
|
||||||
|
const {
|
||||||
|
fetchClients,
|
||||||
|
fetchDevStats,
|
||||||
|
fetchClient,
|
||||||
|
fetchConsents,
|
||||||
|
fetchDevAuditLogs,
|
||||||
|
fetchMyTenants,
|
||||||
|
listIdpConfigsForClient,
|
||||||
|
} = await import("./devApi");
|
||||||
|
|
||||||
|
await fetchClients();
|
||||||
|
await fetchDevStats();
|
||||||
|
await fetchClient("client-a");
|
||||||
|
await fetchConsents("user-a", "client-a", "active");
|
||||||
|
await fetchDevAuditLogs(10, "cursor-a", {
|
||||||
|
action: "client.update",
|
||||||
|
client_id: "client-a",
|
||||||
|
status: "success",
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
});
|
||||||
|
await fetchMyTenants();
|
||||||
|
await listIdpConfigsForClient("client-a");
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/stats");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a", client_id: "client-a", status: "active" },
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", {
|
||||||
|
params: {
|
||||||
|
limit: 10,
|
||||||
|
cursor: "cursor-a",
|
||||||
|
action: "client.update",
|
||||||
|
client_id: "client-a",
|
||||||
|
status: "success",
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants");
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits optional consent filters when they are empty or all", async () => {
|
||||||
|
const { fetchConsents, revokeConsent } = await import("./devApi");
|
||||||
|
|
||||||
|
await fetchConsents("user-a", undefined, "all");
|
||||||
|
await revokeConsent("user-a");
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a" },
|
||||||
|
});
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends mutation requests to the documented dev endpoints", async () => {
|
||||||
|
const {
|
||||||
|
updateClientStatus,
|
||||||
|
createClient,
|
||||||
|
updateClient,
|
||||||
|
rotateClientSecret,
|
||||||
|
refreshHeadlessJwksCache,
|
||||||
|
revokeHeadlessJwksCache,
|
||||||
|
deleteClient,
|
||||||
|
revokeConsent,
|
||||||
|
createIdpConfigForClient,
|
||||||
|
updateIdpConfig,
|
||||||
|
deleteIdpConfig,
|
||||||
|
} = await import("./devApi");
|
||||||
|
|
||||||
|
await updateClientStatus("client-a", "inactive");
|
||||||
|
await createClient({ id: "client-a", name: "Console App" });
|
||||||
|
await updateClient("client-a", { name: "Console App Updated" });
|
||||||
|
await rotateClientSecret("client-a");
|
||||||
|
await refreshHeadlessJwksCache("client-a");
|
||||||
|
await revokeHeadlessJwksCache("client-a");
|
||||||
|
await deleteClient("client-a");
|
||||||
|
await revokeConsent("user-a", "client-a");
|
||||||
|
await createIdpConfigForClient({
|
||||||
|
client_id: "client-a",
|
||||||
|
provider_type: "oidc",
|
||||||
|
display_name: "OIDC Provider",
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
await updateIdpConfig("client-a", "idp-a", { status: "inactive" });
|
||||||
|
await deleteIdpConfig("client-a", "idp-a");
|
||||||
|
|
||||||
|
expect(apiClient.patch).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/status",
|
||||||
|
{ status: "inactive" },
|
||||||
|
);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", {
|
||||||
|
id: "client-a",
|
||||||
|
name: "Console App",
|
||||||
|
});
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", {
|
||||||
|
name: "Console App Updated",
|
||||||
|
});
|
||||||
|
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
|
||||||
|
params: { subject: "user-a", client_id: "client-a" },
|
||||||
|
});
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith(
|
||||||
|
"/dev/clients/client-a/idps/idp-a",
|
||||||
|
{ status: "inactive" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ const resultStyles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const badgeDefinitions = {
|
const badgeDefinitions = {
|
||||||
|
"dev-sha": { label: "dev", message: "unknown", color: "#0969da" },
|
||||||
"code-check": { label: "code check", message: "unknown", color: "#6e7781" },
|
"code-check": { label: "code check", message: "unknown", color: "#6e7781" },
|
||||||
biome: { label: "biome", message: "unknown", color: "#6e7781" },
|
biome: { label: "biome", message: "unknown", color: "#6e7781" },
|
||||||
"userfront-e2e-fast": {
|
"userfront-e2e-fast": {
|
||||||
@@ -147,19 +148,36 @@ function updateCoverageBadges(manifest, coverageSummary) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortSha(value) {
|
||||||
|
return String(value ?? "").trim().slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
const existingManifest = process.env.RESET_BADGES === "true"
|
const existingManifest = process.env.RESET_BADGES === "true"
|
||||||
? null
|
? null
|
||||||
: await readJsonIfExists(manifestPath);
|
: await readJsonIfExists(manifestPath);
|
||||||
|
const sourceSha = shortSha(process.env.BADGE_SOURCE_SHA || process.env.GITHUB_SHA);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
generatedBy: "scripts/update_code_check_badges.mjs",
|
generatedBy: "scripts/update_code_check_badges.mjs",
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
source: {
|
||||||
|
branch: process.env.BADGE_SOURCE_BRANCH || "dev",
|
||||||
|
sha: process.env.BADGE_SOURCE_SHA || process.env.GITHUB_SHA || null,
|
||||||
|
shortSha: sourceSha || null,
|
||||||
|
runId: process.env.GITHUB_RUN_ID || null,
|
||||||
|
runNumber: process.env.GITHUB_RUN_NUMBER || null,
|
||||||
|
},
|
||||||
badges: {
|
badges: {
|
||||||
...badgeDefinitions,
|
...badgeDefinitions,
|
||||||
...(existingManifest?.badges ?? {}),
|
...(existingManifest?.badges ?? {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
manifest.badges["dev-sha"] = {
|
||||||
|
...badgeDefinitions["dev-sha"],
|
||||||
|
message: sourceSha || "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
const jobResults = {
|
const jobResults = {
|
||||||
lint: process.env.LINT_RESULT,
|
lint: process.env.LINT_RESULT,
|
||||||
biome: process.env.BIOME_RESULT,
|
biome: process.env.BIOME_RESULT,
|
||||||
@@ -178,17 +196,19 @@ const hasFailure = overallResults.some((result) =>
|
|||||||
);
|
);
|
||||||
const allSkipped = overallResults.length > 0 &&
|
const allSkipped = overallResults.length > 0 &&
|
||||||
overallResults.every((result) => result === "skipped");
|
overallResults.every((result) => result === "skipped");
|
||||||
updateResultBadge(
|
if (process.env.BADGE_UPDATE_CODE_CHECK !== "false") {
|
||||||
manifest,
|
updateResultBadge(
|
||||||
"code-check",
|
manifest,
|
||||||
overallResults.length === 0
|
"code-check",
|
||||||
? "unknown"
|
overallResults.length === 0
|
||||||
: hasFailure
|
? "unknown"
|
||||||
? "failure"
|
: hasFailure
|
||||||
: allSkipped
|
? "failure"
|
||||||
? "skipped"
|
: allSkipped
|
||||||
: "success",
|
? "skipped"
|
||||||
);
|
: "success",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
updateResultBadge(manifest, "biome", jobResults.biome);
|
updateResultBadge(manifest, "biome", jobResults.biome);
|
||||||
|
|
||||||
|
|||||||
50
test/code_check_badge_branch_policy_test.sh
Normal file
50
test/code_check_badge_branch_policy_test.sh
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WORKFLOW_FILE="$ROOT_DIR/.gitea/workflows/code_check.yml"
|
||||||
|
FULL_NIGHTLY_WORKFLOW_FILE="$ROOT_DIR/.gitea/workflows/userfront_e2e_full_nightly.yml"
|
||||||
|
README_FILE="$ROOT_DIR/README.md"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local file="$1"
|
||||||
|
local pattern="$2"
|
||||||
|
grep -Fq -- "$pattern" "$file" || fail "missing pattern in $file: $pattern"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_not_contains() {
|
||||||
|
local file="$1"
|
||||||
|
local pattern="$2"
|
||||||
|
if grep -Fq -- "$pattern" "$file"; then
|
||||||
|
fail "forbidden pattern in $file: $pattern"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains "$WORKFLOW_FILE" "BADGE_BRANCH=badges"
|
||||||
|
assert_contains "$WORKFLOW_FILE" 'push origin HEAD:${BADGE_BRANCH}'
|
||||||
|
assert_contains "$WORKFLOW_FILE" 'BADGE_SOURCE_SHA: ${{ github.sha }}'
|
||||||
|
assert_contains "$WORKFLOW_FILE" 'BADGE_LATEST_DIR="${BADGE_WORKTREE}/latest"'
|
||||||
|
assert_contains "$WORKFLOW_FILE" 'BADGE_SHA_DIR="${BADGE_WORKTREE}/dev/${GITHUB_SHA}"'
|
||||||
|
if grep -Eq "^[[:space:]]+git push$" "$WORKFLOW_FILE"; then
|
||||||
|
fail "Code Check workflow must not push back to the current branch"
|
||||||
|
fi
|
||||||
|
assert_contains "$README_FILE" "https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/code-check.svg"
|
||||||
|
assert_contains "$README_FILE" "https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/dev-sha.svg"
|
||||||
|
assert_contains "$README_FILE" "https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/userfront-e2e-full.svg"
|
||||||
|
assert_contains "$README_FILE" "https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/adminfront-coverage.svg"
|
||||||
|
assert_not_contains "$README_FILE" "](docs/badges/"
|
||||||
|
|
||||||
|
assert_contains "$FULL_NIGHTLY_WORKFLOW_FILE" "cron: \"0 18 * * *\""
|
||||||
|
assert_contains "$FULL_NIGHTLY_WORKFLOW_FILE" "make code-check-lint"
|
||||||
|
assert_contains "$FULL_NIGHTLY_WORKFLOW_FILE" "refs/remotes/origin/badges:dev/\${target_sha}/badges.json"
|
||||||
|
assert_contains "$FULL_NIGHTLY_WORKFLOW_FILE" "full-result-exists:\${full_message}"
|
||||||
|
assert_contains "$FULL_NIGHTLY_WORKFLOW_FILE" "USERFRONT_E2E_FULL: \"true\""
|
||||||
|
assert_contains "$FULL_NIGHTLY_WORKFLOW_FILE" "BADGE_UPDATE_CODE_CHECK: \"false\""
|
||||||
|
assert_contains "$FULL_NIGHTLY_WORKFLOW_FILE" "npm test"
|
||||||
|
|
||||||
|
echo "OK: Code Check badges are published to the badges branch"
|
||||||
@@ -180,26 +180,25 @@ async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
async function clickVerificationAction(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(500);
|
||||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
if (page.isClosed() || !page.url().includes("/verify-complete")) {
|
||||||
if (await button.count()) {
|
|
||||||
await button.first().evaluate((node) => {
|
|
||||||
(node as HTMLElement).click();
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const placeholder = page.locator("flt-semantics-placeholder").first();
|
|
||||||
if (await placeholder.count()) {
|
const viewport = page.viewportSize();
|
||||||
await placeholder.evaluate((node) => {
|
if (!viewport) {
|
||||||
(node as HTMLElement).click();
|
throw new Error("Viewport size was not available.");
|
||||||
});
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
}
|
}
|
||||||
|
await page.mouse.click(
|
||||||
|
viewport.width / 2,
|
||||||
|
Math.min(viewport.height - 24, viewport.height / 2 + 120),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("UserFront WASM auth routing", () => {
|
test.describe("UserFront WASM auth routing", () => {
|
||||||
|
test.describe.configure({ mode: "default" });
|
||||||
|
|
||||||
test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({
|
test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -332,8 +331,7 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
|
||||||
await enableFlutterAccessibility(page);
|
await clickVerificationAction(page);
|
||||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
@@ -362,17 +360,7 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
|
|
||||||
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
await enableFlutterAccessibility(page);
|
await clickVerificationAction(page);
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText("요청하신 로그인이 완료되었습니다"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0);
|
|
||||||
await expect(
|
|
||||||
page.getByRole("button", { name: "로그인 창으로 이동하기" }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -491,12 +479,9 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
|
||||||
if (!popup.isClosed()) {
|
if (!popup.isClosed()) {
|
||||||
await enableFlutterAccessibility(popup);
|
|
||||||
const closePromise = popup.waitForEvent("close").catch(() => undefined);
|
const closePromise = popup.waitForEvent("close").catch(() => undefined);
|
||||||
try {
|
try {
|
||||||
await popup
|
await clickVerificationAction(popup);
|
||||||
.getByRole("button", { name: "로그인 창으로 이동하기" })
|
|
||||||
.click();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!popup.isClosed()) {
|
if (!popup.isClosed()) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -542,8 +527,7 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
verifyOnly: true,
|
verifyOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await enableFlutterAccessibility(page);
|
await clickVerificationAction(page);
|
||||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
@@ -586,8 +570,7 @@ test.describe("UserFront WASM auth routing", () => {
|
|||||||
verifyOnly: true,
|
verifyOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await enableFlutterAccessibility(page);
|
await clickVerificationAction(page);
|
||||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||||
|
|||||||
@@ -208,11 +208,6 @@ test.describe("UserFront login performance budget", () => {
|
|||||||
...cold.cacheControlByPath,
|
...cold.cacheControlByPath,
|
||||||
...warm.cacheControlByPath,
|
...warm.cacheControlByPath,
|
||||||
]);
|
]);
|
||||||
const contentEncodingByPath = new Map([
|
|
||||||
...cold.contentEncodingByPath,
|
|
||||||
...warm.contentEncodingByPath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const appShellCache = cacheControlByPath.get("/ko/signin") ?? "";
|
const appShellCache = cacheControlByPath.get("/ko/signin") ?? "";
|
||||||
expect(appShellCache).toContain("no-cache");
|
expect(appShellCache).toContain("no-cache");
|
||||||
const serviceWorkerState = await page.evaluate(async () => {
|
const serviceWorkerState = await page.evaluate(async () => {
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ async function mockAuthApis(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path.endsWith("/api/v1/user/me")) {
|
if (path.endsWith("/api/v1/user/me")) {
|
||||||
const authHeader = route.request().headers()["authorization"] ?? "";
|
const authHeader = route.request().headers().authorization ?? "";
|
||||||
if (!authHeader.startsWith("Bearer ")) {
|
if (!authHeader.startsWith("Bearer ")) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 401,
|
status: 401,
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
|||||||
const method = request.method().toUpperCase();
|
const method = request.method().toUpperCase();
|
||||||
|
|
||||||
if (path.endsWith("/api/v1/user/me") && method === "GET") {
|
if (path.endsWith("/api/v1/user/me") && method === "GET") {
|
||||||
const authHeader = request.headers()["authorization"] ?? "";
|
const authHeader = request.headers().authorization ?? "";
|
||||||
if (!authHeader.startsWith("Bearer ")) {
|
if (!authHeader.startsWith("Bearer ")) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 401,
|
status: 401,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function mockInventoryApis(page: Page): Promise<void> {
|
|||||||
const method = route.request().method().toUpperCase();
|
const method = route.request().method().toUpperCase();
|
||||||
|
|
||||||
if (path.endsWith("/api/v1/user/me")) {
|
if (path.endsWith("/api/v1/user/me")) {
|
||||||
const authHeader = route.request().headers()["authorization"] ?? "";
|
const authHeader = route.request().headers().authorization ?? "";
|
||||||
if (authHeader.startsWith("Bearer ")) {
|
if (authHeader.startsWith("Bearer ")) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -23,23 +23,6 @@ function ensureCredentials(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
|
||||||
if (await button.count()) {
|
|
||||||
try {
|
|
||||||
await button.click({ force: true });
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const placeholder = page.locator("flt-semantics-placeholder");
|
|
||||||
if (await placeholder.count()) {
|
|
||||||
await placeholder.first().click({ force: true });
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickPasswordTab(page: Page): Promise<void> {
|
async function clickPasswordTab(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
const pane = page.locator("flt-glass-pane");
|
const pane = page.locator("flt-glass-pane");
|
||||||
|
|||||||
Reference in New Issue
Block a user