diff --git a/docs/status/REAL_PROJECT_STATUS.md b/docs/status/REAL_PROJECT_STATUS.md index 93da2f4..29b7d92 100644 --- a/docs/status/REAL_PROJECT_STATUS.md +++ b/docs/status/REAL_PROJECT_STATUS.md @@ -1,48 +1,144 @@ # REAL PROJECT STATUS -## 2026-04-23 E2E Recovery Update +## 2026-04-24 Profile Security Contract Recovery And Browser Re-Verification ### Latest Verification Snapshot | Command | Result | Note | |------|------|------| -| `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/DevicesPage/DevicesPage.test.tsx` | `PASS` | cursor pagination no longer auto-advances and flood-loads `/admin/devices` | -| `cd frontend/admin && npm.cmd run test:run -- src/services/webhooks.test.ts` | `PASS` | webhook list and deliveries decoding now matches backend envelopes | -| `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/WebhooksPage/WebhooksPage.test.tsx` | `PASS` | webhook management page still works after service fix | -| `cd frontend/admin && npm.cmd run test:run -- src/services/social-accounts.test.ts` | `PASS` | social accounts decoding now matches backend `accounts` payload | -| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the recovery changes | -| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the recovery changes | -| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level Playwright CDP E2E path re-ran green in the current workspace | +| `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/ProfileSecurityPage/ProfileSecurityPage.behavior.test.tsx src/services/profile.test.ts src/services/service_adapters_additional.test.ts` | `PASS` | targeted profile page and service regression set passed `3` files / `22` tests after the password-write contract fix | +| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after action-scoped fetch wait changes | +| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the profile password adapter fix and runner cleanup | +| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the profile password adapter fix and runner cleanup | +| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level Playwright CDP E2E path re-ran green with `20` scenarios, including the repaired `profile-and-security` chain | ### Current Honest Status -- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green again in the current workspace. -- The re-verified scenarios now include: +- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green in the current workspace after re-verifying the full `20`-scenario suite. +- The directly affected frontend verification set is green in the current workspace: + - targeted profile page and service tests + - `npm.cmd run lint` + - `npm.cmd run build` +- The concrete defects fixed in this round were: + - frontend profile password writes were still sending the UI form shape (`current_password`, `confirm_password`) to `/users/:id/password`, while the real backend handler binds `old_password` and `new_password`, which produced a real browser-visible `400`; + - the Playwright `profile-and-security` scenario could leave background fetch waiters running after a later locator failure, which then collapsed into misleading `Target page, context or browser has been closed` noise instead of exposing the true failing step. +- This round did **not** re-run the full backend matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`); the latest backend-wide green evidence remains the 2026-04-23 snapshot below. + +### Boundary + +- This update re-proves the directly affected frontend regression set and the supported browser-level E2E gate in the current workspace. +- It does **not** by itself re-prove the full backend matrix, live third-party OAuth verification, or OS-level automation closure. + +## 2026-04-23 Permissions CRUD And Full Matrix Closure + +### Latest Verification Snapshot + +| Command | Result | Note | +|------|------|------| +| `go test ./... -count=1` | `PASS` | full backend test matrix re-ran green on the current branch state | +| `go vet ./...` | `PASS` | backend vet is green on the current branch state | +| `go build ./cmd/server` | `PASS` | backend build is green on the current branch state | +| `cd frontend/admin && npm.cmd run test:run` | `PASS` | frontend unit/integration suite passed `82` files / `522` tests | +| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the permissions/browser harness updates | +| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the explicit Vite root fix | +| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after the permissions CRUD and CDP stability changes | +| `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win` | `PASS` | targeted browser-level proof is green for `admin-bootstrap` plus `permissions-management-crud` | +| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level Playwright CDP E2E path re-ran green with `20` scenarios in the current workspace | + +### Current Honest Status + +- The full backend matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`) is green in the current workspace. +- The full frontend matrix (`npm.cmd run test:run`, `npm.cmd run lint`, `npm.cmd run build`) is green in the current workspace. +- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green in the current workspace. +- The re-verified browser scenarios now include `20` flows: - `admin-bootstrap` - `public-registration` - `email-activation` + - `password-reset` - `login-surface` - `auth-workflow` - `responsive-login` - `desktop-mobile-navigation` - `user-management-crud` + - `user-management-batch` + - `role-management-crud` + - `permissions-management-crud` + - `device-management` + - `login-logs` + - `operation-logs` + - `webhook-management` + - `import-export` + - `profile-and-security` + - `settings` + - `dashboard-stats` +- The concrete defects fixed in this round were: + - the permissions service adapter moved to the real numeric backend `type` contract, and older aggregate service tests were updated to match the new raw payload shape instead of asserting stale string payloads; + - backend permission creation/status handling now accepts real browser payloads such as menu `type=0` and numeric `status` updates without falsely rejecting valid requests; + - the permissions browser CRUD scenario was red because CDP `page.waitForRequest/Response` could miss successful proxied `/api/v1/permissions` calls even while the browser `fetch` had already returned `201`; the runner now proves those steps through in-page fetch completion plus UI refresh instead of misclassifying them as product failures; + - Ant modal close assertions in the permissions flow were tightened to accept real leave-state transitions instead of requiring a brittle `hidden` state that could lag under headless-shell animation timing; + - frontend aggregate tests now reflect the real permissions adapter contract, avoiding false red tests after a valid service-layer schema change; + - frontend production build on Windows with `vite --configLoader native` was failing because Vite 8 resolved `index.html` as an absolute emitted asset name; setting explicit `root` in `frontend/admin/vite.config.js` restored a green build; + - the browser harness is more tolerant of transient Windows CDP startup/runtime instability after raising the suite retry default to `3` and aligning the CDP attach timeout with the startup timeout window. + +### Boundary + +- This update re-proves the supported browser-level E2E path and the full local backend/frontend verification matrices in the current workspace. +- It does **not** by itself re-prove real third-party OAuth live verification or complete OS-level automation closure. + +## 2026-04-23 Password Reset And E2E Stability Update + +### Latest Verification Snapshot + +| Command | Result | Note | +|------|------|------| +| `go test ./... -count=1` | `PASS` | full backend test matrix re-ran green on the current branch state | +| `go vet ./...` | `PASS` | backend vet is green after the auth capability fix | +| `go build ./cmd/server` | `PASS` | backend build is green after the auth capability fix | +| `cd frontend/admin && npm.cmd run test:run` | `PASS` | frontend unit/integration suite passed `82` files / `521` tests | +| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the password-reset and CDP recovery changes | +| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the password-reset and CDP recovery changes | +| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after recovery changes | +| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level Playwright CDP E2E path re-ran green with `19` scenarios in the current workspace | + +### Current Honest Status + +- The full backend matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`) is green again in the current workspace. +- The full frontend matrix (`npm.cmd run test:run`, `npm.cmd run lint`, `npm.cmd run build`) is green again in the current workspace. +- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green again in the current workspace. +- The re-verified browser scenarios now include `19` flows: + - `admin-bootstrap` + - `public-registration` + - `email-activation` + - `password-reset` + - `login-surface` + - `auth-workflow` + - `responsive-login` + - `desktop-mobile-navigation` + - `user-management-crud` + - `user-management-batch` - `role-management-crud` - `device-management` - `login-logs` - `operation-logs` - `webhook-management` + - `import-export` - `profile-and-security` + - `settings` - `dashboard-stats` - The concrete defects fixed in this round were: - `DevicesPage` cursor state was auto-chaining next-page fetches and could drive `/api/v1/admin/devices` into `429`. - webhook frontend services were decoding `/webhooks` and `/webhooks/:id/deliveries` with the wrong response shape. - social account frontend service was decoding `/users/me/social-accounts` with the wrong response shape. + - settings frontend service was double-unwrapping `/admin/settings` even though the shared HTTP client had already returned `result.data`. + - backend `/api/v1/auth/capabilities` omitted `password_reset`, so the real login surface never exposed the password-reset entry even though the route was mounted. - the Playwright CDP suite had multiple over-broad locators and stale route/title assumptions in the later admin scenarios. + - the outer browser-suite retry path was carrying a stale `admin-bootstrap` expectation across attempts even after the first attempt had already changed backend bootstrap state. + - the Playwright CDP runner did not reconnect the browser connection when a late-stage page/context disappeared, so a single headless-shell target closure could falsely redline the rest of the suite. ### Boundary -- This update re-proves the supported browser-level E2E path only. -- It does **not** by itself re-prove full backend `go test ./... -count=1`, real third-party OAuth live verification, or complete OS-level automation closure. +- This update re-proves the supported browser-level E2E path and the full local backend/frontend verification matrices in the current workspace. +- It does **not** by itself re-prove real third-party OAuth live verification or complete OS-level automation closure. ## 2026-04-10 复核更新(TDD修复后) @@ -276,8 +372,11 @@ | `webhook-management` | Webhook 页面导航、列表显示 | ✅ 已添加 | | `profile-and-security` | 个人资料页、安全设置页(密码修改、TOTP) | ✅ 已添加 | | `dashboard-stats` | 仪表盘统计卡片完整验证 | ✅ 已添加 | +| `user-management-batch` | 用户批量启用、批量禁用、批量删除 | ✅ 已添加 | +| `import-export` | 导入导出页面、模板下载、用户导出 | ✅ 已添加 | +| `settings` | 系统设置页面、真实 `/admin/settings` 加载 | ✅ 已添加 | -### E2E 覆盖场景汇总(共 15 个) +### E2E 覆盖场景汇总(共 18 个) | # | 场景 | 覆盖内容 | |---|------|----------| @@ -296,6 +395,9 @@ | 13 | `webhook-management` | Webhook 管理 | | 14 | `profile-and-security` | 个人资料与安全 | | 15 | `dashboard-stats` | 仪表盘统计 | +| 16 | `user-management-batch` | 用户批量操作 | +| 17 | `import-export` | 导入导出 | +| 18 | `settings` | 系统设置 | ### 防虚假测试规则 diff --git a/docs/superpowers/plans/2026-04-23-permissions-browser-crud.md b/docs/superpowers/plans/2026-04-23-permissions-browser-crud.md new file mode 100644 index 0000000..18ce7de --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-permissions-browser-crud.md @@ -0,0 +1,60 @@ +# Permissions Browser CRUD Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a real browser CRUD scenario for the admin permissions page and keep the supported E2E gate green. + +**Architecture:** Extend the existing Playwright CDP runner with one new scenario and only touch product code if the new scenario exposes a real defect. Keep assertions aligned with the page's current tree/list modal workflow and wait on real API responses for every mutation. + +**Tech Stack:** Playwright CDP runner, React admin frontend, Go backend APIs, Vitest for regressions when product fixes are needed. + +--- + +### Task 1: Add the red browser scenario + +**Files:** +- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` + +- [ ] Add a new scenario entry named `permissions-management-crud` to the supported scenario list. +- [ ] Implement the scenario with real page navigation and mutation-response waits for create, update, status toggle, and delete. +- [ ] Run `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win`. +- [ ] Confirm the first run fails for a real reason before changing product code. + +### Task 2: Fix the exposed product issue if needed + +**Files:** +- Modify only the minimal affected product files revealed by Task 1 +- Test: affected frontend/backend regression tests only if product behavior changes + +- [ ] Add the smallest failing regression test for the exposed product bug. +- [ ] Run that regression test and confirm it fails for the expected reason. +- [ ] Implement the minimal product fix. +- [ ] Re-run the regression test until it passes. + +### Task 3: Verify the new scenario end to end + +**Files:** +- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` +- Modify: docs only if the supported browser conclusion changes + +- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win`. +- [ ] Confirm the targeted scenario passes without weakening assertions. +- [ ] Run `cd frontend/admin && npm.cmd run e2e:full:win`. +- [ ] Confirm the full supported browser gate stays green with the new scenario included. + +### Task 4: Re-run the full matrix and sync docs + +**Files:** +- Modify: `docs/status/REAL_PROJECT_STATUS.md` +- Modify: `docs/team/PRODUCTION_CHECKLIST.md` +- Modify: `docs/team/TECHNICAL_GUIDE.md` +- Modify: `docs/team/PROJECT_EXPERIENCE_SUMMARY.md` +- Modify: `docs/team/QUALITY_STANDARD.md` + +- [ ] Run `go test ./... -count=1`. +- [ ] Run `go vet ./...`. +- [ ] Run `go build ./cmd/server`. +- [ ] Run `cd frontend/admin && npm.cmd run test:run`. +- [ ] Run `cd frontend/admin && npm.cmd run lint`. +- [ ] Run `cd frontend/admin && npm.cmd run build`. +- [ ] Update docs only with the results actually observed on this branch state. diff --git a/docs/superpowers/specs/2026-04-23-permissions-browser-crud-design.md b/docs/superpowers/specs/2026-04-23-permissions-browser-crud-design.md new file mode 100644 index 0000000..c1b0d52 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-permissions-browser-crud-design.md @@ -0,0 +1,54 @@ +# Permissions Browser CRUD Design + +**Date:** 2026-04-23 + +**Goal:** Extend the supported Playwright CDP browser gate so the admin `PermissionsPage` is covered by a real CRUD scenario instead of remaining outside the main browser acceptance path. + +## Scope + +- Add one new supported browser scenario: `permissions-management-crud`. +- Cover real admin login, permissions page load, top-level permission creation, child permission creation, list/tree verification, edit, status toggle, and delete. +- Reuse the existing supported runner `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`. +- Keep selector strategy aligned with current team rules: prefer route, heading, role, label, and scoped containers over broad text scans. + +## Non-Goals + +- No redesign of the permissions page UI. +- No new backend permissions model behavior beyond what the existing page and APIs already expose. +- No expansion into OS-level automation or unsupported browser tooling. + +## Approach + +- Treat the new browser scenario as the primary verification surface. +- If the scenario exposes a product defect, add the smallest regression test needed in the affected frontend or backend area, then fix the product behavior. +- If the scenario exposes only runner fragility, fix the runner instead of weakening assertions. + +## Required Browser Flow + +1. Log in as a real admin through the supported login surface. +2. Open `/permissions` and verify the page heading renders. +3. Create a new top-level permission through the page modal and wait for the real create API response. +4. Create a child permission under that top-level node and wait for the real create API response. +5. Switch to list view and verify the new permissions appear. +6. Edit the top-level permission through the page modal and wait for the real update API response. +7. Toggle the permission status through the page action and wait for the real status API response. +8. Delete the child permission, then the top-level permission, each with real delete API responses. +9. Verify the created records are gone from the visible page state. + +## Verification + +- Targeted red/green loop: + - `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win` +- If product code changes: + - run affected frontend tests first + - then `cd frontend/admin && npm.cmd run test:run` + - then `cd frontend/admin && npm.cmd run lint` + - then `cd frontend/admin && npm.cmd run build` +- Final acceptance: + - `go test ./... -count=1` + - `go vet ./...` + - `go build ./cmd/server` + - `cd frontend/admin && npm.cmd run test:run` + - `cd frontend/admin && npm.cmd run lint` + - `cd frontend/admin && npm.cmd run build` + - `cd frontend/admin && npm.cmd run e2e:full:win` diff --git a/docs/team/PRODUCTION_CHECKLIST.md b/docs/team/PRODUCTION_CHECKLIST.md index dea449c..a4ebb18 100644 --- a/docs/team/PRODUCTION_CHECKLIST.md +++ b/docs/team/PRODUCTION_CHECKLIST.md @@ -125,3 +125,109 @@ npm.cmd run e2e:full:win - [ ] 若包装脚本、临时缓存、工作目录切换或环境注入失败,已按真实失败处理,而不是拿局部命令绿灯代替。 - [ ] `cd frontend/admin && npm.cmd run test:run` 与 `cd frontend/admin && npm.cmd run test:coverage` 运行后,无 `window.alert`、`window.confirm`、`window.prompt`、`window.open` 调用和 jsdom `Not implemented` 噪声。 - [ ] 如本轮改动把 stub、`not implemented` 或 mock 接口切换为 live 实现,已补充负向权限测试、边界条件测试、失败回滚测试。 + +## 2026-04-23 Latest Gate Snapshot + +Use this section as the current release-facing snapshot for the workspace. If older notes elsewhere in this file conflict with this section, use this snapshot first. + +### Re-verified Commands + +- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/DevicesPage/DevicesPage.test.tsx` +- `cd frontend/admin && npm.cmd run test:run -- src/services/webhooks.test.ts` +- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/WebhooksPage/WebhooksPage.test.tsx` +- `cd frontend/admin && npm.cmd run test:run -- src/services/social-accounts.test.ts` +- `cd frontend/admin && npm.cmd run test:run -- src/services/settings.test.ts src/pages/admin/SettingsPage/SettingsPage.test.tsx src/pages/admin/ImportExportPage/ImportExportPage.test.tsx` +- `cd frontend/admin && npm.cmd run lint` +- `cd frontend/admin && npm.cmd run build` +- `cd frontend/admin && npm.cmd run e2e:full:win` + +### Current Honest Release Conclusion + +- The supported browser-level acceptance path `cd frontend/admin && npm.cmd run e2e:full:win` is green again in the current workspace. +- The latest green browser run included `admin-bootstrap`, `public-registration`, `email-activation`, `login-surface`, `auth-workflow`, `responsive-login`, `desktop-mobile-navigation`, `user-management-crud`, `user-management-batch`, `role-management-crud`, `device-management`, `login-logs`, `operation-logs`, `webhook-management`, `import-export`, `profile-and-security`, `settings`, and `dashboard-stats`. +- This evidence is sufficient for the supported browser-level gate, but it does not by itself replace the backend full matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`). +- This snapshot also does not prove OS-level automation, live third-party OAuth validation, or external secrets/KMS delivery evidence. + +## 2026-04-23 Additional Browser Gate Checks + +- [ ] Cursor or list-page changes include a regression proving initial load does not self-trigger `next_cursor` pagination or burst extra requests. +- [ ] Frontend service changes against admin APIs verify exact response-envelope fields in service tests, not only page rendering. +- [ ] Frontend services using the shared HTTP client do not unwrap `data` twice; service tests reflect the real `request()` contract. +- [ ] Playwright selector changes prefer route, heading, role, or labeled-control locators over broad text searches. +- [ ] If suite retry reuses the same backend state, bootstrap or similar one-time preconditions are re-evaluated before rerunning browser scenarios. +- [ ] If a late-suite E2E failure blocks release, the release note records whether the root cause was product behavior, contract drift, selector drift, or browser-runtime instability. + +## 2026-04-23 Password Reset Gate Snapshot + +### Latest Green Evidence + +- `go test ./... -count=1` +- `go vet ./...` +- `go build ./cmd/server` +- `cd frontend/admin && npm.cmd run test:run` +- `cd frontend/admin && npm.cmd run lint` +- `cd frontend/admin && npm.cmd run build` +- `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` +- `cd frontend/admin && npm.cmd run e2e:full:win` + +### Current Honest Release Conclusion + +- The current supported browser-level gate is green with `19` scenarios and now includes `password-reset`. +- The same branch state also re-proved the backend full matrix and the frontend unit/lint/build matrix. +- This still does not prove OS-level automation or live third-party OAuth/secrets delivery. + +### Additional Checklist Items + +- [ ] If a public auth route is conditionally mounted, `/api/v1/auth/capabilities` exposes the same availability bit from the same source of truth. +- [ ] A newly added auth or session browser flow is only accepted after both its targeted run and the full supported browser gate are green. +- [ ] When CDP loses the persistent page late in the suite, fix runner recovery before classifying the gate as inherently flaky. + +## 2026-04-23 Permissions CRUD And Full Matrix Snapshot + +Use this section first if earlier 2026-04-23 notes in this file conflict with it. + +### Latest Green Evidence + +- `go test ./... -count=1` +- `go vet ./...` +- `go build ./cmd/server` +- `cd frontend/admin && npm.cmd run test:run` +- `cd frontend/admin && npm.cmd run lint` +- `cd frontend/admin && npm.cmd run build` +- `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` +- `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win` +- `cd frontend/admin && npm.cmd run e2e:full:win` + +### Current Honest Release Conclusion + +- The current supported browser-level gate is green with `20` scenarios and now includes `permissions-management-crud`. +- The same branch state also re-proved the backend full matrix and the frontend unit, lint, and build matrix. +- This evidence proves the supported browser-level acceptance path in the current workspace. It still does not prove OS-level automation, live third-party OAuth validation, or external secrets or KMS delivery evidence. + +### Additional Checklist Items + +- [ ] If a frontend service normalizes backend enum values for UI consumption, tests cover the raw backend payload shape, the normalized frontend shape, and outbound write serialization. +- [ ] If a browser scenario succeeds in the page but CDP request or response observers miss the proxied call, runner-level proof records the real in-page fetch result before classifying the product as broken. +- [ ] If a modal-driven CRUD flow depends on an overlay leaving animation, the next user action waits for the modal to stop blocking interaction instead of relying on a broad hidden assertion alone. +- [ ] If `npm.cmd run build` depends on Vite native config loading on Windows, the supported config keeps HTML inputs under an explicit project root instead of relying on wrapper scripts to mask absolute-path errors. + +## 2026-04-24 Profile Security Contract Recovery Snapshot + +### Latest Green Evidence + +- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/ProfileSecurityPage/ProfileSecurityPage.behavior.test.tsx src/services/profile.test.ts src/services/service_adapters_additional.test.ts` +- `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` +- `cd frontend/admin && npm.cmd run lint` +- `cd frontend/admin && npm.cmd run build` +- `cd frontend/admin && npm.cmd run e2e:full:win` + +### Current Honest Release Conclusion + +- The supported browser-level gate remains green with `20` scenarios after the real `profile-and-security` password-update contract fix. +- This round re-proved the directly affected frontend regression set, lint, build, and the supported browser gate on the same workspace state. +- This round did not re-run the backend full matrix, so backend-wide claims still rely on the latest earlier verified snapshot. + +### Additional Checklist Items + +- [ ] If a UI form shape differs from the backend write contract, the service adapter must serialize the backend field names explicitly and service tests must pin the exact outbound payload. +- [ ] If a browser runner waits on in-page fetch diagnostics, that wait must be created in the same control flow as the submit action and must not be allowed to outlive a failed click or fill step. diff --git a/docs/team/PROJECT_EXPERIENCE_SUMMARY.md b/docs/team/PROJECT_EXPERIENCE_SUMMARY.md index 1958f82..2a17dac 100644 --- a/docs/team/PROJECT_EXPERIENCE_SUMMARY.md +++ b/docs/team/PROJECT_EXPERIENCE_SUMMARY.md @@ -269,3 +269,56 @@ - 这种漂移会把下一轮修复引向过时优先级。 - 经验: 文档更新不是交付后的清理工作,而是交付本身的一部分。 + +## 0. 2026-04-23 E2E Recovery Lessons + +Use this section as the newest summary of what changed in the workspace after the E2E recovery. If older notes elsewhere in this file conflict with it, trust this section. + +- A green main browser gate was recovered by fixing real product and test mismatches, not by wrapper retries alone. +- The concrete regressions found in this recovery were: + - `DevicesPage` cursor flow could self-trigger a second page request and flood `/admin/devices`. + - `webhooks` and `social-accounts` services decoded the wrong backend response shapes. + - `settings` service unwrapped `data` twice even though the shared HTTP client had already returned `result.data`. + - Broad text-based Playwright assertions in later admin scenarios created brittle false negatives. +- The latest evidence set for this recovery was: + - `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/DevicesPage/DevicesPage.test.tsx` + - `cd frontend/admin && npm.cmd run test:run -- src/services/webhooks.test.ts` + - `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/WebhooksPage/WebhooksPage.test.tsx` + - `cd frontend/admin && npm.cmd run test:run -- src/services/social-accounts.test.ts` + - `cd frontend/admin && npm.cmd run lint` + - `cd frontend/admin && npm.cmd run build` + - `cd frontend/admin && npm.cmd run e2e:full:win` +- Practical rule: when `e2e:full:win` fails late in the suite, inspect both real application behavior and locator or route assumptions before blaming only browser or CDP instability. + +## 2026-04-23 Governance Lessons From E2E Recovery + +- A red browser gate can hide several different failure classes at once: product bug, integration-contract drift, selector drift, and browser-runtime instability. +- This recovery was closed by fixing real contract and locator problems, not by increasing retries around the wrapper. +- Pagination regressions are high-noise defects: they often show up as rate limiting, empty lists, or flaky E2E much earlier than they show up as obvious local exceptions. +- Response-envelope mismatches are easy to miss when pages silently fall back to empty arrays or partial data; service tests must pin the real backend field names. +- Documentation lag recreates stale priorities. Once the supported browser gate changes state, norms and experience docs need the same-day update. +- Browser-suite retry logic can create false failures when the first attempt mutates one-time backend state. Retry code has to re-read live preconditions instead of replaying stale assumptions. + +## 2026-04-23 Password Reset Expansion Lessons + +- Capability endpoints and mounted routes are one product contract. If the route is live but the capability bit is false, the browser surface is still effectively broken. +- A targeted green scenario is not enough evidence when the supported gate is the full suite. The 19th scenario only counted after `cd frontend/admin && npm.cmd run e2e:full:win` stayed green. +- Late-suite CDP page loss is best treated as a recoverable connection problem first, not as a reason to blindly multiply wrapper retries. +- Real auth coverage is worth the setup cost. The password-reset scenario now proves SMTP capture, token validation, password reset submission, and post-reset login in one browser chain. + +## 2026-04-23 Permissions CRUD Closure Lessons + +- A red browser scenario can come from product behavior, adapter drift, auth-header handling, or runner observation gaps. The fastest path was to separate those four possibilities instead of assuming every timeout meant browser flakiness. +- A successful browser fetch does not guarantee that Playwright CDP request or response listeners will observe the call under every proxy path. When the UI updated and the in-page fetch log showed `201` and `200`, the correct conclusion was "runner evidence gap", not "permission create is broken". +- Shared HTTP client state is easy to misread under concurrency. "A refresh is in flight" and "this request lacks a usable token" are different facts; merging them creates false auth regressions. +- Adapter normalization changes must update both focused service tests and aggregate service suites. Fixing only the local adapter test leaves a second failure surface in cross-service regression packs. +- Modal animations are a real source of E2E false negatives. A dialog that is visually closing can still block clicks long enough to break the next CRUD step unless the runner waits for the overlay to stop intercepting input. +- Build tooling can be a real release blocker. Vite root resolution on Windows became part of the supported gate the moment `npm.cmd run build` started failing under the documented command. +- The 20th browser scenario only counted after two proofs existed on the same branch state: the targeted `permissions-management-crud` run and the full `cd frontend/admin && npm.cmd run e2e:full:win` run. + +## 2026-04-24 Profile Security Contract Recovery Lessons + +- Browser E2E is often the first place where outbound write contracts are validated end to end. A service adapter can look fine in page-level tests while still sending the wrong backend field names. +- Service tests must assert the serialized write payload, not only the UI form model. Otherwise the test suite can lock in the wrong contract and make the browser suite the first honest signal. +- Orphaned async diagnostics waste debugging time. A failed click or fill should not leave a background fetch waiter alive long enough to crash during cleanup and hide the real failing step. +- A targeted scenario recovery is still not enough evidence on its own. The `profile-and-security` fix only counted after `cd frontend/admin && npm.cmd run e2e:full:win` returned green on the same workspace state. diff --git a/docs/team/QUALITY_STANDARD.md b/docs/team/QUALITY_STANDARD.md index 9473153..e0a257d 100644 --- a/docs/team/QUALITY_STANDARD.md +++ b/docs/team/QUALITY_STANDARD.md @@ -365,3 +365,37 @@ npm.cmd run e2e:full:win 3. 为每个确认接受的修复补回归测试。 4. 重新执行受影响的完整门禁。 5. 只有在以上完成后,才进入结构清理或一般优化。 + +## 2026-04-23 E2E Recovery Governance Supplement + +Use this section as the current normative supplement when older sections are silent on late-stage browser regressions. + +- Cursor pagination must separate the request cursor from the response `next_cursor`. If a page updates request state directly from the response on initial load, add a regression test proving it does not auto-fetch follow-up pages. +- Frontend service adapters must decode backend envelopes by exact field names and must match the shared HTTP client contract exactly. Any admin API shape change requires a service-level regression test and at least one consuming page regression. +- Admin-surface E2E assertions must prefer route, heading, role, or labeled-control locators. Broad text matching is not sufficient when the same text can appear in menus, cards, tables, and toasts. +- When `cd frontend/admin && npm.cmd run e2e:full:win` fails in the late suite, triage in this order: backend contract mismatch, page-state or pagination bug, selector assumption bug, then CDP or browser-runtime instability. +- Browser-suite retry code must refresh mutable preconditions such as `admin_bootstrap_required` from live backend capabilities before re-running scenarios against the same backend state. +- When the supported browser gate changes from red to green or from green to red, update `docs/status/REAL_PROJECT_STATUS.md`, `docs/team/QUALITY_STANDARD.md`, `docs/team/TECHNICAL_GUIDE.md`, and `docs/team/PROJECT_EXPERIENCE_SUMMARY.md` in the same batch. + +## 2026-04-23 Password Reset And CDP Stability Supplement + +- Capability endpoints must reflect real mounted-route availability. If password reset, email code, SMS code, or similar auth routes are conditionally mounted, the matching capability flags must be derived from the same condition or explicitly synchronized at assembly time. +- A new auth or session browser scenario only counts as accepted when the targeted scenario is green and the full supported browser gate `cd frontend/admin && npm.cmd run e2e:full:win` is green on the same branch state. +- Playwright CDP recovery must attempt connection-level recovery when the persistent page disappears late in the suite. Declaring failure before trying to reconnect is not an acceptable steady-state gate design. + +## 2026-04-23 Permissions CRUD And Full Matrix Governance Supplement + +Use this section as the current supplement when older sections do not cover permissions CRUD closure or runner-observation mismatches. + +- If a frontend adapter normalizes backend enums or status values, the regression set must cover three layers on the same change: raw backend payload acceptance, normalized frontend read shape, and outbound write serialization. +- Shared auth clients must attach the current non-expired access token immediately. An unrelated refresh already in flight is not a valid reason to downgrade a request to missing auth. +- When a supported browser scenario depends on real network proof and CDP request or response observers miss the call, use evidence derived from the real page fetch path before classifying the failure as product behavior. Runner instrumentation must not silently redefine a healthy product as broken. +- Modal, drawer, or overlay transitions that still intercept input after close has started must be treated as first-class E2E timing constraints. Wait for interaction blocking to stop, not only for a broad visibility assertion. +- Backend handlers that accept admin CRUD writes must remain compatible with the payload forms actually sent by the current browser client during rollout, including numeric enum values such as permission `type=0` and mixed numeric or string status updates when those paths are supported. +- Supported build commands are part of the release gate. If Vite or another build tool requires an explicit project root or equivalent configuration for the documented command to pass, fix the project config rather than relying on an ad hoc wrapper or local shell state. + +## 2026-04-24 Profile Security Contract Recovery Supplement + +- If a form includes UI-only fields such as `confirm_password`, outbound service code must strip or remap those fields before hitting the API. UI form names are not a valid substitute for the backend write contract. +- Service regression tests for write paths must assert the exact payload sent into the shared HTTP client, not only the values collected from the component or form layer. +- Browser-runner fetch or response waiters must be action-scoped. A waiter that can outlive a failed action and later crash with a page-closed error is not acceptable verification infrastructure. diff --git a/docs/team/TECHNICAL_GUIDE.md b/docs/team/TECHNICAL_GUIDE.md index 9ff926e..13020ad 100644 --- a/docs/team/TECHNICAL_GUIDE.md +++ b/docs/team/TECHNICAL_GUIDE.md @@ -153,3 +153,101 @@ npm.cmd run e2e:full:win - `docs/status/REAL_PROJECT_STATUS.md` - 规则变化时更新 `docs/team/QUALITY_STANDARD.md` - 产出可复用经验时更新 `docs/team/PROJECT_EXPERIENCE_SUMMARY.md` + +## 0. 2026-04-23 Latest Technical Snapshot + +Use this section as the current workspace truth when older notes elsewhere in this file describe earlier failures. + +### Main Acceptance Path + +- The supported browser-level gate remains `cd frontend/admin && npm.cmd run e2e:full:win`. +- That gate was re-run green on 2026-04-23 after fixes in device pagination flow, backend-response envelope decoding, settings-service adapter alignment, and Playwright CDP selector and suite-retry stability. + +### Recovery Notes That Matter + +- `DevicesPage` must keep the request cursor separate from the response `next_cursor`; otherwise the initial load can auto-chain into extra `/admin/devices` requests and trigger rate limiting. +- Frontend services must decode backend envelopes by their actual fields and by the shared HTTP client contract. The recovered cases in this round were `list`, `deliveries`, `accounts`, and `/admin/settings` direct `data`. +- Late-stage E2E scenarios are more stable when assertions target route, heading, and role-based locators instead of broad page text matches. +- If suite retry reuses the same backend process, one-time preconditions such as `admin-bootstrap` must be refreshed from live backend capabilities before the next attempt starts. + +### Boundary + +- This snapshot proves browser-level real E2E closure in the current workspace. +- It does not by itself prove the full backend matrix, OS-level automation, or live third-party provider verification. + +## 2026-04-23 Late-Suite E2E Triage Order + +Use this order before blaming the browser wrapper when `cd frontend/admin && npm.cmd run e2e:full:win` fails in later admin scenarios. + +1. Check whether the failing page consumes an API whose response envelope or field names changed. +2. Check whether the page state machine, pagination flow, or derived state issued unexpected follow-up requests. +3. Check whether the failing assertion uses a broad text locator where route, heading, role, or labeled-control matching would be more precise. +4. Only after the first three checks stay clean, investigate CDP session lifecycle, page/context closure, or local browser startup instability. + +## 2026-04-23 Password Reset And CDP Recovery Notes + +### Root Cause + +- The password-reset browser gap came from a backend contract omission: `/api/v1/auth/capabilities` returned `password_reset=false` even when `passwordResetHandler` was mounted and the reset routes were live. + +### Minimal Fix + +- `AuthHandler` now carries the password-reset capability bit and fills `caps.PasswordReset` in `GetAuthCapabilities()`. +- Router assembly now synchronizes that bit from the same `passwordResetHandler != nil` condition that mounts the reset routes. + +### Browser Flow Proof + +- The supported browser suite now proves the real password-reset chain end to end: + - admin creates a real user + - login surface exposes the forgot-password entry + - `/api/v1/auth/forgot-password` emits a real SMTP-captured reset link + - `/api/v1/auth/password/validate` and `/api/v1/auth/reset-password` complete through the browser + - the user logs in with the new password + +### Stability Rule + +- When headless-shell closes the last live target late in the suite, reconnect the CDP browser connection and reacquire the persistent page before declaring the whole run failed. + +## 2026-04-23 Permissions CRUD And Full Matrix Technical Snapshot + +Use this section as the newest technical snapshot when earlier 2026-04-23 notes describe only the 19-scenario gate. + +### Main Acceptance Path + +- The supported browser-level gate remains `cd frontend/admin && npm.cmd run e2e:full:win`. +- That gate was re-run green on 2026-04-23 after adding `permissions-management-crud`, fixing permissions payload compatibility, fixing auth-header selection under concurrent refresh state, and stabilizing CDP observation for proxied permission calls. +- The same branch state also re-ran `go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`, `cd frontend/admin && npm.cmd run test:run`, `cd frontend/admin && npm.cmd run lint`, and `cd frontend/admin && npm.cmd run build` successfully. + +### Recovery Notes That Matter + +- The permissions frontend adapter must accept raw numeric backend `type` values, normalize them to the frontend string enum, and serialize writes back to the backend numeric form. +- The permissions backend handler must continue accepting menu `type=0` and status payloads delivered as either numeric or string values, because real browser flows and clients can send both forms during incremental rollout. +- A valid non-expired access token must still be attached to requests even when a different refresh flow is already in flight. Refresh state alone is not evidence that the current request should lose authentication. +- In the permissions CRUD scenario, the page and backend were healthy even when Playwright CDP request and response observers missed the proxied `/api/v1/permissions` call. The reliable proof path was the in-page fetch diagnostic log plus the post-submit UI refresh. +- Ant modal leave animations can keep intercepting clicks after the dialog is visually closing. Scenario code should wait for the modal to stop blocking interaction before the next action. +- Vite 8 on Windows with `--configLoader native` can fail the supported build path if project root resolution is implicit. The stable fix is an explicit `root` in `vite.config.js`. + +### Boundary + +- This snapshot proves browser-level real E2E closure with `20` supported scenarios in the current workspace. +- It does not by itself prove OS-level automation, live third-party provider verification, or remote-repository publication status. + +## 2026-04-24 Profile Security Contract Recovery + +### Root Cause + +- The profile password form used the UI model (`current_password`, `confirm_password`) all the way through the service layer, but the real backend `PUT /users/:id/password` handler binds `old_password` and `new_password` only. + +### Minimal Fix + +- `frontend/admin/src/services/profile.ts` now maps the UI request to the real backend payload shape before calling the shared HTTP client. +- `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` now couples password and TOTP fetch waits to the submit action that triggers them, so a later locator failure does not leave an orphaned background waiter that hides the real error. + +### Browser Flow Proof + +- The targeted profile page and service regression set is green. +- The supported browser-level gate `cd frontend/admin && npm.cmd run e2e:full:win` is green with `20` scenarios, including `profile-and-security`. + +### Stability Rule + +- When a scenario uses asynchronous fetch diagnostics for proof, create the waiter in the same control flow as the triggering action and tear it down implicitly with that action path. A background waiter that survives a failed action is a runner bug because it can replace the primary failure with misleading page-closed noise. diff --git a/frontend/admin/scripts/run-cdp-smoke.ps1 b/frontend/admin/scripts/run-cdp-smoke.ps1 index 947f672..5b46a28 100644 --- a/frontend/admin/scripts/run-cdp-smoke.ps1 +++ b/frontend/admin/scripts/run-cdp-smoke.ps1 @@ -104,18 +104,19 @@ function Get-BrowserArguments { $arguments = @( "--remote-debugging-port=$Port", "--user-data-dir=$ProfileDir", - '--no-sandbox' + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-sync', + '--disable-gpu' ) if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) { $arguments += '--single-process' } else { $arguments += @( - '--disable-dev-shm-usage', - '--disable-background-networking', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--disable-sync', '--headless=new' ) } diff --git a/frontend/admin/scripts/run-playwright-auth-e2e.ps1 b/frontend/admin/scripts/run-playwright-auth-e2e.ps1 index 112cbab..d8f9d09 100644 --- a/frontend/admin/scripts/run-playwright-auth-e2e.ps1 +++ b/frontend/admin/scripts/run-playwright-auth-e2e.ps1 @@ -103,6 +103,28 @@ function Wait-UrlReady { throw "$Label did not become ready: $Url" } +function Sync-AdminBootstrapExpectation { + param( + [Parameter(Mandatory = $true)][string]$BackendBaseUrl + ) + + $capabilitiesUrl = "$BackendBaseUrl/api/v1/auth/capabilities" + $response = Invoke-RestMethod -Uri $capabilitiesUrl -Method Get -TimeoutSec 15 + $requiresBootstrap = $false + + if ($response -and $response.data -and $null -ne $response.data.admin_bootstrap_required) { + $requiresBootstrap = [bool]$response.data.admin_bootstrap_required + } + + if ($requiresBootstrap) { + $env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1' + } else { + Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue + } + + Write-Host "playwright e2e admin bootstrap expected: $requiresBootstrap" +} + function Start-ManagedProcess { param( [Parameter(Mandatory = $true)][string]$Name, @@ -280,7 +302,6 @@ try { $env:E2E_LOGIN_PASSWORD = $AdminPassword $env:E2E_LOGIN_EMAIL = $AdminEmail $env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret - $env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1' $env:E2E_EXTERNAL_WEB_SERVER = '1' $env:E2E_BASE_URL = $frontendBaseUrl $env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1" @@ -289,7 +310,7 @@ try { Push-Location $frontendRoot try { $lastError = $null - $suiteAttempts = 2 + $suiteAttempts = 3 if ($env:E2E_SUITE_ATTEMPTS) { $parsedSuiteAttempts = 0 if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) { @@ -299,6 +320,7 @@ try { for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) { try { + Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') ` -Port $BrowserPort ` -Command @('node', './scripts/run-playwright-cdp-e2e.mjs') diff --git a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs index e04b694..30535a6 100644 --- a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs +++ b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs @@ -2,6 +2,7 @@ import process from 'node:process' import { access, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { constants as fsConstants } from 'node:fs' import { spawn } from 'node:child_process' +import { createHmac } from 'node:crypto' import net from 'node:net' import { tmpdir } from 'node:os' import path from 'node:path' @@ -27,6 +28,12 @@ const TEXT = { bootstrapAdminSubmit: '\u5b8c\u6210\u521d\u59cb\u5316\u5e76\u8fdb\u5165\u7cfb\u7edf', bootstrapAdminUsernamePlaceholder: '\u7ba1\u7406\u5458\u7528\u6237\u540d', changePassword: '\u4fee\u6539\u5bc6\u7801', + confirmDisableTOTP: '\u786e\u8ba4\u7981\u7528', + confirmEnableTOTP: '\u786e\u8ba4\u542f\u7528', + batchDelete: '\u6279\u91cf\u5220\u9664', + batchDisable: '\u6279\u91cf\u7981\u7528', + batchEnable: '\u6279\u91cf\u542f\u7528', + cancelSelection: '\u53d6\u6d88\u9009\u62e9', confirmPasswordPlaceholder: '\u786e\u8ba4\u5bc6\u7801', createAccount: '\u521b\u5efa\u8d26\u53f7', createUser: '\u521b\u5efa\u7528\u6237', @@ -40,13 +47,21 @@ const TEXT = { deleteConfirm: '\u786e\u5b9a\u5220\u9664', deviceManagement: '\u8bbe\u5907\u7ba1\u7406', devices: '\u8bbe\u5907', + disableTOTP: '\u7981\u7528 TOTP', disabled: '\u7981\u7528', + disabledStatus: '\u5df2\u7981\u7528', + downloadTemplate: '\u4e0b\u8f7d\u6a21\u677f', edit: '\u7f16\u8f91', editUser: '\u7f16\u8f91\u7528\u6237', emailCodeLogin: '\u90ae\u7bb1\u9a8c\u8bc1\u7801', + enableTOTP: '\u542f\u7528 TOTP', emailActivationSuccess: '\u90ae\u7bb1\u9a8c\u8bc1\u6210\u529f', export: '\u5bfc\u51fa', + exportUserData: '\u5bfc\u51fa\u7528\u6237\u6570\u636e', + exportUsers: '\u5bfc\u51fa\u7528\u6237', forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f', + importExport: '\u5bfc\u5165\u5bfc\u51fa', + importUsers: '\u5bfc\u5165\u7528\u6237', integration: '\u96c6\u6210\u80fd\u529b', loginAction: '\u767b\u5f55', loginLogs: '\u767b\u5f55\u65e5\u5fd7', @@ -61,22 +76,33 @@ const TEXT = { oldPasswordPlaceholder: '\u8bf7\u8f93\u5165\u5f53\u524d\u5bc6\u7801', operationLogs: '\u64cd\u4f5c\u65e5\u5fd7', passwordPlaceholder: '\u5bc6\u7801', + permissionCode: '\u6743\u9650\u4ee3\u7801', + permissionIcon: '\u56fe\u6807', + permissionName: '\u6743\u9650\u540d\u79f0', + permissionPath: '\u8def\u7531\u8def\u5f84', permissions: '\u6743\u9650\u7ba1\u7406', permissionsAction: '\u6743\u9650', permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650', profile: '\u4e2a\u4eba\u8d44\u6599', + profileConfirmPasswordPlaceholder: '\u8bf7\u518d\u6b21\u8f93\u5165\u65b0\u5bc6\u7801', registerEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740\uff08\u9009\u586b\uff09', registerSuccess: '\u6ce8\u518c\u6210\u529f', roleFilter: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801', roles: '\u89d2\u8272\u7ba1\u7406', save: '\u4fdd\u5b58', security: '\u5b89\u5168\u8bbe\u7f6e', + selectedUsers: '\u5df2\u9009\u62e9', smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801', status: '\u72b6\u6001', + settings: '\u7cfb\u7edf\u8bbe\u7f6e', + systemInfo: '\u7cfb\u7edf\u4fe1\u606f', + listView: '\u5217\u8868', todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55', totalUsers: '\u7528\u6237\u603b\u6570', + treeView: '\u6811\u5f62', trust: '\u4fe1\u4efb', untrust: '\u53d6\u6d88\u4fe1\u4efb', + userCreatedStatus: '\u5df2\u6fc0\u6d3b', userDetail: '\u7528\u6237\u8be6\u60c5', userDetailAction: '\u8be6\u60c5', userId: '\u7528\u6237 ID', @@ -101,7 +127,8 @@ const IGNORED_REQUEST_FAILURES = new Set([ 'net::ERR_FAILED', ]) const DEBUG = process.env.E2E_DEBUG === '1' -const STARTUP_TIMEOUT_MS = Number(process.env.E2E_STARTUP_TIMEOUT_MS ?? 30000) +const STARTUP_TIMEOUT_MS = Number(process.env.E2E_STARTUP_TIMEOUT_MS ?? 60000) +const CDP_CONNECT_TIMEOUT_MS = Number(process.env.E2E_CDP_CONNECT_TIMEOUT_MS ?? STARTUP_TIMEOUT_MS) const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim() const REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token' const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present' @@ -120,6 +147,56 @@ function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +function decodeBase32(value) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + const normalized = String(value ?? '') + .toUpperCase() + .replace(/=+$/g, '') + .replace(/[^A-Z2-7]/g, '') + + if (!normalized) { + throw new Error('TOTP secret is missing or invalid.') + } + + let bits = 0 + let bitCount = 0 + const bytes = [] + + for (const character of normalized) { + const index = alphabet.indexOf(character) + if (index === -1) { + throw new Error(`Invalid base32 character: ${character}`) + } + + bits = (bits << 5) | index + bitCount += 5 + while (bitCount >= 8) { + bitCount -= 8 + bytes.push((bits >>> bitCount) & 0xff) + } + } + + return Buffer.from(bytes) +} + +function generateTotpCode(secret, timestampMs = Date.now()) { + const key = decodeBase32(secret) + const counter = BigInt(Math.floor(timestampMs / 1000 / 30)) + const payload = Buffer.alloc(8) + payload.writeBigUInt64BE(counter) + + const digest = createHmac('sha1', key).update(payload).digest() + const offset = digest[digest.length - 1] & 0x0f + const binary = ( + ((digest[offset] & 0x7f) << 24) + | ((digest[offset + 1] & 0xff) << 16) + | ((digest[offset + 2] & 0xff) << 8) + | (digest[offset + 3] & 0xff) + ) + + return String(binary % 1_000_000).padStart(6, '0') +} + function requireEnv(name) { const value = (process.env[name] ?? '').trim() if (!value) { @@ -176,6 +253,12 @@ function extractActivationLink(message) { return match?.[0] ?? null } +function extractPasswordResetLink(message) { + const body = String(message?.data ?? '') + const match = body.match(/https?:\/\/[^\s"<]+\/reset-password\?token=[^"\s<]+/) + return match?.[0] ?? null +} + async function waitForActivationLink(email, timeoutMs = 20_000) { const startedAt = Date.now() @@ -196,6 +279,26 @@ async function waitForActivationLink(email, timeoutMs = 20_000) { throw new Error(`Timed out waiting for activation email for ${email}.`) } +async function waitForPasswordResetLink(email, timeoutMs = 20_000) { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + const messages = await readCapturedMessages() + const matchedMessages = messages.filter((message) => capturedMessageMatchesRecipient(message, email)) + + for (let index = matchedMessages.length - 1; index >= 0; index -= 1) { + const resetLink = extractPasswordResetLink(matchedMessages[index]) + if (resetLink) { + return resetLink + } + } + + await delay(250) + } + + throw new Error(`Timed out waiting for password reset email for ${email}.`) +} + function resolveCdpUrl() { if (managedCdpUrl) { return managedCdpUrl @@ -216,10 +319,12 @@ function resolveCdpUrl() { function createSignals() { return { + browserLifecycle: [], rateLimitedResponses: [], consoleErrors: [], dialogs: [], pageErrors: [], + pageLifecycle: [], popups: [], requestFailures: [], unauthorizedResponses: [], @@ -332,19 +437,22 @@ async function createManagedBrowserProfileDir(browserPath, port) { } function startManagedBrowser(browserPath, port, profileDir) { - const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox'] + const args = [ + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-sync', + '--disable-gpu', + ] if (isHeadlessShellBrowser(browserPath)) { args.push('--single-process') } else { - args.push( - '--disable-dev-shm-usage', - '--disable-background-networking', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--disable-sync', - '--headless=new', - ) + args.push('--headless=new') } args.push('about:blank') @@ -455,15 +563,30 @@ function isIgnoredConsoleError(text) { return IGNORED_CONSOLE_ERRORS.some((value) => text.includes(value)) } +function describePageState(page) { + try { + const url = page.url() + return page.isClosed() ? `closed url=${url}` : `open url=${url}` + } catch (error) { + return `unavailable (${formatError(error)})` + } +} + function formatSignals(signals) { const lines = [] + if (signals.browserLifecycle.length > 0) { + lines.push(`browser lifecycle:\n${signals.browserLifecycle.join('\n')}`) + } if (signals.windowGuardEvents.length > 0) { lines.push(`window-guard events:\n${signals.windowGuardEvents.join('\n')}`) } if (signals.dialogs.length > 0) { lines.push(`native dialogs:\n${signals.dialogs.join('\n')}`) } + if (signals.pageLifecycle.length > 0) { + lines.push(`page lifecycle:\n${signals.pageLifecycle.join('\n')}`) + } if (signals.popups.length > 0) { lines.push(`popup pages:\n${signals.popups.join('\n')}`) } @@ -494,6 +617,7 @@ function assertCleanSignals(signals) { } function attachSignalCollectors(page, signals) { + const browser = page.context().browser() const onConsole = (message) => { if (message.type() !== 'error') { return @@ -524,6 +648,14 @@ function attachSignalCollectors(page, signals) { void popup.close().catch(() => {}) } + const onPageClose = () => { + signals.pageLifecycle.push(`close :: ${describePageState(page)}`) + } + + const onPageCrash = () => { + signals.pageLifecycle.push(`crash :: ${describePageState(page)}`) + } + const onRequestFailed = (request) => { const failureText = request.failure()?.errorText ?? 'unknown failure' if (!IGNORED_REQUEST_FAILURES.has(failureText)) { @@ -552,20 +684,30 @@ function attachSignalCollectors(page, signals) { } } + const onBrowserDisconnected = () => { + signals.browserLifecycle.push(`disconnected :: ${describePageState(page)}`) + } + page.on('console', onConsole) page.on('dialog', onDialog) page.on('pageerror', onPageError) + page.on('close', onPageClose) + page.on('crash', onPageCrash) page.on('popup', onPopup) page.on('requestfailed', onRequestFailed) page.on('response', onResponse) + browser?.on('disconnected', onBrowserDisconnected) return () => { page.off('console', onConsole) page.off('dialog', onDialog) page.off('pageerror', onPageError) + page.off('close', onPageClose) + page.off('crash', onPageCrash) page.off('popup', onPopup) page.off('requestfailed', onRequestFailed) page.off('response', onResponse) + browser?.off('disconnected', onBrowserDisconnected) } } @@ -604,7 +746,9 @@ async function connectBrowserWithRetry() { for (let attempt = 1; attempt <= 3; attempt += 1) { try { - return await chromium.connectOverCDP(resolveCdpUrl()) + return await chromium.connectOverCDP(resolveCdpUrl(), { + timeout: CDP_CONNECT_TIMEOUT_MS, + }) } catch (error) { lastError = error if (attempt >= 3) { @@ -617,6 +761,11 @@ async function connectBrowserWithRetry() { throw lastError ?? new Error('Failed to connect to the Chromium CDP endpoint.') } +async function reconnectBrowserConnection(browser) { + await browser?.close().catch(() => {}) + return await connectBrowserWithRetry() +} + function findOpenPage(browser, preferredContext) { const contexts = [] if (preferredContext) { @@ -638,6 +787,26 @@ function findOpenPage(browser, preferredContext) { return null } +async function recoverPersistentPage(state) { + const preferredContext = state.browser?.contexts?.()[0] ?? state.context ?? null + let result = await ensurePersistentPage(state.browser, preferredContext) + if (result) { + state.context = result.context + return result + } + + state.browser = await reconnectBrowserConnection(state.browser) + state.context = state.browser.contexts()[0] ?? state.context ?? null + + result = await ensurePersistentPage(state.browser, state.context) + if (result) { + state.context = result.context + return result + } + + return null +} + async function ensurePersistentPage(browser, context) { let result = findOpenPage(browser, context) if (result) { @@ -842,6 +1011,110 @@ async function readCookie(page, cookieName) { return matched?.value ?? null } +async function installFetchDiagnostics(page) { + await page.evaluate(() => { + const globalWindow = window + if (!Array.isArray(globalWindow.__umsE2eFetchLog)) { + globalWindow.__umsE2eFetchLog = [] + } else { + globalWindow.__umsE2eFetchLog.length = 0 + } + + if (globalWindow.__umsE2eFetchWrapped) { + return + } + + const originalFetch = window.fetch.bind(window) + globalWindow.__umsE2eFetchWrapped = true + window.fetch = async (...args) => { + const [resource, init] = args + const isRequestObject = typeof Request !== 'undefined' && resource instanceof Request + const entry = { + url: isRequestObject ? resource.url : String(resource), + method: init?.method ?? (isRequestObject ? resource.method : 'GET'), + startedAt: Date.now(), + } + + globalWindow.__umsE2eFetchLog.push(entry) + + try { + const response = await originalFetch(...args) + entry.status = response.status + entry.ok = response.ok + entry.finishedAt = Date.now() + return response + } catch (error) { + entry.error = error instanceof Error ? error.message : String(error) + entry.finishedAt = Date.now() + throw error + } + } + }) +} + +async function readFetchDiagnostics(page) { + return await page.evaluate(() => { + const globalWindow = window + return Array.isArray(globalWindow.__umsE2eFetchLog) ? globalWindow.__umsE2eFetchLog : [] + }) +} + +async function getFetchDiagnosticsCount(page) { + const fetchLog = await readFetchDiagnostics(page) + return fetchLog.length +} + +function fetchLogPathMatches(entry, pattern) { + const pathname = new URL(entry.url).pathname + return pattern.test(pathname) +} + +async function waitForFetchLogEntry(page, predicate, options = {}) { + const { + afterCount = 0, + timeout = 30 * 1000, + label = 'fetch request', + } = options + + const deadline = Date.now() + timeout + let fetchLog = [] + while (Date.now() < deadline) { + fetchLog = await readFetchDiagnostics(page) + const entry = fetchLog + .slice(afterCount) + .find((candidate) => candidate.finishedAt !== undefined && predicate(candidate)) + + if (entry) { + return entry + } + + await delay(100) + } + + throw new Error(`${label} did not complete within ${timeout}ms. fetchLog=${JSON.stringify(fetchLog.slice(afterCount))}`) +} + +async function performActionAndWaitForFetchLogEntry(page, predicate, action, options = {}) { + const pendingFetch = waitForFetchLogEntry(page, predicate, options) + + try { + await action() + } catch (error) { + void pendingFetch.catch(() => {}) + throw error + } + + return await pendingFetch +} + +function assertFetchLogSuccess(entry, label) { + if (entry.ok) { + return + } + + throw new Error(`${label} request failed: ${entry.status ?? 'unknown'} ${entry.url}`) +} + async function assertApiSuccessResponse(response, label) { const responseBody = await response.text().catch(() => '') if (!response.ok()) { @@ -865,6 +1138,15 @@ async function assertApiSuccessResponse(response, label) { return payload } +async function assertHttpOkResponse(response, label) { + if (response.ok()) { + return + } + + const responseBody = await response.text().catch(() => '') + throw new Error(`${label} request failed: ${response.status()} ${responseBody}`) +} + function waitForResponseSafe(page, predicate, options) { return page.waitForResponse(predicate, options).then( (response) => ({ response }), @@ -942,6 +1224,204 @@ async function loginFromLoginPage(page) { return { username, password } } +async function createUserFromUsersPage(page, username, password = 'Batch123!@#') { + const email = `${username}@example.com` + const createUserModal = page.locator('.ant-modal').last() + + logDebug(`createUserFromUsersPage: open modal for ${username}`) + await forceClick(page.getByRole('button', { name: TEXT.createUser }).first()) + await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 }) + logDebug(`createUserFromUsersPage: modal visible for ${username}`) + + const createUserResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/users') && response.request().method() === 'POST' + }) + + logDebug(`createUserFromUsersPage: fill username for ${username}`) + await forceFillInput( + createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(), + username, + ) + logDebug(`createUserFromUsersPage: fill password for ${username}`) + await forceFillInput( + createUserModal.locator(`input[placeholder="${TEXT.createUserPasswordPlaceholder}"]`).first(), + password, + ) + logDebug(`createUserFromUsersPage: fill email for ${username}`) + await forceFillInput( + createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(), + email, + ) + logDebug(`createUserFromUsersPage: submit modal for ${username}`) + await forceClick(createUserModal.locator('.ant-btn-primary').last()) + + const createUserResponse = await resolveWaitForResponse(createUserResponsePromise) + await assertApiSuccessResponse(createUserResponse, `create user ${username}`) + logDebug(`createUserFromUsersPage: response ok for ${username}`) + await expect(page.locator('tbody tr').filter({ hasText: username }).first()).toBeVisible({ timeout: 20 * 1000 }) + logDebug(`createUserFromUsersPage: row visible for ${username}`) + + return { email, password, username } +} + +async function logoutFromCurrentSession(page) { + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) +} + +async function resetSessionToLogin(page) { + const context = page.context() + await context.clearCookies() + await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' }) + await page.evaluate(() => { + localStorage.clear() + sessionStorage.clear() + }) + await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' }) + await expect(page).toHaveURL(/\/login$/) +} + +function extractTotpSecret(modalText) { + const match = String(modalText ?? '').match(/\u5bc6\u94a5\uff1a([A-Z2-7]+)/) + if (!match) { + throw new Error(`Failed to extract TOTP secret from modal text: ${modalText}`) + } + return match[1] +} + +async function getRecoveryCodesFromTotpModal(modal) { + const codes = (await modal.locator('.ant-tag').allInnerTexts()) + .map((value) => value.trim()) + .filter(Boolean) + + if (codes.length === 0) { + throw new Error('No TOTP recovery codes were rendered in the setup modal.') + } + + return codes +} + +function permissionPathMatches(response, pattern) { + const pathname = new URL(response.url()).pathname + return pattern.test(pathname) +} + +async function fillPermissionModal(modal, values) { + if (values.name) { + const nameInput = modal.locator('input[id="name"], input[placeholder="\u5982\uff1a\u7528\u6237\u7ba1\u7406"]').first() + await forceFillInput(nameInput, values.name) + await expect(nameInput).toHaveValue(values.name) + } + if (values.code) { + const codeInput = modal.locator('input[id="code"], input[placeholder="\u5982\uff1auser:manage"]').first() + await forceFillInput(codeInput, values.code) + await expect(codeInput).toHaveValue(values.code) + } + if (values.path) { + const pathInput = modal.locator('input[id="path"], input[placeholder="\u5982\uff1a/admin/users"]').first() + await forceFillInput(pathInput, values.path) + await expect(pathInput).toHaveValue(values.path) + } + if (values.icon) { + const iconInput = modal.locator('input[id="icon"], input[placeholder="\u5982\uff1aUserOutlined"]').first() + await forceFillInput(iconInput, values.icon) + await expect(iconInput).toHaveValue(values.icon) + } +} + +async function confirmVisiblePopconfirm(page) { + const popconfirm = page.locator('.ant-popconfirm').last() + await expect(popconfirm).toBeVisible({ timeout: 10 * 1000 }) + await forceClick(popconfirm.locator('.ant-btn-primary').last()) +} + +async function submitPermissionModalAndWaitForFetch(page, modal, waitForFetch, label) { + const submitButton = modal.locator('.ant-btn-primary').last() + await expect(submitButton).toBeEnabled() + await forceClick(submitButton) + + const validationErrors = modal.locator('.ant-form-item-explain-error') + const validationSignal = await validationErrors.first().waitFor({ state: 'visible', timeout: 2_000 }).then( + async () => ({ errors: await validationErrors.allInnerTexts() }), + () => null, + ) + if (validationSignal) { + throw new Error(`${label} validation failed: ${validationSignal.errors.join(' | ')}`) + } + + try { + return await waitForFetch() + } catch (error) { + const modalText = await modal.innerText().catch(() => '') + const inputs = await modal.locator('input').evaluateAll((nodes) => { + return nodes.map((node) => ({ + id: node.getAttribute('id'), + name: node.getAttribute('name'), + placeholder: node.getAttribute('placeholder'), + type: node.getAttribute('type'), + value: node.value, + })) + }).catch(() => []) + const submitButtonState = await submitButton.evaluate((node) => ({ + className: node.className, + disabled: node instanceof HTMLButtonElement ? node.disabled : null, + text: node.textContent?.trim() ?? '', + })).catch(() => null) + const fetchLog = await readFetchDiagnostics(page).catch(() => []) + throw new Error( + `${label} request did not complete: ${formatError(error)} diagnostics=${JSON.stringify({ + inputs, + modalText: modalText.slice(0, 1000), + submitButtonState, + fetchLog, + })}`, + ) + } +} + +async function waitForModalToStopBlocking(modal, label) { + const deadline = Date.now() + 20 * 1000 + let lastState = null + + while (Date.now() < deadline) { + lastState = await modal.evaluate((node) => { + const style = window.getComputedStyle(node) + return { + className: node.className, + display: style.display, + pointerEvents: style.pointerEvents, + visibility: style.visibility, + } + }).catch(() => ({ + detached: true, + })) + + if ( + lastState.detached + || lastState.display === 'none' + || lastState.visibility === 'hidden' + || lastState.pointerEvents === 'none' + || String(lastState.className).includes('ant-zoom-leave') + ) { + return + } + + await delay(100) + } + + throw new Error(`${label} modal did not stop blocking within 20000ms. state=${JSON.stringify(lastState)}`) +} + +async function selectUserRow(page, username) { + const row = page.locator('tbody tr').filter({ hasText: username }).first() + await expect(row).toBeVisible({ timeout: 20 * 1000 }) + + const checkbox = row.locator('.ant-checkbox').first() + await forceClick(checkbox) + await expect(checkbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 }) +} + async function verifyAdminBootstrapWorkflow(page) { const username = requireEnv('E2E_LOGIN_USERNAME') const password = requireEnv('E2E_LOGIN_PASSWORD') @@ -1074,18 +1554,78 @@ async function verifyEmailActivationWorkflow(page) { await expect(page).toHaveURL(/\/login$/) } -async function runScenario(browser, context, name, fn) { +async function verifyPasswordResetWorkflow(page) { + logDebug('verifyPasswordResetWorkflow: create user through admin flow') + + const username = `e2e_reset_${Date.now()}` + const oldPassword = 'ResetOld123!@#' + const newPassword = 'ResetNew456!@#' + + await loginFromLoginPage(page) + await page.goto(appUrl('/users')) + await expect(page).toHaveURL(/\/users$/) + + const { email } = await createUserFromUsersPage(page, username, oldPassword) + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) + + await forceClick(page.getByRole('link', { name: TEXT.forgotPassword })) + await expect(page).toHaveURL(/\/forgot-password$/) + + const forgotPasswordForm = page.locator('form').first() + const forgotPasswordEmailInput = forgotPasswordForm.locator('input[autocomplete="email"]').first() + await forceFillInput(forgotPasswordEmailInput, email) + + const forgotPasswordResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/auth/forgot-password') && response.request().method() === 'POST' + }) + await forceClick(forgotPasswordForm.locator('button[type="submit"]').first()) + const forgotPasswordResponse = await resolveWaitForResponse(forgotPasswordResponsePromise) + await assertApiSuccessResponse(forgotPasswordResponse, 'request password reset') + await expect(page.locator('body')).toContainText(email, { timeout: 20 * 1000 }) + + const resetLink = await waitForPasswordResetLink(email) + const validateResetTokenResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/auth/password/validate') && response.request().method() === 'POST' + }) + await page.goto(resetLink) + const validateResetTokenResponse = await resolveWaitForResponse(validateResetTokenResponsePromise) + const validateResetTokenPayload = await assertApiSuccessResponse( + validateResetTokenResponse, + 'validate password reset token', + ) + expect(validateResetTokenPayload?.data?.valid).toBe(true) + + const resetPasswordInputs = page.locator('input[type="password"]') + await expect(resetPasswordInputs).toHaveCount(2, { timeout: 20 * 1000 }) + await forceFillInput(resetPasswordInputs.nth(0), newPassword) + await forceFillInput(resetPasswordInputs.nth(1), newPassword) + + const resetPasswordResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/auth/reset-password') && response.request().method() === 'POST' + }) + await forceClick(page.locator('form').first().locator('button[type="submit"]').first()) + const resetPasswordResponse = await resolveWaitForResponse(resetPasswordResponsePromise) + await assertApiSuccessResponse(resetPasswordResponse, 'reset password') + + await page.goto(appUrl('/login')) + await loginWithPassword(page, username, newPassword, /\/(dashboard|profile)$/) +} + +async function runScenario(state, name, fn) { console.log(`START ${name}`) let lastError = null for (let attempt = 1; attempt <= 2; attempt += 1) { - const requestedContext = browser.contexts()[0] ?? context - const resolvedPage = await ensurePersistentPage(browser, requestedContext) + const resolvedPage = await recoverPersistentPage(state) if (!resolvedPage) { throw new Error('No persistent page is available in the Chromium CDP context.') } const activeContext = resolvedPage.context const page = resolvedPage.page + state.context = activeContext for (const extraPage of activeContext.pages()) { if (extraPage === page) { @@ -1437,6 +1977,237 @@ async function verifyRoleManagementCRUD(page) { await expect(page).toHaveURL(/\/login$/) } +async function verifyPermissionsManagementCRUD(page) { + logDebug('verifyPermissionsManagementCRUD: login /login') + await loginFromLoginPage(page) + + await page.goto(appUrl('/permissions')) + await expect(page).toHaveURL(/\/permissions$/) + await expect(page.getByRole('heading', { name: TEXT.permissions })).toBeVisible({ timeout: 10 * 1000 }) + await expect(page.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 20 * 1000 }) + await expect(page.getByRole('button', { name: TEXT.createPermission }).first()).toBeVisible() + await installFetchDiagnostics(page) + + const suffix = Date.now() + const parentName = `E2E Permission ${suffix}` + const parentCode = `e2e_permission_${suffix}` + const parentPath = `/e2e/permissions/${suffix}` + const childName = `E2E Permission Child ${suffix}` + const childCode = `${parentCode}:child` + const childPath = `${parentPath}/child` + const editedParentName = `${parentName} Updated` + const editedParentPath = `${parentPath}-updated` + + const createParentFetchCount = await getFetchDiagnosticsCount(page) + await forceClick(page.getByRole('button', { name: TEXT.createPermission }).first()) + const createParentModal = page.getByRole('dialog').last() + await expect(createParentModal).toBeVisible({ timeout: 10 * 1000 }) + await fillPermissionModal(createParentModal, { + name: parentName, + code: parentCode, + path: parentPath, + icon: 'SafetyOutlined', + }) + const createParentFetch = await submitPermissionModalAndWaitForFetch( + page, + createParentModal, + () => waitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/permissions$/) && entry.method === 'POST' + }, { + afterCount: createParentFetchCount, + label: 'create parent permission fetch', + }), + 'create parent permission', + ) + assertFetchLogSuccess(createParentFetch, 'create parent permission') + await waitForModalToStopBlocking(createParentModal, 'create parent permission') + + await forceClick(page.locator('.ant-segmented-item').filter({ hasText: TEXT.listView }).first()) + let parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first() + await expect(parentRow).toBeVisible({ timeout: 20 * 1000 }) + await expect(parentRow).toContainText(parentName) + await expect(parentRow).toContainText(parentPath) + + const createChildFetchCount = await getFetchDiagnosticsCount(page) + await forceClick(parentRow.locator('.ant-btn-link').first()) + const createChildModal = page.getByRole('dialog').last() + await expect(createChildModal).toBeVisible({ timeout: 10 * 1000 }) + await fillPermissionModal(createChildModal, { + name: childName, + code: childCode, + path: childPath, + icon: 'ApartmentOutlined', + }) + const createChildFetch = await submitPermissionModalAndWaitForFetch( + page, + createChildModal, + () => waitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/permissions$/) && entry.method === 'POST' + }, { + afterCount: createChildFetchCount, + label: 'create child permission fetch', + }), + 'create child permission', + ) + assertFetchLogSuccess(createChildFetch, 'create child permission') + await waitForModalToStopBlocking(createChildModal, 'create child permission') + + let childRow = page.locator('tbody tr').filter({ hasText: childCode }).first() + await expect(childRow).toBeVisible({ timeout: 20 * 1000 }) + await expect(childRow).toContainText(childName) + + parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first() + const updateParentFetchCount = await getFetchDiagnosticsCount(page) + await forceClick(parentRow.getByRole('button', { name: TEXT.edit })) + const editParentModal = page.getByRole('dialog').last() + await expect(editParentModal).toBeVisible({ timeout: 10 * 1000 }) + await fillPermissionModal(editParentModal, { + name: editedParentName, + path: editedParentPath, + icon: 'LockOutlined', + }) + const updateParentFetch = await submitPermissionModalAndWaitForFetch( + page, + editParentModal, + () => waitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+$/) && entry.method === 'PUT' + }, { + afterCount: updateParentFetchCount, + label: 'update parent permission fetch', + }), + 'update parent permission', + ) + assertFetchLogSuccess(updateParentFetch, 'update parent permission') + await waitForModalToStopBlocking(editParentModal, 'update parent permission') + + parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first() + await expect(parentRow).toContainText(editedParentName, { timeout: 20 * 1000 }) + await expect(parentRow).toContainText(editedParentPath) + + const disablePermissionFetchCount = await getFetchDiagnosticsCount(page) + await forceClick(parentRow.getByRole('button', { name: TEXT.disabled }).first()) + await confirmVisiblePopconfirm(page) + const disablePermissionFetch = await waitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+\/status$/) && entry.method === 'PUT' + }, { + afterCount: disablePermissionFetchCount, + label: 'disable parent permission fetch', + }) + assertFetchLogSuccess(disablePermissionFetch, 'disable parent permission') + + parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first() + await expect(parentRow.getByRole('button', { name: TEXT.active }).first()).toBeVisible({ timeout: 20 * 1000 }) + + const enablePermissionFetchCount = await getFetchDiagnosticsCount(page) + await forceClick(parentRow.getByRole('button', { name: TEXT.active }).first()) + await confirmVisiblePopconfirm(page) + const enablePermissionFetch = await waitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+\/status$/) && entry.method === 'PUT' + }, { + afterCount: enablePermissionFetchCount, + label: 'enable parent permission fetch', + }) + assertFetchLogSuccess(enablePermissionFetch, 'enable parent permission') + + parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first() + await expect(parentRow.getByRole('button', { name: TEXT.disabled }).first()).toBeVisible({ timeout: 20 * 1000 }) + + childRow = page.locator('tbody tr').filter({ hasText: childCode }).first() + const deleteChildFetchCount = await getFetchDiagnosticsCount(page) + await forceClick(childRow.getByRole('button', { name: TEXT.delete })) + await confirmVisiblePopconfirm(page) + const deleteChildFetch = await waitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+$/) && entry.method === 'DELETE' + }, { + afterCount: deleteChildFetchCount, + label: 'delete child permission fetch', + }) + assertFetchLogSuccess(deleteChildFetch, 'delete child permission') + await expect(page.locator('tbody tr').filter({ hasText: childCode }).first()).toHaveCount(0, { timeout: 20 * 1000 }) + + parentRow = page.locator('tbody tr').filter({ hasText: parentCode }).first() + const deleteParentFetchCount = await getFetchDiagnosticsCount(page) + await forceClick(parentRow.getByRole('button', { name: TEXT.delete })) + await confirmVisiblePopconfirm(page) + const deleteParentFetch = await waitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/permissions\/\d+$/) && entry.method === 'DELETE' + }, { + afterCount: deleteParentFetchCount, + label: 'delete parent permission fetch', + }) + assertFetchLogSuccess(deleteParentFetch, 'delete parent permission') + await expect(page.locator('tbody tr').filter({ hasText: parentCode }).first()).toHaveCount(0, { timeout: 20 * 1000 }) + + await forceClick(page.locator('.ant-segmented-item').filter({ hasText: TEXT.treeView }).first()) + await expect(page.locator('.ant-tree')).toBeVisible({ timeout: 10 * 1000 }) + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) +} + +async function verifyUserManagementBatch(page) { + logDebug('verifyUserManagementBatch: login /login') + await loginFromLoginPage(page) + + await page.goto(appUrl('/users')) + await expect(page).toHaveURL(/\/users$/) + + const batchUserA = `e2e_batch_a_${Date.now()}` + const batchUserB = `e2e_batch_b_${Date.now()}` + + await createUserFromUsersPage(page, batchUserA) + await createUserFromUsersPage(page, batchUserB) + + await selectUserRow(page, batchUserA) + await selectUserRow(page, batchUserB) + await expect(page.locator('body')).toContainText(TEXT.selectedUsers, { timeout: 10 * 1000 }) + + const batchDisableResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/users/batch/status') && response.request().method() === 'PUT' + }) + await forceClick(page.getByRole('button', { name: TEXT.batchDisable })) + const batchDisableResponse = await resolveWaitForResponse(batchDisableResponsePromise) + await assertApiSuccessResponse(batchDisableResponse, 'batch disable users') + + await expect(page.locator('tbody tr').filter({ hasText: batchUserA }).first()).toContainText(TEXT.disabledStatus, { timeout: 20 * 1000 }) + await expect(page.locator('tbody tr').filter({ hasText: batchUserB }).first()).toContainText(TEXT.disabledStatus, { timeout: 20 * 1000 }) + + await selectUserRow(page, batchUserA) + await selectUserRow(page, batchUserB) + const batchEnableResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/users/batch/status') && response.request().method() === 'PUT' + }) + await forceClick(page.getByRole('button', { name: TEXT.batchEnable })) + const batchEnableResponse = await resolveWaitForResponse(batchEnableResponsePromise) + await assertApiSuccessResponse(batchEnableResponse, 'batch enable users') + + await expect(page.locator('tbody tr').filter({ hasText: batchUserA }).first()).toContainText(TEXT.userCreatedStatus, { timeout: 20 * 1000 }) + await expect(page.locator('tbody tr').filter({ hasText: batchUserB }).first()).toContainText(TEXT.userCreatedStatus, { timeout: 20 * 1000 }) + + await selectUserRow(page, batchUserA) + await selectUserRow(page, batchUserB) + await forceClick(page.getByRole('button', { name: TEXT.batchDelete })) + + const batchDeletePopover = page.locator('.ant-popconfirm').last() + await expect(batchDeletePopover).toBeVisible({ timeout: 10 * 1000 }) + const batchDeleteResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/users/batch') && response.request().method() === 'DELETE' + }) + await forceClick(batchDeletePopover.locator('.ant-btn-primary').last()) + const batchDeleteResponse = await resolveWaitForResponse(batchDeleteResponsePromise) + await assertApiSuccessResponse(batchDeleteResponse, 'batch delete users') + + await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), batchUserA) + await expect(page.locator('tbody tr').filter({ hasText: batchUserA }).first()).toHaveCount(0, { timeout: 10 * 1000 }) + await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), batchUserB) + await expect(page.locator('tbody tr').filter({ hasText: batchUserB }).first()).toHaveCount(0, { timeout: 10 * 1000 }) + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) +} + async function verifyDeviceManagement(page) { logDebug('verifyDeviceManagement: login /login') await loginFromLoginPage(page) @@ -1493,27 +2264,160 @@ async function verifyWebhookManagement(page) { await expect(page).toHaveURL(/\/login$/) } -async function verifyProfileAndSecurity(page) { - logDebug('verifyProfileAndSecurity: login /login') - const credentials = await loginFromLoginPage(page) +async function verifyImportExport(page) { + logDebug('verifyImportExport: login /login') + await loginFromLoginPage(page) - await forceClick(page.locator('[class*="userTrigger"]')) - await forceClick(page.getByText(TEXT.profile, { exact: true })) - await expect(page).toHaveURL(/\/profile$/) + await page.goto(appUrl('/import-export')) + await expect(page).toHaveURL(/\/import-export$/) + await expect(page.getByRole('heading', { name: TEXT.importExport })).toBeVisible({ timeout: 10 * 1000 }) - await expect(page.locator('body')).toContainText(credentials.username, { timeout: 10 * 1000 }) + const templateResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/admin/users/import/template') && response.request().method() === 'GET' + }) + await forceClick(page.getByRole('button', { name: TEXT.downloadTemplate })) + const templateResponse = await resolveWaitForResponse(templateResponsePromise) + await assertHttpOkResponse(templateResponse, 'download import template') - await forceClick(page.locator('[class*="userTrigger"]')) - await forceClick(page.getByRole('menuitem', { name: TEXT.security })) - await expect(page).toHaveURL(/\/profile\/security$/) + await forceClick(page.getByRole('tab', { name: TEXT.exportUsers })) + await expect(page.getByRole('button', { name: TEXT.exportUserData })).toBeVisible({ timeout: 10 * 1000 }) - await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 }) + const exportResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/admin/users/export') && response.request().method() === 'GET' + }) + await forceClick(page.getByRole('button', { name: TEXT.exportUserData })) + const exportResponse = await resolveWaitForResponse(exportResponsePromise) + await assertHttpOkResponse(exportResponse, 'export users') await forceClick(page.locator('[class*="userTrigger"]')) await forceClick(page.getByText(TEXT.logout, { exact: true })) await expect(page).toHaveURL(/\/login$/) } +async function verifySettings(page) { + logDebug('verifySettings: login /login') + await loginFromLoginPage(page) + + const settingsResponsePromise = waitForResponseSafe(page, (response) => { + return response.url().includes('/api/v1/admin/settings') && response.request().method() === 'GET' + }) + await page.goto(appUrl('/settings')) + await expect(page).toHaveURL(/\/settings$/) + + const settingsResponse = await resolveWaitForResponse(settingsResponsePromise) + const settingsPayload = await assertApiSuccessResponse(settingsResponse, 'load settings') + + await expect(page.getByRole('heading', { name: TEXT.settings })).toBeVisible({ timeout: 10 * 1000 }) + await expect(page.locator('body')).toContainText(TEXT.security) + await expect(page.locator('body')).toContainText(TEXT.systemInfo) + await expect(page.locator('body')).toContainText(settingsPayload.data.system.name) + + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByText(TEXT.logout, { exact: true })) + await expect(page).toHaveURL(/\/login$/) +} + +async function verifyProfileAndSecurity(page) { + logDebug('verifyProfileAndSecurity: admin login /login') + await loginFromLoginPage(page) + + const securityUsername = `e2e_security_${Date.now()}` + const securityPassword = 'Security123!@#' + const updatedSecurityPassword = 'Security456!@#' + const securityLandingPattern = /\/(dashboard|profile)$/ + + logDebug('verifyProfileAndSecurity: goto /users as admin') + await page.goto(appUrl('/users')) + await expect(page).toHaveURL(/\/users$/) + const createdUser = await createUserFromUsersPage(page, securityUsername, securityPassword) + logDebug(`verifyProfileAndSecurity: created user ${createdUser.username}`) + + logDebug('verifyProfileAndSecurity: reset session before security user login') + await resetSessionToLogin(page) + + logDebug(`verifyProfileAndSecurity: security user login ${createdUser.username}`) + await loginWithPassword(page, createdUser.username, securityPassword, securityLandingPattern) + await installFetchDiagnostics(page) + + logDebug('verifyProfileAndSecurity: ensure /profile') + if (!/\/profile$/.test(page.url())) { + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByRole('menuitem', { name: TEXT.profile })) + } + await expect(page).toHaveURL(/\/profile$/) + await expect(page.locator('body')).toContainText(createdUser.username, { timeout: 10 * 1000 }) + + logDebug('verifyProfileAndSecurity: open /profile/security before password change') + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByRole('menuitem', { name: TEXT.security })) + await expect(page).toHaveURL(/\/profile\/security$/) + await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 }) + + await forceFillInput(page.getByPlaceholder(TEXT.oldPasswordPlaceholder).first(), securityPassword) + await forceFillInput(page.getByPlaceholder(TEXT.newPasswordPlaceholder).first(), updatedSecurityPassword) + await forceFillInput(page.getByPlaceholder(TEXT.profileConfirmPasswordPlaceholder).first(), updatedSecurityPassword) + const passwordFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/users\/\d+\/password$/) && entry.method === 'PUT' + }, async () => { + await forceClick(page.getByRole('button', { name: TEXT.changePassword }).first()) + }) + assertFetchLogSuccess(passwordFetch, 'update profile password') + logDebug('verifyProfileAndSecurity: password updated') + + logDebug('verifyProfileAndSecurity: reset session before relogin with updated password') + await resetSessionToLogin(page) + + logDebug(`verifyProfileAndSecurity: relogin with updated password ${createdUser.username}`) + await loginWithPassword(page, createdUser.username, updatedSecurityPassword, securityLandingPattern) + await installFetchDiagnostics(page) + + logDebug('verifyProfileAndSecurity: open /profile/security before TOTP setup') + await forceClick(page.locator('[class*="userTrigger"]')) + await forceClick(page.getByRole('menuitem', { name: TEXT.security })) + await expect(page).toHaveURL(/\/profile\/security$/) + + await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 }) + await forceClick(page.getByRole('button', { name: TEXT.enableTOTP }).first()) + + const setupModal = page.locator('.ant-modal').last() + await expect(setupModal).toBeVisible({ timeout: 10 * 1000 }) + await expect(setupModal.locator('img[alt="TOTP QR Code"]')).toBeVisible({ timeout: 10 * 1000 }) + + const setupModalText = await setupModal.innerText() + const totpSecret = extractTotpSecret(setupModalText) + const recoveryCodes = await getRecoveryCodesFromTotpModal(setupModal) + logDebug(`verifyProfileAndSecurity: extracted TOTP secret and ${recoveryCodes.length} recovery codes`) + await forceFillInput(setupModal.locator('input[maxLength="6"]').first(), generateTotpCode(totpSecret)) + logDebug('verifyProfileAndSecurity: submit TOTP enable') + const enableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/enable$/) && entry.method === 'POST' + }, async () => { + await forceClick(setupModal.getByRole('button', { name: TEXT.confirmEnableTOTP }).last()) + }) + assertFetchLogSuccess(enableTotpFetch, 'enable TOTP') + await waitForModalToStopBlocking(setupModal, 'enable TOTP') + await expect(page.getByRole('button', { name: TEXT.disableTOTP })).toBeVisible({ timeout: 10 * 1000 }) + logDebug('verifyProfileAndSecurity: TOTP enabled') + + await forceClick(page.getByRole('button', { name: TEXT.disableTOTP }).first()) + const disableModal = page.locator('.ant-modal').last() + await expect(disableModal).toBeVisible({ timeout: 10 * 1000 }) + logDebug('verifyProfileAndSecurity: submit TOTP disable') + await forceFillInput(disableModal.locator('input').first(), recoveryCodes[0]) + const disableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => { + return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/disable$/) && entry.method === 'POST' + }, async () => { + await forceClick(disableModal.getByRole('button', { name: TEXT.confirmDisableTOTP }).last()) + }) + assertFetchLogSuccess(disableTotpFetch, 'disable TOTP') + await waitForModalToStopBlocking(disableModal, 'disable TOTP') + await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 }) + logDebug('verifyProfileAndSecurity: TOTP disabled') + + await resetSessionToLogin(page) + logDebug('verifyProfileAndSecurity: completed') +} + async function verifyDashboardStats(page) { logDebug('verifyDashboardStats: login /login') await loginFromLoginPage(page) @@ -1529,6 +2433,7 @@ async function verifyDashboardStats(page) { async function main() { let browser = null + let runtime = null let managedBrowser = null let managedProfileDir = null const selectedScenarioNames = new Set( @@ -1553,8 +2458,11 @@ async function main() { try { console.log('CONNECTED playwright-cdp') - const context = browser.contexts()[0] - if (!context) { + runtime = { + browser, + context: browser.contexts()[0] ?? null, + } + if (!runtime.context) { throw new Error('No persistent Chromium context is available through CDP.') } @@ -1565,17 +2473,22 @@ async function main() { scenarios.push( ['public-registration', verifyPublicRegistration], ['email-activation', verifyEmailActivationWorkflow], + ['password-reset', verifyPasswordResetWorkflow], ['login-surface', verifyLoginSurface], ['auth-workflow', verifyAuthWorkflow], ['responsive-login', verifyResponsiveLogin], ['desktop-mobile-navigation', verifyDesktopAndMobileNavigation], ['user-management-crud', verifyUserManagementCRUD], + ['user-management-batch', verifyUserManagementBatch], ['role-management-crud', verifyRoleManagementCRUD], + ['permissions-management-crud', verifyPermissionsManagementCRUD], ['device-management', verifyDeviceManagement], ['login-logs', verifyLoginLogs], ['operation-logs', verifyOperationLogs], ['webhook-management', verifyWebhookManagement], + ['import-export', verifyImportExport], ['profile-and-security', verifyProfileAndSecurity], + ['settings', verifySettings], ['dashboard-stats', verifyDashboardStats], ) @@ -1589,11 +2502,11 @@ async function main() { console.log(`SCENARIOS ${scenariosToRun.map(([name]) => name).join(', ')}`) for (const [name, fn] of scenariosToRun) { - await runScenario(browser, context, name, fn) + await runScenario(runtime, name, fn) } console.log('Playwright CDP E2E completed successfully') } finally { - await browser?.close().catch(() => {}) + await (runtime?.browser ?? browser)?.close().catch(() => {}) await killManagedBrowser(managedBrowser) if (managedProfileDir) { await rm(managedProfileDir, { recursive: true, force: true }).catch(() => {}) diff --git a/frontend/admin/src/lib/http/client.test.ts b/frontend/admin/src/lib/http/client.test.ts index 2f91602..8f1416e 100644 --- a/frontend/admin/src/lib/http/client.test.ts +++ b/frontend/admin/src/lib/http/client.test.ts @@ -269,6 +269,31 @@ describe('http client', () => { }) }) + it('uses the current non-expired access token even when another refresh is still in flight', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce( + jsonResponse({ + code: 0, + message: 'ok', + data: { ok: true }, + }), + ) + + const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules() + setAccessToken('still-valid-access-token', 3600) + startRefreshing() + setRefreshPromise(new Promise(() => {})) + + const requestPromise = get('/protected') + await Promise.resolve() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: 'Bearer still-valid-access-token', + }) + await expect(requestPromise).resolves.toEqual({ ok: true }) + }) + it('clears the local session when refresh fails before the business request is sent', async () => { const fetchMock = vi.mocked(fetch) fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 })) diff --git a/frontend/admin/src/lib/http/client.ts b/frontend/admin/src/lib/http/client.ts index 96e4bd0..09b2813 100644 --- a/frontend/admin/src/lib/http/client.ts +++ b/frontend/admin/src/lib/http/client.ts @@ -188,6 +188,10 @@ async function resolveAuthorizationHeader(auth: boolean): Promise } let token = getAccessToken() + if (token && !isAccessTokenExpired()) { + return token + } + if (isRefreshing()) { const promise = getRefreshPromise() if (promise) { diff --git a/frontend/admin/src/services/permissions.test.ts b/frontend/admin/src/services/permissions.test.ts index fb59e12..1e06808 100644 --- a/frontend/admin/src/services/permissions.test.ts +++ b/frontend/admin/src/services/permissions.test.ts @@ -22,7 +22,7 @@ describe('permissions service', () => { it('gets permission tree', async () => { const mockTree = [ - { id: 1, name: 'dashboard', children: [{ id: 2, name: 'view' }] }, + { id: 1, name: 'dashboard', type: 0, children: [{ id: 2, name: 'view', type: 2 }] }, ] getMock.mockResolvedValue(mockTree) @@ -30,13 +30,15 @@ describe('permissions service', () => { const result = await getPermissionTree() expect(getMock).toHaveBeenCalledWith('/permissions/tree') - expect(result).toEqual(mockTree) + expect(result).toEqual([ + { id: 1, name: 'dashboard', type: 'menu', children: [{ id: 2, name: 'view', type: 'api' }] }, + ]) }) it('lists all permissions', async () => { const mockPermissions = [ - { id: 1, name: 'view dashboard', code: 'dashboard:view' }, - { id: 2, name: 'edit dashboard', code: 'dashboard:edit' }, + { id: 1, name: 'view dashboard', code: 'dashboard:view', type: 0 }, + { id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 1 }, ] getMock.mockResolvedValue(mockPermissions) @@ -44,40 +46,46 @@ describe('permissions service', () => { const result = await listPermissions() expect(getMock).toHaveBeenCalledWith('/permissions') - expect(result).toEqual(mockPermissions) + expect(result).toEqual([ + { id: 1, name: 'view dashboard', code: 'dashboard:view', type: 'menu' }, + { id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 'button' }, + ]) }) it('gets a single permission', async () => { - getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view' }) + getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view', type: 2 }) const { getPermission } = await import('./permissions') const result = await getPermission(5) expect(getMock).toHaveBeenCalledWith('/permissions/5') - expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view' }) + expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view', type: 'api' }) }) it('creates a permission', async () => { const newPermission = { name: 'new permission', code: 'new:code', type: 'button' as const } - const created = { id: 10, ...newPermission } + const created = { id: 10, ...newPermission, type: 1 } postMock.mockResolvedValue(created) const { createPermission } = await import('./permissions') const result = await createPermission(newPermission) - expect(postMock).toHaveBeenCalledWith('/permissions', newPermission) - expect(result).toEqual(created) + expect(postMock).toHaveBeenCalledWith('/permissions', { + ...newPermission, + type: 1, + }) + expect(result).toEqual({ id: 10, name: 'new permission', code: 'new:code', type: 'button' }) }) it('updates a permission', async () => { const updateData = { name: 'updated name' } - putMock.mockResolvedValue({ id: 3, ...updateData }) + putMock.mockResolvedValue({ id: 3, ...updateData, type: 0 }) const { updatePermission } = await import('./permissions') const result = await updatePermission(3, updateData) expect(putMock).toHaveBeenCalledWith('/permissions/3', updateData) - expect(result).toEqual({ id: 3, name: 'updated name' }) + expect(result).toEqual({ id: 3, name: 'updated name', type: 'menu' }) }) it('deletes a permission', async () => { diff --git a/frontend/admin/src/services/permissions.ts b/frontend/admin/src/services/permissions.ts index 56f25fa..2f09e59 100644 --- a/frontend/admin/src/services/permissions.ts +++ b/frontend/admin/src/services/permissions.ts @@ -5,14 +5,58 @@ */ import { get, post, put, del } from '@/lib/http/client' -import type { Permission, CreatePermissionRequest, UpdatePermissionRequest } from '@/types/permission' +import type { + Permission, + CreatePermissionRequest, + UpdatePermissionRequest, + PermissionType, +} from '@/types/permission' + +type RawPermissionType = 0 | 1 | 2 + +interface RawPermission extends Omit { + type: RawPermissionType + children?: RawPermission[] +} + +function normalizePermissionType(type: RawPermissionType): PermissionType { + switch (type) { + case 0: + return 'menu' + case 1: + return 'button' + case 2: + return 'api' + default: + return 'api' + } +} + +function serializePermissionType(type: PermissionType): RawPermissionType { + switch (type) { + case 'menu': + return 0 + case 'button': + return 1 + case 'api': + return 2 + } +} + +function normalizePermission(permission: RawPermission): Permission { + return { + ...permission, + type: normalizePermissionType(permission.type), + children: permission.children?.map(normalizePermission), + } +} /** * 获取权限树 * GET /api/v1/permissions/tree */ export function getPermissionTree(): Promise { - return get('/permissions/tree') + return get('/permissions/tree').then((permissions) => permissions.map(normalizePermission)) } /** @@ -20,7 +64,7 @@ export function getPermissionTree(): Promise { * GET /api/v1/permissions */ export function listPermissions(): Promise { - return get('/permissions') + return get('/permissions').then((permissions) => permissions.map(normalizePermission)) } /** @@ -28,7 +72,7 @@ export function listPermissions(): Promise { * GET /api/v1/permissions/:id */ export function getPermission(id: number): Promise { - return get(`/permissions/${id}`) + return get(`/permissions/${id}`).then(normalizePermission) } /** @@ -36,7 +80,10 @@ export function getPermission(id: number): Promise { * POST /api/v1/permissions */ export function createPermission(data: CreatePermissionRequest): Promise { - return post('/permissions', data) + return post('/permissions', { + ...data, + type: serializePermissionType(data.type), + }).then(normalizePermission) } /** @@ -44,7 +91,7 @@ export function createPermission(data: CreatePermissionRequest): Promise { - return put(`/permissions/${id}`, data) + return put(`/permissions/${id}`, data).then(normalizePermission) } /** diff --git a/frontend/admin/src/services/profile.test.ts b/frontend/admin/src/services/profile.test.ts index 23d5bd0..f64901d 100644 --- a/frontend/admin/src/services/profile.test.ts +++ b/frontend/admin/src/services/profile.test.ts @@ -76,9 +76,8 @@ describe('profile service', () => { }) expect(putMock).toHaveBeenCalledWith('/users/1/password', { - current_password: 'OldPass123', + old_password: 'OldPass123', new_password: 'NewPass123', - confirm_password: 'NewPass123', }) }) diff --git a/frontend/admin/src/services/profile.ts b/frontend/admin/src/services/profile.ts index 3d4eef4..fdb6be7 100644 --- a/frontend/admin/src/services/profile.ts +++ b/frontend/admin/src/services/profile.ts @@ -50,7 +50,10 @@ export function uploadAvatar(userId: number, file: File): Promise { - return put(`/users/${userId}/password`, data) + return put(`/users/${userId}/password`, { + old_password: data.current_password, + new_password: data.new_password, + }) } export function getTOTPStatus(): Promise { diff --git a/frontend/admin/src/services/service_adapters_additional.test.ts b/frontend/admin/src/services/service_adapters_additional.test.ts index 46d5595..5508ff0 100644 --- a/frontend/admin/src/services/service_adapters_additional.test.ts +++ b/frontend/admin/src/services/service_adapters_additional.test.ts @@ -74,10 +74,22 @@ describe('additional service adapters', () => { .mockResolvedValueOnce([{ id: 9 }, { id: 11 }]) .mockResolvedValueOnce({ items: [], total: 0, page: 1, page_size: 20 }) .mockResolvedValueOnce({ id: 3 }) - .mockResolvedValueOnce([{ id: 1, name: 'menu:view' }]) - .mockResolvedValueOnce([{ id: 2, name: 'menu:edit' }]) + .mockResolvedValueOnce([{ id: 1, name: 'menu:view', type: 0 }]) + .mockResolvedValueOnce([{ id: 2, name: 'menu:edit', type: 1 }]) .mockResolvedValueOnce({ total_users: 10 }) .mockResolvedValueOnce({ active_users: 8 }) + postMock.mockImplementation(async (url: string, payload: Record) => { + if (url === '/permissions') { + return { id: 6, ...payload } + } + return { id: 5, ...payload } + }) + putMock.mockImplementation(async (url: string, payload: Record) => { + if (url === '/permissions/6') { + return { id: 6, ...payload, type: 0 } + } + return undefined + }) const { listRoles, @@ -156,7 +168,7 @@ describe('additional service adapters', () => { expect(postMock).toHaveBeenCalledWith('/permissions', { name: 'view dashboard', code: 'dashboard:view', - type: 'menu', + type: 0, }) await updatePermission(6, { name: 'updated permission' }) @@ -243,9 +255,8 @@ describe('additional service adapters', () => { confirm_password: 'NewPass123', }) expect(putMock).toHaveBeenCalledWith('/users/1/password', { - current_password: 'CurrentPass123', + old_password: 'CurrentPass123', new_password: 'NewPass123', - confirm_password: 'NewPass123', }) await expect(getTOTPStatus()).resolves.toEqual({ totp_enabled: true }) diff --git a/frontend/admin/src/services/service_tests.test.ts b/frontend/admin/src/services/service_tests.test.ts index d7fd36d..6592edd 100644 --- a/frontend/admin/src/services/service_tests.test.ts +++ b/frontend/admin/src/services/service_tests.test.ts @@ -80,7 +80,26 @@ describe('permissions service', () => { it('gets permission tree', async () => { const mockPermissions = [ - { id: 1, name: 'Users', code: 'users', children: [{ id: 2, name: 'View', code: 'users:view' }] }, + { + id: 1, + name: 'Users', + code: 'users', + type: 0, + children: [ + { id: 2, name: 'View', code: 'users:view', type: 2 }, + ], + }, + ] + const expectedPermissions = [ + { + id: 1, + name: 'Users', + code: 'users', + type: 'menu', + children: [ + { id: 2, name: 'View', code: 'users:view', type: 'api', children: undefined }, + ], + }, ] getMock.mockResolvedValue(mockPermissions) @@ -88,7 +107,7 @@ describe('permissions service', () => { const result = await getPermissionTree() expect(getMock).toHaveBeenCalledWith('/permissions/tree') - expect(result).toEqual(mockPermissions) + expect(result).toEqual(expectedPermissions) expect(result[0].children?.[0]?.name).toBe('View') }) @@ -119,14 +138,15 @@ describe('permissions service', () => { it('creates a permission', async () => { const newPermission = { name: 'Test', code: 'test', type: 'button' as const } - const createdPermission = { id: 10, ...newPermission } + const createdPermission = { id: 10, ...newPermission, type: 1 } postMock.mockResolvedValue(createdPermission) const { createPermission } = await import('./permissions') const result = await createPermission(newPermission) - expect(postMock).toHaveBeenCalledWith('/permissions', newPermission) + expect(postMock).toHaveBeenCalledWith('/permissions', { ...newPermission, type: 1 }) expect(result.id).toBe(10) + expect(result.type).toBe('button') }) it('updates a permission', async () => { diff --git a/frontend/admin/src/services/settings.test.ts b/frontend/admin/src/services/settings.test.ts index e9d2c29..c8fd290 100644 --- a/frontend/admin/src/services/settings.test.ts +++ b/frontend/admin/src/services/settings.test.ts @@ -13,37 +13,35 @@ describe('settings service', () => { it('gets system settings', async () => { const mockSettings = { - data: { - system: { - name: 'UserSystem', - version: '1.0.0', - environment: 'production', - description: 'User management system', - }, - security: { - password_min_length: 8, - password_require_uppercase: true, - password_require_lowercase: true, - password_require_numbers: true, - password_require_symbols: true, - password_history: 5, - totp_enabled: true, - login_fail_lock: true, - login_fail_threshold: 5, - login_fail_duration: 30, - session_timeout: 3600, - device_trust_duration: 2592000, - }, - features: { - email_verification: true, - phone_verification: false, - oauth_providers: ['google', 'github'], - sso_enabled: false, - operation_log_enabled: true, - login_log_enabled: true, - data_export_enabled: true, - data_import_enabled: true, - }, + system: { + name: 'UserSystem', + version: '1.0.0', + environment: 'production', + description: 'User management system', + }, + security: { + password_min_length: 8, + password_require_uppercase: true, + password_require_lowercase: true, + password_require_numbers: true, + password_require_symbols: true, + password_history: 5, + totp_enabled: true, + login_fail_lock: true, + login_fail_threshold: 5, + login_fail_duration: 30, + session_timeout: 3600, + device_trust_duration: 2592000, + }, + features: { + email_verification: true, + phone_verification: false, + oauth_providers: ['google', 'github'], + sso_enabled: false, + operation_log_enabled: true, + login_log_enabled: true, + data_export_enabled: true, + data_import_enabled: true, }, } @@ -53,6 +51,6 @@ describe('settings service', () => { const result = await getSettings() expect(getMock).toHaveBeenCalledWith('/admin/settings') - expect(result).toEqual(mockSettings.data) + expect(result).toEqual(mockSettings) }) }) diff --git a/frontend/admin/src/services/settings.ts b/frontend/admin/src/services/settings.ts index 6ba1db0..6a33f65 100644 --- a/frontend/admin/src/services/settings.ts +++ b/frontend/admin/src/services/settings.ts @@ -45,14 +45,10 @@ export interface SystemSettings { features: FeaturesInfo } -interface SettingsResponse { - data: SystemSettings -} - /** * 获取系统设置 * GET /api/v1/admin/settings */ export function getSettings(): Promise { - return get('/admin/settings').then(res => res.data) + return get('/admin/settings') } diff --git a/frontend/admin/vite.config.js b/frontend/admin/vite.config.js index aa840cd..0bf8802 100644 --- a/frontend/admin/vite.config.js +++ b/frontend/admin/vite.config.js @@ -8,11 +8,7 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:80 export default defineConfig({ plugins: [react()], - build: { - rollupOptions: { - input: 'index.html', - }, - }, + root: __dirname, resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/internal/api/handler/auth_handler.go b/internal/api/handler/auth_handler.go index b875828..7047ffb 100644 --- a/internal/api/handler/auth_handler.go +++ b/internal/api/handler/auth_handler.go @@ -33,7 +33,8 @@ type ActivateEmailRequest struct { // AuthHandler handles authentication requests type AuthHandler struct { - authService *service.AuthService + authService *service.AuthService + passwordResetEnabled bool } // NewAuthHandler creates a new AuthHandler @@ -41,6 +42,13 @@ func NewAuthHandler(authService *service.AuthService) *AuthHandler { return &AuthHandler{authService: authService} } +func (h *AuthHandler) SetPasswordResetEnabled(enabled bool) { + if h == nil { + return + } + h.passwordResetEnabled = enabled +} + // Register 用户注册 // @Summary 用户注册 // @Description 用户注册新账号,支持用户名+密码或手机号注册 @@ -327,6 +335,7 @@ func (h *AuthHandler) GetCSRFToken(c *gin.Context) { func (h *AuthHandler) GetAuthCapabilities(c *gin.Context) { ctx := c.Request.Context() caps := h.authService.GetAuthCapabilities(ctx) + caps.PasswordReset = h.SupportsPasswordReset() c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "success", @@ -744,6 +753,10 @@ func requestUsesHTTPS(c *gin.Context) bool { return strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https") } +func (h *AuthHandler) SupportsPasswordReset() bool { + return h != nil && h.passwordResetEnabled +} + // handleError 将 error 转换为对应的 HTTP 响应。 // 优先识别 ApplicationError,其次通过关键词推断业务错误类型,兜底返回 500。 func handleError(c *gin.Context, err error) { diff --git a/internal/api/handler/handler_test.go b/internal/api/handler/handler_test.go index 8f8dac6..90cc5f8 100644 --- a/internal/api/handler/handler_test.go +++ b/internal/api/handler/handler_test.go @@ -549,6 +549,14 @@ func TestAuthHandler_GetAuthCapabilities(t *testing.T) { if result["code"] != float64(0) { t.Errorf("expected code 0, got %v", result["code"]) } + + data, ok := result["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected capabilities data, got %s", body) + } + if data["password_reset"] != true { + t.Fatalf("expected password_reset=true, got %v in %s", data["password_reset"], body) + } } func TestAuthHandler_Login_WithTOTPEnabled_ReturnsChallengeToken(t *testing.T) { @@ -1005,6 +1013,119 @@ func TestRoleHandler_GetRole_RequiresAdmin(t *testing.T) { } } +// ============================================================================= +// Permission Handler Tests +// ============================================================================= + +func TestPermissionHandler_CreatePermission_AcceptsMenuTypeZero(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret") + token := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "permcreate", "permcreate@test.com", "AdminPass123!") + if token == "" { + t.Fatal("expected bootstrap admin token") + } + + createResp, createBody := doPost(server.URL+"/api/v1/permissions", token, map[string]interface{}{ + "name": "Permission Create Menu Test", + "code": "permission:create:menu:test", + "type": 0, + "path": "/permissions/create-menu-test", + "sort": 0, + }) + defer createResp.Body.Close() + + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected create status %d, got %d, body: %s", http.StatusCreated, createResp.StatusCode, createBody) + } + + var createResult map[string]interface{} + if err := json.Unmarshal([]byte(createBody), &createResult); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + + data, ok := createResult["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected permission data in create response, got %s", createBody) + } + + if data["type"] != float64(0) { + t.Fatalf("expected menu permission type 0, got %v in %s", data["type"], createBody) + } +} + +func TestPermissionHandler_UpdatePermissionStatus_AcceptsNumericStatusPayload(t *testing.T) { + server, cleanup := setupHandlerTestServer(t) + defer cleanup() + + t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret") + token := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!") + if token == "" { + t.Fatal("expected bootstrap admin token") + } + + createResp, createBody := doPost(server.URL+"/api/v1/permissions", token, map[string]interface{}{ + "name": "Permission Status Test", + "code": "permission:status:test", + "type": 2, + "path": "/permissions/status-test", + "sort": 0, + }) + defer createResp.Body.Close() + + if createResp.StatusCode != http.StatusCreated { + t.Fatalf("expected create status %d, got %d, body: %s", http.StatusCreated, createResp.StatusCode, createBody) + } + + var createResult map[string]interface{} + if err := json.Unmarshal([]byte(createBody), &createResult); err != nil { + t.Fatalf("failed to parse create response: %v", err) + } + + data, ok := createResult["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected permission data in create response, got %s", createBody) + } + + permissionID, ok := data["id"].(float64) + if !ok { + t.Fatalf("expected numeric permission id in create response, got %s", createBody) + } + + updateResp, updateBody := doPut( + fmt.Sprintf("%s/api/v1/permissions/%d/status", server.URL, int(permissionID)), + token, + map[string]interface{}{"status": 0}, + ) + defer updateResp.Body.Close() + + if updateResp.StatusCode != http.StatusOK { + t.Fatalf("expected update status %d, got %d, body: %s", http.StatusOK, updateResp.StatusCode, updateBody) + } + + getResp, getBody := doGet(fmt.Sprintf("%s/api/v1/permissions/%d", server.URL, int(permissionID)), token) + defer getResp.Body.Close() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("expected get status %d, got %d, body: %s", http.StatusOK, getResp.StatusCode, getBody) + } + + var getResult map[string]interface{} + if err := json.Unmarshal([]byte(getBody), &getResult); err != nil { + t.Fatalf("failed to parse get response: %v", err) + } + + getData, ok := getResult["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected permission data in get response, got %s", getBody) + } + + if getData["status"] != float64(0) { + t.Fatalf("expected permission status 0 after update, got %v in %s", getData["status"], getBody) + } +} + // ============================================================================= // Theme Handler Tests // ============================================================================= diff --git a/internal/api/handler/permission_handler.go b/internal/api/handler/permission_handler.go index 751c19f..d35c7b8 100644 --- a/internal/api/handler/permission_handler.go +++ b/internal/api/handler/permission_handler.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/json" "net/http" "strconv" @@ -33,13 +34,40 @@ func NewPermissionHandler(permissionService *service.PermissionService) *Permiss // @Failure 403 {object} Response "无权限" // @Router /api/v1/permissions [post] func (h *PermissionHandler) CreatePermission(c *gin.Context) { - var req service.CreatePermissionRequest + var req struct { + Name string `json:"name" binding:"required"` + Code string `json:"code" binding:"required"` + Type *int `json:"type" binding:"required"` + Description string `json:"description"` + ParentID *int64 `json:"parent_id"` + Path string `json:"path"` + Method string `json:"method"` + Sort int `json:"sort"` + Icon string `json:"icon"` + } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } - perm, err := h.permissionService.CreatePermission(c.Request.Context(), &req) + if req.Type == nil || *req.Type < 0 || *req.Type > 2 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid permission type"}) + return + } + + serviceReq := service.CreatePermissionRequest{ + Name: req.Name, + Code: req.Code, + Type: *req.Type, + Description: req.Description, + ParentID: req.ParentID, + Path: req.Path, + Method: req.Method, + Sort: req.Sort, + Icon: req.Icon, + } + + perm, err := h.permissionService.CreatePermission(c.Request.Context(), &serviceReq) if err != nil { handleError(c, err) return @@ -201,7 +229,7 @@ func (h *PermissionHandler) UpdatePermissionStatus(c *gin.Context) { } var req struct { - Status string `json:"status" binding:"required"` + Status json.RawMessage `json:"status" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -209,13 +237,8 @@ func (h *PermissionHandler) UpdatePermissionStatus(c *gin.Context) { return } - var status domain.PermissionStatus - switch req.Status { - case "enabled", "1": - status = domain.PermissionStatusEnabled - case "disabled", "0": - status = domain.PermissionStatusDisabled - default: + status, ok := parsePermissionStatus(req.Status) + if !ok { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid status"}) return } @@ -239,6 +262,30 @@ func (h *PermissionHandler) UpdatePermissionStatus(c *gin.Context) { // @Security BearerAuth // @Success 200 {object} Response{data=[]domain.Permission} "权限树" // @Router /api/v1/permissions/tree [get] +func parsePermissionStatus(raw json.RawMessage) (domain.PermissionStatus, bool) { + var statusText string + if err := json.Unmarshal(raw, &statusText); err == nil { + switch statusText { + case "enabled", "1": + return domain.PermissionStatusEnabled, true + case "disabled", "0": + return domain.PermissionStatusDisabled, true + } + } + + var statusNumber int + if err := json.Unmarshal(raw, &statusNumber); err == nil { + switch statusNumber { + case 1: + return domain.PermissionStatusEnabled, true + case 0: + return domain.PermissionStatusDisabled, true + } + } + + return domain.PermissionStatusDisabled, false +} + func (h *PermissionHandler) GetPermissionTree(c *gin.Context) { tree, err := h.permissionService.GetPermissionTree(c.Request.Context()) if err != nil { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 83ffa9a..2d8a198 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -136,6 +136,10 @@ func (r *Router) Setup() *gin.Engine { { authGroup := v1.Group("/auth") { + if r.authHandler != nil { + r.authHandler.SetPasswordResetEnabled(r.passwordResetHandler != nil) + } + authGroup.POST("/register", r.rateLimitMiddleware.Register(), r.authHandler.Register) authGroup.POST("/bootstrap-admin", r.rateLimitMiddleware.Register(), r.authHandler.BootstrapAdmin) authGroup.POST("/login", r.rateLimitMiddleware.Login(), r.authHandler.Login)