Compare commits
2 Commits
main
...
9b1cea246e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b1cea246e | |||
| 3f3bb82f1d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -43,6 +43,7 @@ logs/*.log
|
||||
.cache/
|
||||
.tmp/
|
||||
.gocache/
|
||||
.gocache-*/
|
||||
.gomodcache/
|
||||
frontend/admin/.cache/
|
||||
frontend/admin/playwright-report/
|
||||
@@ -54,6 +55,7 @@ Thumbs.db
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
config.yaml
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
340
docs/code-review/FULL_CODE_REVIEW_REPORT_2026-04-20.md
Normal file
340
docs/code-review/FULL_CODE_REVIEW_REPORT_2026-04-20.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# UMS 项目全面代码复核报告 v6.0
|
||||
|
||||
**报告日期**: 2026-04-20
|
||||
**审查范围**: 当前 `main` 工作区全部实现代码、旧报告未闭环问题、自动化门禁、系统化静态审查结果
|
||||
**基线说明**: 本报告按日期拆分,作为 [FULL_CODE_REVIEW_REPORT_2026-04-17.md](./FULL_CODE_REVIEW_REPORT_2026-04-17.md) 的后续复核报告。凡与旧报告或旧附录冲突之处,以本报告基于 2026-04-20 新鲜命令证据和当前代码实现得到的结论为准。
|
||||
|
||||
---
|
||||
|
||||
## 一句话结论
|
||||
|
||||
项目在 2026-04-17 报告中的多数首轮 P0 缺陷已经被修复,但当前代码仍存在新的认证与授权断层,且旧报告中的一部分未修复问题仍未真正闭环。当前状态不适合宣称“全部问题已修完”或“可直接上线”。
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 新鲜验证证据
|
||||
|
||||
| 项目 | 命令 | 结果 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 后端构建 | `go build ./cmd/server` | PASS | 2026-04-20 23:07:51 +08:00 实跑通过 |
|
||||
| 后端静态检查 | `go vet ./...` | PASS | 实跑通过 |
|
||||
| 后端测试 | `go test ./... -count=1` | PASS | 全量通过,`internal/service` 仍是主要耗时段 |
|
||||
| 前端 Lint | `cd frontend/admin && npm.cmd run lint` | PASS | 与 2026-04-18 红灯状态相比已恢复 |
|
||||
| 前端构建 | `cd frontend/admin && npm.cmd run build` | PASS | 实跑通过 |
|
||||
| 系统化静态检查 | `staticcheck ./...` | FAIL | 发现测试代码 `nil context`、潜在空指针、死代码等问题 |
|
||||
| 安全静态检查 | `gosec ./internal/... ./cmd/...` | FAIL | 有真实问题,也有大量误报/高噪音结果,需要人工过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 当前阻塞级问题
|
||||
|
||||
### P0-01: `TOTP` 二次验证链路缺少首因子绑定,形成独立登录入口
|
||||
|
||||
**位置**
|
||||
- `internal/api/handler/auth_handler.go:151`
|
||||
- `internal/service/auth.go:125`
|
||||
- `internal/service/auth.go:811`
|
||||
|
||||
**问题**
|
||||
- `/api/v1/auth/login/totp-verify` 只要求 `user_id + code + device_id`
|
||||
- 服务端 `VerifyTOTPAfterPasswordLogin()` 只校验用户状态与 `TOTP` 码,然后直接签发完整 token
|
||||
- 代码里虽然保留了 `TempToken` 字段,但当前登录闭环并未使用任何临时登录态或 challenge 票据
|
||||
|
||||
**影响**
|
||||
- “密码登录后第二步验证”被降级成“知道用户 ID 且拿到有效 TOTP 即可直接登录”
|
||||
- 这不是旧 P0-07 的原样复现,但本质上仍然属于 MFA 闭环未正确实现
|
||||
|
||||
**结论**
|
||||
- 旧报告 P0-07 不能标记为“已完全修复”,应迁移为“修复方向已变化,但认证闭环仍未完成”
|
||||
|
||||
### P0-02: 设备接口存在成组 `IDOR`
|
||||
|
||||
**位置**
|
||||
- `internal/api/handler/device_handler.go:114`
|
||||
- `internal/api/handler/device_handler.go:147`
|
||||
- `internal/api/handler/device_handler.go:183`
|
||||
- `internal/api/handler/device_handler.go:214`
|
||||
- `internal/api/handler/device_handler.go:392`
|
||||
- `internal/api/handler/device_handler.go:474`
|
||||
- `internal/service/device.go:121`
|
||||
- `internal/service/device.go:158`
|
||||
- `internal/service/device.go:163`
|
||||
- `internal/service/device.go:181`
|
||||
- `internal/service/device.go:204`
|
||||
- `internal/service/device.go:236`
|
||||
|
||||
**问题**
|
||||
- `GET/PUT/DELETE /devices/:id`
|
||||
- `PUT /devices/:id/status`
|
||||
- `POST/DELETE /devices/:id/trust`
|
||||
|
||||
这些接口的 handler 没有 owner/admin 校验,service 层也没有按 `user_id` 兜底约束,只按设备主键直接读写删除。
|
||||
|
||||
**影响**
|
||||
- 任意已登录用户只要知道设备 ID,就可以读取、修改、删除、信任或取消信任他人设备
|
||||
|
||||
**结论**
|
||||
- 这是本轮新增发现,严重程度等同发布阻塞
|
||||
|
||||
### P0-03: 修改密码接口缺少“本人或管理员”授权校验
|
||||
|
||||
**位置**
|
||||
- `internal/api/handler/user_handler.go:275`
|
||||
- `internal/service/user_service.go:84`
|
||||
|
||||
**问题**
|
||||
- `PUT /api/v1/users/:id/password` 直接使用路径里的 `id`
|
||||
- handler 没有 self-or-admin 校验
|
||||
- service 只验证目标用户旧密码是否正确
|
||||
|
||||
**影响**
|
||||
- 普通用户在知道目标用户旧密码时可直接修改目标用户密码
|
||||
- 管理员也没有单独的安全重置路径,权限模型与接口语义混杂
|
||||
|
||||
**结论**
|
||||
- 这是一条真实的授权缺口,应纳入 P0
|
||||
|
||||
### P0-04: 上下文协议漂移导致多处管理员路径失效
|
||||
|
||||
**位置**
|
||||
- `internal/api/middleware/auth.go:90`
|
||||
- `internal/api/middleware/auth.go:91`
|
||||
- `internal/api/handler/user_handler.go:191`
|
||||
- `internal/api/handler/user_handler.go:374`
|
||||
- `internal/api/handler/avatar_handler.go:74`
|
||||
|
||||
**问题**
|
||||
- 认证中间件当前只写入 `role_codes` / `permission_codes`
|
||||
- 多个 handler 仍读取旧的 `user_roles`
|
||||
|
||||
**影响**
|
||||
- 管理员跨用户更新资料
|
||||
- 管理员查看他人角色
|
||||
- 管理员代传头像
|
||||
|
||||
这些路径都会被错误判定为无权限。
|
||||
|
||||
**结论**
|
||||
- 旧 P0-06 已做过一轮修复,但当前实现没有真正闭环,应以“部分修复后回归失效”迁移进新报告
|
||||
|
||||
### P0-05: OAuth handler 仍返回“200 假成功”占位响应
|
||||
|
||||
**位置**
|
||||
- `internal/api/handler/auth_handler.go:316`
|
||||
- `internal/api/handler/auth_handler.go:329`
|
||||
- `internal/api/handler/auth_handler.go:342`
|
||||
- `internal/api/handler/auth_handler.go:353`
|
||||
- `internal/service/auth.go:939`
|
||||
- `internal/service/auth.go:946`
|
||||
- `internal/service/auth.go:1492`
|
||||
|
||||
**问题**
|
||||
- handler 仍直接返回 `OAuth not configured` 或空 provider 列表
|
||||
- service 层实际上已经存在 `OAuthLogin` / `OAuthCallback` / `GetEnabledOAuthProviders` 逻辑
|
||||
|
||||
**影响**
|
||||
- API 层向前端暴露假成功语义
|
||||
- 与仓库“禁止 fake success / fail closed”的运行时规则冲突
|
||||
|
||||
**结论**
|
||||
- 这不是旧报告中的原编号问题,但属于当前实现真实性问题,应纳入高优先级修复
|
||||
|
||||
### P0-06: 游标分页与动态排序的契约仍未真正闭环
|
||||
|
||||
**位置**
|
||||
- `internal/repository/user.go:353`
|
||||
|
||||
**问题**
|
||||
- 当前实现只在 `sortBy == created_at` 时应用游标条件
|
||||
- 其他排序字段下并不会报错,只是静默忽略游标条件
|
||||
|
||||
**影响**
|
||||
- 前端如果带着非 `created_at` 排序继续请求下一页,得到的不是严格意义上的“下一页”
|
||||
- 旧报告的“数据错乱”主因已经被收敛,但 API 契约仍然是不闭合的,容易出现重复页或错误分页预期
|
||||
|
||||
**结论**
|
||||
- 旧 P0-08 不应从报告中移除,应以下降风险后的“残留契约缺口”形式迁移
|
||||
|
||||
---
|
||||
|
||||
## 从旧报告迁移的未闭环问题
|
||||
|
||||
下表只迁移“当前仍未真正闭环”的旧问题;已经明确修复完成的问题不再重复记为未完成。
|
||||
|
||||
| 旧编号 | 当前状态 | 新报告结论 |
|
||||
|---|---|---|
|
||||
| P0-06 UpdateUser IDOR | 部分修复后再次失效 | 迁移为 P0-04,上下文协议漂移导致管理员授权逻辑失效 |
|
||||
| P0-07 Login 绕过 TOTP | 修复方向变化,但未闭环 | 迁移为 P0-01,`totp-verify` 未绑定首因子 |
|
||||
| P0-08 ListCursor / sort | 风险下降但契约未闭合 | 迁移为 P0-06,非 `created_at` 排序下游标被静默忽略 |
|
||||
| P1-12 ~ P1-14 响应格式不一致 | 仍未修复 | 保留为 P1,`auth_handler`、`password_reset_handler` 等多处仍返回非统一响应格式 |
|
||||
| P2-12 `/uploads` 直接暴露 | 仍未修复 | 保留为 P2,`router.Setup()` 仍静态暴露上传目录 |
|
||||
|
||||
---
|
||||
|
||||
## 已确认修复完成的旧问题
|
||||
|
||||
以下问题在当前代码中已具备明确修复证据,不再迁移为“未修复项”:
|
||||
|
||||
| 旧编号 | 当前状态 | 证据 |
|
||||
|---|---|---|
|
||||
| P0-01 LIKE 通配/模式注入 | 已修复 | `internal/repository/operation_log.go`、`internal/repository/device.go`、`internal/repository/user.go` 已统一使用 `escapeLikePattern()` |
|
||||
| P0-02 登录失败计数竞态 | 主路径已修复 | `internal/service/auth.go:492` 已改用 `cache.Increment()`;但降级 fallback 仍保留非原子路径,见“残留风险” |
|
||||
| P0-03 refresh 黑名单 fail-open | 已修复 | `internal/service/auth.go` 中黑名单写入失败已向上返回错误 |
|
||||
| P0-04 手机重置 replay | 基本修复 | `internal/service/password_reset.go` 在验证码校验通过后先删除 key 再继续流程 |
|
||||
| P0-05 CORS 默认危险组合 | 已修复 | `internal/api/middleware/cors.go` 默认值已改为空 origins + `AllowCredentials=false` |
|
||||
| P1-01 错误处理中间件泄露内部错误 | 已修复 | `internal/api/middleware/error.go` 对未知错误返回通用消息 |
|
||||
| P1-03 导出接口泄露内部错误 | 已修复 | `internal/api/handler/export_handler.go` 已改为通用错误文案 |
|
||||
| P1-04 CountByResultSince 静默忽略错误 | 已修复 | `internal/repository/login_log.go` 已返回 `(int64, error)` |
|
||||
| P1-07 Theme SetDefault 非原子 | 已修复 | `internal/repository/theme.go` 已改用事务 |
|
||||
| P1-08 数据库连接池硬编码 | 已修复 | `internal/database/db.go` 已使用配置参数 |
|
||||
| P1-15 分页参数无上限 | 大体修复 | `user_handler.go`、`device_handler.go`、`log_handler.go` 均已限制 `page_size <= 100` |
|
||||
|
||||
---
|
||||
|
||||
## 仍需保留的中高优先级问题
|
||||
|
||||
### P1-01: API 响应格式仍然不统一
|
||||
|
||||
**位置**
|
||||
- `internal/api/handler/auth_handler.go`
|
||||
- `internal/api/handler/password_reset_handler.go`
|
||||
- `internal/api/handler/user_handler.go`
|
||||
|
||||
**问题**
|
||||
- 同一套 API 中同时存在 `{error: ...}`、`{message: ...}`、`{code,message,data}` 等多种响应结构
|
||||
- `Logout`、`CSRF`、认证错误分支、参数绑定错误分支的格式仍不一致
|
||||
|
||||
**影响**
|
||||
- 前端错误处理成本高
|
||||
- 自动化契约测试难写
|
||||
- 文档与真实行为容易继续漂移
|
||||
|
||||
### P1-02: 登录失败计数器仍保留非原子降级路径
|
||||
|
||||
**位置**
|
||||
- `internal/service/auth.go:492`
|
||||
|
||||
**问题**
|
||||
- 主路径已使用 `cache.Increment()`
|
||||
- 但 `Increment` 出错时仍回退到 `Get + current++ + Set`
|
||||
|
||||
**影响**
|
||||
- 在缓存不支持原子递增或运行时出错场景下,旧竞态仍可能重现
|
||||
|
||||
**结论**
|
||||
- 不再按 P0 处理,但仍是必须收尾的 P1
|
||||
|
||||
### P1-03: CLI/初始化路径存在权限与类型转换告警
|
||||
|
||||
**系统化工具证据**
|
||||
- `cmd/ums/cmd/init.go:306` `gosec G115`
|
||||
- `cmd/ums/cmd/init.go:341` `gosec G301`
|
||||
- `cmd/ums/cmd/init.go:446` `gosec G306`
|
||||
|
||||
**人工判断**
|
||||
- `int(os.Stdin.Fd())` 在 Windows 常见运行路径下不一定形成真实高危,但应改成更明确的受控转换
|
||||
- 初始化命令写目录/文件权限偏宽,适合作为 P1/P2 收敛项
|
||||
|
||||
### P2-01: 上传目录仍被直接公开暴露
|
||||
|
||||
**位置**
|
||||
- `internal/api/router/router.go`
|
||||
|
||||
**问题**
|
||||
- `r.engine.Static("/uploads", "./uploads")` 仍直接公开暴露上传目录
|
||||
|
||||
**影响**
|
||||
- 上传内容默认可被匿名访问
|
||||
- 一旦上传内容策略控制不足,容易扩大文件暴露面
|
||||
|
||||
---
|
||||
|
||||
## 系统化工具补充审查
|
||||
|
||||
### `staticcheck ./...` 结果摘要
|
||||
|
||||
人工过滤后,当前值得保留的信号主要有三类:
|
||||
|
||||
1. **测试代码错误用法**
|
||||
- `internal/api/handler/captcha_handler_test.go`
|
||||
- `internal/service/auth_capabilities_test.go`
|
||||
|
||||
存在 `SA1012`,测试里向需要 `context.Context` 的调用传了 `nil`。
|
||||
|
||||
2. **测试代码潜在空指针**
|
||||
- `internal/service/sms_provider_test.go`
|
||||
- `internal/service/user_roles_test.go`
|
||||
- `internal/service/webhook_service_test.go`
|
||||
|
||||
存在 `SA5011`,说明部分测试断言路径缺少空值保护。
|
||||
|
||||
3. **仓库内死代码/遗留辅助代码**
|
||||
- `internal/api/middleware/auth.go`
|
||||
- `internal/monitoring/slo.go`
|
||||
- `internal/repository/sql_scan.go`
|
||||
- `internal/repository/pagination.go`
|
||||
|
||||
存在 `U1000`,说明最近几轮修复后有未清理的遗留函数或字段。
|
||||
|
||||
### `gosec ./internal/... ./cmd/...` 结果摘要
|
||||
|
||||
`gosec` 本轮输出噪音较大,尤其把 OAuth URL、常量名、header 名、token URL 大量误判为“硬编码凭证”。人工过滤后,建议保留的结果如下:
|
||||
|
||||
1. **真实可收敛问题**
|
||||
- `internal/api/handler/avatar_handler.go:147` `G301`
|
||||
- `internal/api/handler/avatar_handler.go:159` `G306`
|
||||
- `internal/service/password_reset.go:237`
|
||||
- `internal/service/password_reset.go:252`
|
||||
|
||||
前者是目录/文件权限偏宽,后者是关键删除操作忽略返回错误。
|
||||
|
||||
2. **低风险但建议修整**
|
||||
- `internal/service/captcha.go:164` `G404`
|
||||
|
||||
这里使用 `math/rand` 仅用于验证码图片背景色随机化,不直接影响验证码秘密值,但可以考虑改为更明确的非安全随机用途注释,或避免被安全扫描反复报警。
|
||||
|
||||
3. **高噪音误报,不建议直接据此立项**
|
||||
- OAuth token URL / auth URL
|
||||
- Header 名称
|
||||
- 非凭证字符串常量
|
||||
|
||||
这些不应直接写进缺陷列表,否则会污染修复优先级。
|
||||
|
||||
---
|
||||
|
||||
## 当前建议修复顺序
|
||||
|
||||
### 第一批:立即处理
|
||||
|
||||
1. 修复 `totp-verify` 登录闭环,要求必须携带首因子验证后的临时态
|
||||
2. 为设备接口补全 owner/admin 校验,并在 service 层增加按 `user_id` 的兜底约束
|
||||
3. 为 `/users/:id/password` 增加 self-or-admin 授权,并区分“本人修改密码”和“管理员重置密码”语义
|
||||
4. 统一 handler 上下文字段,彻底移除 `user_roles` 旧协议
|
||||
5. 去掉 OAuth handler 的假成功返回,改成真实能力分发或显式 fail closed
|
||||
|
||||
### 第二批:本周内收口
|
||||
|
||||
1. 统一 API 响应结构
|
||||
2. 清理登录失败计数器 fallback 竞态
|
||||
3. 清理 `staticcheck` 暴露的测试错误与死代码
|
||||
4. 收敛 `gosec` 中目录/文件权限与关键错误忽略问题
|
||||
|
||||
---
|
||||
|
||||
## 对旧报告的处理建议
|
||||
|
||||
1. 保留旧报告作为历史记录,不删除
|
||||
2. 明确以本报告作为后续复核基线
|
||||
3. 旧报告中“2026-04-18 修复完成附录”的“全部问题已修复完成”说法不再可信,后续对外引用时应停止使用该表述
|
||||
|
||||
---
|
||||
|
||||
## 最终判断
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 当前是否全部修复完成 | 否 |
|
||||
| 当前是否适合直接上线 | 否 |
|
||||
| 是否比 2026-04-17 更接近可上线 | 是,门禁更绿,旧 P0 多数已修,但出现新的授权/认证断层 |
|
||||
| 当前最真实的状态 | “旧高危问题大部分已修,当前仍有新的 P0 授权与认证问题待收口,系统化静态审查还暴露出测试与遗留代码清理不足” |
|
||||
|
||||
89
docs/plans/2026-04-21-report-v6-execution-plan.md
Normal file
89
docs/plans/2026-04-21-report-v6-execution-plan.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Report v6 Blocking Fixes 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:** 修复 `FULL_CODE_REVIEW_REPORT_2026-04-20.md` 中当前阻塞上线的认证、授权和假成功问题,并为每项修复补齐回归验证。
|
||||
|
||||
**Architecture:** 以后端授权和认证闭环为主,优先通过测试锁定期望行为,再做最小实现修改。每个批次修复后运行受影响测试集,最后跑完整后端/前端门禁。
|
||||
|
||||
**Tech Stack:** Go, Gin, GORM, React, Vitest, PowerShell, Git
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 锁定 TOTP 二阶段登录闭环
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/service/auth.go`
|
||||
- Modify: `internal/api/handler/auth_handler.go`
|
||||
- Modify: `frontend/admin/src/services/auth.ts`
|
||||
- Modify: `frontend/admin/src/types/auth.ts`
|
||||
- Test: `internal/service/auth_social_test.go`
|
||||
- Test: `internal/api/handler/auth_handler_test.go`
|
||||
|
||||
- [ ] **Step 1: 写服务层失败测试**
|
||||
- [ ] **Step 2: 运行服务层测试确认当前允许无首因子直接换 token**
|
||||
- [ ] **Step 3: 实现临时登录态或 challenge 约束**
|
||||
- [ ] **Step 4: 写 handler/前端契约测试**
|
||||
- [ ] **Step 5: 运行受影响测试并确认通过**
|
||||
|
||||
### Task 2: 修复设备接口 IDOR
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handler/device_handler.go`
|
||||
- Modify: `internal/service/device.go`
|
||||
- Test: `internal/api/handler/device_handler_test.go`
|
||||
- Test: `internal/service/device_service_test.go`
|
||||
|
||||
- [ ] **Step 1: 写失败测试覆盖跨用户读取/修改/删除/信任设备**
|
||||
- [ ] **Step 2: 运行测试确认当前越权成立**
|
||||
- [ ] **Step 3: 在 handler 和 service 层补 owner/admin 双层校验**
|
||||
- [ ] **Step 4: 运行受影响测试并确认通过**
|
||||
|
||||
### Task 3: 修复修改密码接口授权模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handler/user_handler.go`
|
||||
- Modify: `internal/service/user_service.go`
|
||||
- Test: `internal/api/handler/user_handler_test.go`
|
||||
|
||||
- [ ] **Step 1: 写失败测试覆盖非本人访问 `/users/:id/password`**
|
||||
- [ ] **Step 2: 运行测试确认当前缺口存在**
|
||||
- [ ] **Step 3: 增加 self-or-admin 校验并明确管理员重置策略**
|
||||
- [ ] **Step 4: 运行受影响测试并确认通过**
|
||||
|
||||
### Task 4: 清理 `user_roles` 到 `role_codes` 协议漂移
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handler/user_handler.go`
|
||||
- Modify: `internal/api/handler/avatar_handler.go`
|
||||
- Test: `internal/api/handler/user_handler_test.go`
|
||||
- Test: `internal/api/handler/avatar_handler_test.go`
|
||||
|
||||
- [ ] **Step 1: 写失败测试覆盖管理员跨用户操作被误拒绝**
|
||||
- [ ] **Step 2: 运行测试确认当前回归存在**
|
||||
- [ ] **Step 3: 统一读取 `role_codes` 或复用 RBAC helper**
|
||||
- [ ] **Step 4: 运行受影响测试并确认通过**
|
||||
|
||||
### Task 5: 去掉 OAuth 假成功响应
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handler/auth_handler.go`
|
||||
- Test: `internal/api/handler/auth_handler_test.go`
|
||||
|
||||
- [ ] **Step 1: 写失败测试覆盖 OAuth provider 列表与入口行为**
|
||||
- [ ] **Step 2: 运行测试确认 handler 当前没有调用 service**
|
||||
- [ ] **Step 3: 改成真实 service 分发或显式错误返回**
|
||||
- [ ] **Step 4: 运行受影响测试并确认通过**
|
||||
|
||||
### Task 6: 全量回归与提交流程
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/code-review/FULL_CODE_REVIEW_REPORT_2026-04-20.md`
|
||||
- Modify: `docs/status/REAL_PROJECT_STATUS.md`
|
||||
|
||||
- [ ] **Step 1: 更新报告中已修复项和剩余风险**
|
||||
- [ ] **Step 2: 运行完整后端/前端门禁**
|
||||
- [ ] **Step 3: 检查 git diff 与工作区状态**
|
||||
- [ ] **Step 4: 按逻辑批次提交**
|
||||
- [ ] **Step 5: 推送远程分支**
|
||||
|
||||
@@ -1,5 +1,145 @@
|
||||
# REAL PROJECT STATUS
|
||||
|
||||
## 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/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 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 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修复后)
|
||||
|
||||
本节记录 2026-04-10 TDD修复后的最新状态。
|
||||
@@ -232,8 +372,11 @@
|
||||
| `webhook-management` | Webhook 页面导航、列表显示 | ✅ 已添加 |
|
||||
| `profile-and-security` | 个人资料页、安全设置页(密码修改、TOTP) | ✅ 已添加 |
|
||||
| `dashboard-stats` | 仪表盘统计卡片完整验证 | ✅ 已添加 |
|
||||
| `user-management-batch` | 用户批量启用、批量禁用、批量删除 | ✅ 已添加 |
|
||||
| `import-export` | 导入导出页面、模板下载、用户导出 | ✅ 已添加 |
|
||||
| `settings` | 系统设置页面、真实 `/admin/settings` 加载 | ✅ 已添加 |
|
||||
|
||||
### E2E 覆盖场景汇总(共 15 个)
|
||||
### E2E 覆盖场景汇总(共 18 个)
|
||||
|
||||
| # | 场景 | 覆盖内容 |
|
||||
|---|------|----------|
|
||||
@@ -252,6 +395,9 @@
|
||||
| 13 | `webhook-management` | Webhook 管理 |
|
||||
| 14 | `profile-and-security` | 个人资料与安全 |
|
||||
| 15 | `dashboard-stats` | 仪表盘统计 |
|
||||
| 16 | `user-management-batch` | 用户批量操作 |
|
||||
| 17 | `import-export` | 导入导出 |
|
||||
| 18 | `settings` | 系统设置 |
|
||||
|
||||
### 防虚假测试规则
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
28
frontend/admin/scripts/cdp-connect-probe.mjs
Normal file
28
frontend/admin/scripts/cdp-connect-probe.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import process from 'node:process'
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const cdpBaseUrl = (process.env.E2E_PLAYWRIGHT_CDP_URL ?? process.env.E2E_CDP_BASE_URL ?? '').trim()
|
||||
|
||||
if (!cdpBaseUrl) {
|
||||
throw new Error('E2E_PLAYWRIGHT_CDP_URL or E2E_CDP_BASE_URL is required')
|
||||
}
|
||||
|
||||
console.log(`PROBE cdp=${cdpBaseUrl}`)
|
||||
|
||||
if (process.env.PROBE_PRECREATE_TARGET === '1') {
|
||||
console.log('PROBE precreate-target=start')
|
||||
await fetch(`${cdpBaseUrl}/json/new?about:blank`, { method: 'PUT' }).catch(async () => {
|
||||
await fetch(`${cdpBaseUrl}/json/new?about:blank`)
|
||||
})
|
||||
console.log('PROBE precreate-target=done')
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(cdpBaseUrl)
|
||||
console.log(`PROBE connected contexts=${browser.contexts().length}`)
|
||||
|
||||
for (const [index, context] of browser.contexts().entries()) {
|
||||
console.log(`PROBE context[${index}] pages=${context.pages().length}`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
console.log('PROBE done')
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -383,6 +384,7 @@ try {
|
||||
Write-Host "Launching command: $commandName $($commandArgs -join ' ')"
|
||||
& $commandName @commandArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Show-BrowserLogs $browserHandle
|
||||
throw "command failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -9,19 +9,58 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||
$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache'
|
||||
$goCacheDir = Join-Path $tempCacheRoot 'go-build'
|
||||
$goModCacheDir = Join-Path $tempCacheRoot 'gomod'
|
||||
$goPathDir = Join-Path $tempCacheRoot 'gopath'
|
||||
function Resolve-E2ERoots {
|
||||
$scriptFrontendRoot = Resolve-Path (Join-Path $PSScriptRoot '..') -ErrorAction SilentlyContinue
|
||||
$scriptProjectRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..') -ErrorAction SilentlyContinue
|
||||
$cwdFrontendRoot = Resolve-Path (Get-Location).Path
|
||||
$cwdProjectRoot = Resolve-Path (Join-Path $cwdFrontendRoot '..\..') -ErrorAction SilentlyContinue
|
||||
|
||||
if (
|
||||
$scriptFrontendRoot -and
|
||||
$scriptProjectRoot -and
|
||||
(Test-Path (Join-Path $scriptFrontendRoot 'package.json')) -and
|
||||
(Test-Path (Join-Path $scriptProjectRoot 'go.mod'))
|
||||
) {
|
||||
return [pscustomobject]@{
|
||||
FrontendRoot = $scriptFrontendRoot.Path
|
||||
ProjectRoot = $scriptProjectRoot.Path
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
$cwdProjectRoot -and
|
||||
(Test-Path (Join-Path $cwdFrontendRoot 'package.json')) -and
|
||||
(Test-Path (Join-Path $cwdProjectRoot 'go.mod'))
|
||||
) {
|
||||
return [pscustomobject]@{
|
||||
FrontendRoot = $cwdFrontendRoot
|
||||
ProjectRoot = $cwdProjectRoot.Path
|
||||
}
|
||||
}
|
||||
|
||||
throw 'failed to resolve frontend/project roots for playwright e2e'
|
||||
}
|
||||
|
||||
$resolvedRoots = Resolve-E2ERoots
|
||||
$projectRoot = $resolvedRoots.ProjectRoot
|
||||
$frontendRoot = $resolvedRoots.FrontendRoot
|
||||
$serverExePath = Join-Path $env:TEMP ("ums-server-playwright-e2e-" + [guid]::NewGuid().ToString('N') + '.exe')
|
||||
$e2eRunRoot = Join-Path $env:TEMP ("ums-playwright-e2e-" + [guid]::NewGuid().ToString('N'))
|
||||
$goCacheDir = Join-Path $e2eRunRoot 'go-build'
|
||||
$goModCacheDir = Join-Path $e2eRunRoot 'gomod'
|
||||
$goPathDir = Join-Path $e2eRunRoot 'gopath'
|
||||
$e2eDataRoot = Join-Path $e2eRunRoot 'data'
|
||||
$e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db'
|
||||
$smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl'
|
||||
$e2eConfigPath = Join-Path $e2eRunRoot 'config.yaml'
|
||||
$bootstrapSecret = 'e2e-bootstrap-secret'
|
||||
|
||||
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eDataRoot | Out-Null
|
||||
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eRunRoot, $e2eDataRoot | Out-Null
|
||||
Set-Content -Path $e2eConfigPath -Encoding utf8 -Value @(
|
||||
'default:',
|
||||
' admin_email: ""',
|
||||
' admin_password: ""'
|
||||
)
|
||||
|
||||
function Get-FreeTcpPort {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
@@ -64,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,
|
||||
@@ -160,28 +221,36 @@ $backendBaseUrl = "http://127.0.0.1:$selectedBackendPort"
|
||||
$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort"
|
||||
|
||||
try {
|
||||
$serverSrcPath = Join-Path $projectRoot 'cmd\server'
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
$env:GOCACHE = $goCacheDir
|
||||
go build -o $serverExePath $serverSrcPath
|
||||
$env:GOMODCACHE = $goModCacheDir
|
||||
$env:GOPATH = $goPathDir
|
||||
$env:GOTELEMETRY = 'off'
|
||||
go build -o $serverExePath ./cmd/server
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'server build failed'
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOTELEMETRY -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$env:DATA_DIR = $e2eRunRoot
|
||||
$env:SERVER_PORT = "$selectedBackendPort"
|
||||
$env:DATABASE_DBNAME = $e2eDbPath
|
||||
$env:SERVER_MODE = 'debug'
|
||||
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
|
||||
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
|
||||
$env:SERVER_MODE = 'debug'
|
||||
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
|
||||
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
|
||||
$env:LOGGING_OUTPUT = 'stdout'
|
||||
$env:EMAIL_HOST = '127.0.0.1'
|
||||
$env:EMAIL_PORT = "$selectedSMTPPort"
|
||||
$env:EMAIL_FROM_EMAIL = 'noreply@test.local'
|
||||
$env:EMAIL_FROM_NAME = 'UMS E2E'
|
||||
$env:BOOTSTRAP_SECRET = $bootstrapSecret
|
||||
# JWT secret must be at least 32 bytes
|
||||
$env:JWT_SECRET = 'e2e-test-jwt-secret-at-least-32-bytes-long-for-security'
|
||||
|
||||
@@ -232,16 +301,26 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
$env:E2E_LOGIN_USERNAME = $AdminUsername
|
||||
$env:E2E_LOGIN_PASSWORD = $AdminPassword
|
||||
$env:E2E_LOGIN_EMAIL = $AdminEmail
|
||||
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
|
||||
$env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret
|
||||
$env:E2E_EXTERNAL_WEB_SERVER = '1'
|
||||
$env:E2E_BASE_URL = $frontendBaseUrl
|
||||
$env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1"
|
||||
$env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile
|
||||
|
||||
Push-Location $frontendRoot
|
||||
try {
|
||||
$lastError = $null
|
||||
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
$suiteAttempts = 3
|
||||
if ($env:E2E_SUITE_ATTEMPTS) {
|
||||
$parsedSuiteAttempts = 0
|
||||
if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) {
|
||||
$suiteAttempts = $parsedSuiteAttempts
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -249,7 +328,7 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
break
|
||||
} catch {
|
||||
$lastError = $_
|
||||
if ($attempt -ge 2) {
|
||||
if ($attempt -ge $suiteAttempts) {
|
||||
throw
|
||||
}
|
||||
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
|
||||
@@ -263,12 +342,15 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:DATA_DIR -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_LOGIN_EMAIL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_API_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue
|
||||
}
|
||||
} finally {
|
||||
@@ -290,9 +372,11 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
|
||||
Remove-Item Env:EMAIL_FROM_NAME -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:DEFAULT_ADMIN_EMAIL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:DEFAULT_ADMIN_PASSWORD -ErrorAction SilentlyContinue
|
||||
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $e2eConfigPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }))
|
||||
|
||||
@@ -188,6 +188,10 @@ async function resolveAuthorizationHeader(auth: boolean): Promise<string | null>
|
||||
}
|
||||
|
||||
let token = getAccessToken()
|
||||
if (token && !isAccessTokenExpired()) {
|
||||
return token
|
||||
}
|
||||
|
||||
if (isRefreshing()) {
|
||||
const promise = getRefreshPromise()
|
||||
if (promise) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getAccessTokenMock = vi.fn<() => string | null>()
|
||||
|
||||
function jsonResponse(data: unknown, init: ResponseInit = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
@@ -12,6 +14,9 @@ function jsonResponse(data: unknown, init: ResponseInit = {}) {
|
||||
|
||||
async function loadCsrfModule() {
|
||||
vi.resetModules()
|
||||
vi.doMock('./auth-session', () => ({
|
||||
getAccessToken: () => getAccessTokenMock(),
|
||||
}))
|
||||
return import('./csrf')
|
||||
}
|
||||
|
||||
@@ -27,6 +32,8 @@ describe('csrf helpers', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.unstubAllEnvs()
|
||||
getAccessTokenMock.mockReset()
|
||||
getAccessTokenMock.mockReturnValue(null)
|
||||
clearCsrfCookie()
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
@@ -85,6 +92,7 @@ describe('csrf helpers', () => {
|
||||
|
||||
it('fetches and stores a csrf token from the default relative api base', async () => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
getAccessTokenMock.mockReturnValue('access-token')
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
code: 0,
|
||||
@@ -105,6 +113,7 @@ describe('csrf helpers', () => {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: 'Bearer access-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// 使用原生 fetch 获取 CSRF Token
|
||||
|
||||
import { config } from '@/lib/config'
|
||||
import { getAccessToken } from './auth-session'
|
||||
|
||||
// CSRF Token 存储
|
||||
let csrfToken: string | null = null
|
||||
@@ -84,13 +85,19 @@ export async function initCSRFToken(): Promise<string | null> {
|
||||
|
||||
if (!token) {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const accessToken = getAccessToken()
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
// 使用原生 fetch 避免循环依赖
|
||||
const response = await fetch(buildUrl('/auth/csrf-token'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@@ -4,9 +4,12 @@ import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Device, AdminDeviceListParams } from '@/types/device'
|
||||
import type { CursorPaginatedData, PaginatedData } from '@/types/http'
|
||||
import { DevicesPage } from './DevicesPage'
|
||||
|
||||
const listAllDevicesMock = vi.fn<(params?: AdminDeviceListParams) => Promise<{ items: Device[]; total: number; page: number; page_size: number }>>()
|
||||
type DeviceListResponse = PaginatedData<Device> | CursorPaginatedData<Device>
|
||||
|
||||
const listAllDevicesMock = vi.fn<(params?: AdminDeviceListParams) => Promise<DeviceListResponse>>()
|
||||
const deleteDeviceMock = vi.fn<(id: number) => Promise<void>>()
|
||||
const trustDeviceMock = vi.fn<(id: number, duration?: string) => Promise<void>>()
|
||||
const untrustDeviceMock = vi.fn<(id: number) => Promise<void>>()
|
||||
@@ -377,6 +380,34 @@ describe('DevicesPage', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not auto-request the next cursor page after initial load', async () => {
|
||||
listAllDevicesMock.mockReset()
|
||||
listAllDevicesMock
|
||||
.mockResolvedValueOnce({
|
||||
items: [currentDevices[0]],
|
||||
next_cursor: 'cursor-page-2',
|
||||
has_more: true,
|
||||
page_size: 20,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [currentDevices[1]],
|
||||
next_cursor: '',
|
||||
has_more: false,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
render(<DevicesPage />)
|
||||
|
||||
expect(await screen.findByText('Device 1')).toBeInTheDocument()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(listAllDevicesMock).toHaveBeenCalledTimes(1)
|
||||
expect(listAllDevicesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cursor: undefined, size: 20 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error state and retry', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ export function DevicesPage() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
// Cursor-based pagination state (preferred for large datasets)
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [requestCursor, setRequestCursor] = useState('')
|
||||
const [nextCursor, setNextCursor] = useState('')
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
// Legacy page state (for Ant Design Table compatibility)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -64,7 +65,7 @@ export function DevicesPage() {
|
||||
setError(null)
|
||||
try {
|
||||
const params: AdminDeviceListParams = {
|
||||
cursor: cursor || undefined,
|
||||
cursor: requestCursor || undefined,
|
||||
size: pageSize,
|
||||
keyword: keyword || undefined,
|
||||
user_id: userIdFilter,
|
||||
@@ -75,12 +76,14 @@ export function DevicesPage() {
|
||||
setDevices(result.items ?? [])
|
||||
// If the response has cursor fields, use them; otherwise fall back to legacy total
|
||||
if ('next_cursor' in result) {
|
||||
setCursor(result.next_cursor ?? '')
|
||||
setNextCursor(result.next_cursor ?? '')
|
||||
setHasMore(result.has_more ?? false)
|
||||
// Estimate total from current data + whether there's more
|
||||
setTotal((page - 1) * pageSize + result.items?.length + (result.has_more ? 1 : 0))
|
||||
} else {
|
||||
// Legacy response format fallback
|
||||
setNextCursor('')
|
||||
setHasMore(false)
|
||||
setTotal((result as { total?: number }).total ?? 0)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -88,7 +91,7 @@ export function DevicesPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [cursor, page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
|
||||
}, [requestCursor, page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchDevices()
|
||||
@@ -97,7 +100,8 @@ export function DevicesPage() {
|
||||
// 筛选条件变化时重置到第一页(清空游标)
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setCursor('')
|
||||
setRequestCursor('')
|
||||
setNextCursor('')
|
||||
}, [keyword, userIdFilter, statusFilter, trustFilter])
|
||||
|
||||
// 重置筛选
|
||||
@@ -107,7 +111,8 @@ export function DevicesPage() {
|
||||
setStatusFilter(undefined)
|
||||
setTrustFilter(undefined)
|
||||
setPage(1)
|
||||
setCursor('')
|
||||
setRequestCursor('')
|
||||
setNextCursor('')
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
@@ -278,14 +283,17 @@ export function DevicesPage() {
|
||||
if (ps !== pageSize) {
|
||||
setPageSize(ps)
|
||||
setPage(1)
|
||||
setCursor('')
|
||||
} else if (p === page + 1 && cursor) {
|
||||
setRequestCursor('')
|
||||
setNextCursor('')
|
||||
} else if (p === page + 1 && nextCursor) {
|
||||
// Next page via cursor
|
||||
setPage(p)
|
||||
setRequestCursor(nextCursor)
|
||||
} else {
|
||||
// Jump to specific page - fall back
|
||||
setPage(p)
|
||||
setCursor('')
|
||||
setRequestCursor('')
|
||||
setNextCursor('')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import type { AuthCapabilities, TokenBundle } from '@/types'
|
||||
import { BootstrapAdminPage } from './BootstrapAdminPage'
|
||||
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const bootstrapAdminMock = vi.fn<(payload: unknown) => Promise<TokenBundle>>()
|
||||
const bootstrapAdminMock = vi.fn<(payload: unknown, bootstrapSecret: string) => Promise<TokenBundle>>()
|
||||
const onLoginSuccessMock = vi.fn<(tokenBundle: TokenBundle) => Promise<void>>()
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
||||
bootstrapAdmin: (payload: unknown) => bootstrapAdminMock(payload),
|
||||
bootstrapAdmin: (payload: unknown, bootstrapSecret: string) => bootstrapAdminMock(payload, bootstrapSecret),
|
||||
}))
|
||||
|
||||
const authContextValue: AuthContextValue = {
|
||||
@@ -76,6 +76,7 @@ describe('BootstrapAdminPage', () => {
|
||||
|
||||
expect(screen.getByRole('heading', { name: '初始化首个管理员账号' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('管理员用户名')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('引导密钥')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('管理员密码')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '完成初始化并进入系统' })).toBeInTheDocument()
|
||||
})
|
||||
@@ -89,17 +90,21 @@ describe('BootstrapAdminPage', () => {
|
||||
await user.type(screen.getByPlaceholderText('管理员用户名'), 'bootstrap_admin')
|
||||
await user.type(screen.getByPlaceholderText('管理员昵称(选填)'), 'Bootstrap Admin')
|
||||
await user.type(screen.getByPlaceholderText('管理员邮箱(选填)'), 'bootstrap_admin@example.com')
|
||||
await user.type(screen.getByPlaceholderText('引导密钥'), 'bootstrap-secret')
|
||||
await user.type(screen.getByPlaceholderText('管理员密码'), 'Bootstrap123!@#')
|
||||
await user.type(screen.getByPlaceholderText('确认管理员密码'), 'Bootstrap123!@#')
|
||||
await user.click(screen.getByRole('button', { name: '完成初始化并进入系统' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(bootstrapAdminMock).toHaveBeenCalledWith({
|
||||
username: 'bootstrap_admin',
|
||||
nickname: 'Bootstrap Admin',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
password: 'Bootstrap123!@#',
|
||||
}),
|
||||
expect(bootstrapAdminMock).toHaveBeenCalledWith(
|
||||
{
|
||||
username: 'bootstrap_admin',
|
||||
nickname: 'Bootstrap Admin',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
password: 'Bootstrap123!@#',
|
||||
},
|
||||
'bootstrap-secret',
|
||||
),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -25,6 +25,7 @@ type BootstrapAdminFormValues = {
|
||||
username: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
bootstrapSecret: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
@@ -68,12 +69,15 @@ export function BootstrapAdminPage() {
|
||||
const handleSubmit = useCallback(async (values: BootstrapAdminFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const tokenBundle = await bootstrapAdmin({
|
||||
username: values.username.trim(),
|
||||
nickname: values.nickname?.trim() || undefined,
|
||||
email: values.email?.trim() || undefined,
|
||||
password: values.password,
|
||||
})
|
||||
const tokenBundle = await bootstrapAdmin(
|
||||
{
|
||||
username: values.username.trim(),
|
||||
nickname: values.nickname?.trim() || undefined,
|
||||
email: values.email?.trim() || undefined,
|
||||
password: values.password,
|
||||
},
|
||||
values.bootstrapSecret.trim(),
|
||||
)
|
||||
await onLoginSuccess(tokenBundle)
|
||||
message.success('管理员初始化完成')
|
||||
navigate('/dashboard', { replace: true })
|
||||
@@ -152,6 +156,17 @@ export function BootstrapAdminPage() {
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bootstrapSecret"
|
||||
rules={[{ required: true, message: '请输入引导密钥' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="引导密钥"
|
||||
size="large"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入管理员密码' }]}
|
||||
|
||||
@@ -29,6 +29,7 @@ const assignMock = vi.fn()
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const getOAuthAuthorizationUrlMock = vi.fn()
|
||||
const loginByPasswordMock = vi.fn()
|
||||
const verifyTOTPAfterPasswordLoginMock = vi.fn()
|
||||
const loginByEmailCodeMock = vi.fn()
|
||||
const loginBySmsCodeMock = vi.fn()
|
||||
const sendEmailCodeMock = vi.fn()
|
||||
@@ -73,6 +74,7 @@ vi.mock('@/services/auth', () => ({
|
||||
getOAuthAuthorizationUrl: (provider: string, returnTo: string) =>
|
||||
getOAuthAuthorizationUrlMock(provider, returnTo),
|
||||
loginByPassword: (payload: unknown) => loginByPasswordMock(payload),
|
||||
verifyTOTPAfterPasswordLogin: (payload: unknown) => verifyTOTPAfterPasswordLoginMock(payload),
|
||||
loginByEmailCode: (payload: unknown) => loginByEmailCodeMock(payload),
|
||||
loginBySmsCode: (payload: unknown) => loginBySmsCodeMock(payload),
|
||||
sendEmailCode: (payload: unknown) => sendEmailCodeMock(payload),
|
||||
@@ -127,6 +129,7 @@ describe('LoginPage', () => {
|
||||
getAuthCapabilitiesMock.mockReset()
|
||||
getOAuthAuthorizationUrlMock.mockReset()
|
||||
loginByPasswordMock.mockReset()
|
||||
verifyTOTPAfterPasswordLoginMock.mockReset()
|
||||
loginByEmailCodeMock.mockReset()
|
||||
loginBySmsCodeMock.mockReset()
|
||||
sendEmailCodeMock.mockReset()
|
||||
@@ -280,6 +283,49 @@ describe('LoginPage', () => {
|
||||
expect(navigateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds password login on a TOTP challenge and completes verification before creating a session', async () => {
|
||||
loginByPasswordMock.mockResolvedValue({
|
||||
requires_totp: true,
|
||||
user_id: 1,
|
||||
temp_token: 'totp-challenge-token',
|
||||
})
|
||||
verifyTOTPAfterPasswordLoginMock.mockResolvedValue(loginTokenBundle)
|
||||
|
||||
renderLoginPage('/login?redirect=/profile')
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
|
||||
target: { value: 'admin' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
|
||||
target: { value: 'SecurePass123!' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => expect(loginByPasswordMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(onLoginSuccessMock).not.toHaveBeenCalled()
|
||||
expect(screen.getByPlaceholderText('TOTP code')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('TOTP code'), {
|
||||
target: { value: '123456' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /verify totp/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(verifyTOTPAfterPasswordLoginMock).toHaveBeenCalledWith({
|
||||
user_id: 1,
|
||||
code: '123456',
|
||||
device_id: expect.any(String),
|
||||
temp_token: 'totp-challenge-token',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
|
||||
expect(navigateMock).toHaveBeenCalledWith('/profile', { replace: true })
|
||||
})
|
||||
|
||||
it('sends an email verification code and starts the resend countdown', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
loginBySmsCode,
|
||||
sendEmailCode,
|
||||
sendSmsCode,
|
||||
verifyTOTPAfterPasswordLogin,
|
||||
} from '@/services/auth'
|
||||
import type { AuthCapabilities, TokenBundle } from '@/types'
|
||||
import type { AuthCapabilities, PasswordLoginChallenge, PasswordLoginResponse, TokenBundle } from '@/types'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
|
||||
@@ -53,6 +54,19 @@ type SmsCodeFormValues = {
|
||||
code: string
|
||||
}
|
||||
|
||||
function isPasswordLoginChallenge(
|
||||
result: PasswordLoginResponse,
|
||||
): result is PasswordLoginChallenge {
|
||||
return (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'requires_totp' in result &&
|
||||
result.requires_totp === true &&
|
||||
typeof result.user_id === 'number' &&
|
||||
typeof result.temp_token === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const [activeTab, setActiveTab] = useState('password')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -60,6 +74,8 @@ export function LoginPage() {
|
||||
const [emailCountdown, setEmailCountdown] = useState(0)
|
||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
||||
const [pendingTOTP, setPendingTOTP] = useState<(PasswordLoginChallenge & { device_id?: string }) | null>(null)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [emailForm] = Form.useForm<EmailCodeFormValues>()
|
||||
const [smsForm] = Form.useForm<SmsCodeFormValues>()
|
||||
|
||||
@@ -151,6 +167,8 @@ export function LoginPage() {
|
||||
|
||||
const handlePasswordLogin = useCallback(async (values: LoginFormValues) => {
|
||||
setLoading(true)
|
||||
setPendingTOTP(null)
|
||||
setTotpCode('')
|
||||
try {
|
||||
const deviceInfo = getDeviceFingerprint()
|
||||
const tokenBundle = await loginByPassword({
|
||||
@@ -158,6 +176,17 @@ export function LoginPage() {
|
||||
password: values.password,
|
||||
...deviceInfo,
|
||||
})
|
||||
if (isPasswordLoginChallenge(tokenBundle)) {
|
||||
setPendingTOTP({
|
||||
...tokenBundle,
|
||||
device_id: deviceInfo.device_id,
|
||||
})
|
||||
setTotpCode('')
|
||||
return
|
||||
}
|
||||
|
||||
setPendingTOTP(null)
|
||||
setTotpCode('')
|
||||
await handleLoginSuccess(tokenBundle)
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '登录失败,请检查用户名和密码'))
|
||||
@@ -166,6 +195,29 @@ export function LoginPage() {
|
||||
}
|
||||
}, [handleLoginSuccess])
|
||||
|
||||
const handleTOTPVerification = useCallback(async () => {
|
||||
if (!pendingTOTP) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const tokenBundle = await verifyTOTPAfterPasswordLogin({
|
||||
user_id: pendingTOTP.user_id,
|
||||
code: totpCode,
|
||||
device_id: pendingTOTP.device_id,
|
||||
temp_token: pendingTOTP.temp_token,
|
||||
})
|
||||
setPendingTOTP(null)
|
||||
setTotpCode('')
|
||||
await handleLoginSuccess(tokenBundle)
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, 'TOTP verification failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleLoginSuccess, pendingTOTP, totpCode])
|
||||
|
||||
const handleSendEmailCode = useCallback(async () => {
|
||||
try {
|
||||
const values = await emailForm.validateFields(['email'])
|
||||
@@ -232,6 +284,33 @@ export function LoginPage() {
|
||||
key: 'password',
|
||||
label: '密码登录',
|
||||
children: (
|
||||
pendingTOTP ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="TOTP verification required"
|
||||
description="Enter the code from your authenticator app to finish signing in."
|
||||
/>
|
||||
<Input
|
||||
prefix={<SafetyOutlined />}
|
||||
placeholder="TOTP code"
|
||||
size="large"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
loading={loading}
|
||||
onClick={() => void handleTOTPVerification()}
|
||||
>
|
||||
Verify TOTP
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Form<LoginFormValues> layout="vertical" onFinish={handlePasswordLogin} autoComplete="off">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
@@ -255,6 +334,7 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -387,12 +467,15 @@ export function LoginPage() {
|
||||
emailForm,
|
||||
handleEmailCodeLogin,
|
||||
handlePasswordLogin,
|
||||
handleTOTPVerification,
|
||||
handleSendEmailCode,
|
||||
handleSendSmsCode,
|
||||
handleSmsCodeLogin,
|
||||
loading,
|
||||
pendingTOTP,
|
||||
smsCountdown,
|
||||
smsForm,
|
||||
totpCode,
|
||||
])
|
||||
|
||||
const currentTab = tabItems.find((item) => item.key === activeTab) ?? tabItems[0]
|
||||
|
||||
@@ -41,16 +41,13 @@ const defaultCapabilities: AuthCapabilities = {
|
||||
}
|
||||
|
||||
const activeRegisterResponse: RegisterResponse = {
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'new-user',
|
||||
email: 'new-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'New User',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
message: 'registered successfully',
|
||||
id: 2,
|
||||
username: 'new-user',
|
||||
email: 'new-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'New User',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
}
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
@@ -321,16 +318,13 @@ describe('RegisterPage', () => {
|
||||
email_activation: true,
|
||||
})
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 3,
|
||||
username: 'inactive-user',
|
||||
email: 'inactive-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'Inactive User',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, please check your email to activate the account',
|
||||
id: 3,
|
||||
username: 'inactive-user',
|
||||
email: 'inactive-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'Inactive User',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
@@ -350,16 +344,13 @@ describe('RegisterPage', () => {
|
||||
|
||||
it('shows the generic activation summary when the new inactive account has no email address', async () => {
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 4,
|
||||
username: 'inactive-without-email',
|
||||
email: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, activation required',
|
||||
id: 4,
|
||||
username: 'inactive-without-email',
|
||||
email: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
@@ -39,9 +39,9 @@ type RegisterFormValues = {
|
||||
}
|
||||
|
||||
function buildRegisterSummary(result: RegisterResponse) {
|
||||
if (result.user.status === 0) {
|
||||
if (result.user.email) {
|
||||
return `账号已创建,激活邮件会发送到 ${result.user.email}。请完成激活后再登录。`
|
||||
if (result.status === 0) {
|
||||
if (result.email) {
|
||||
return `账号已创建,激活邮件会发送到 ${result.email}。请完成激活后再登录。`
|
||||
}
|
||||
return '账号已创建,请按页面提示完成激活后再登录。'
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export function RegisterPage() {
|
||||
form.resetFields()
|
||||
setSmsCountdown(0)
|
||||
setSubmitted(result)
|
||||
message.success(result.user.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
|
||||
message.success(result.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '注册失败,请检查输入信息后重试'))
|
||||
} finally {
|
||||
@@ -137,7 +137,7 @@ export function RegisterPage() {
|
||||
}, [capabilities.sms_code, form])
|
||||
|
||||
if (submitted) {
|
||||
const activationEmail = submitted.user.email?.trim()
|
||||
const activationEmail = submitted.email?.trim()
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
@@ -146,7 +146,7 @@ export function RegisterPage() {
|
||||
title="注册成功"
|
||||
subTitle={(
|
||||
<Paragraph>
|
||||
<Text strong>{submitted.user.username}</Text>
|
||||
<Text strong>{submitted.username}</Text>
|
||||
{' '}
|
||||
{buildRegisterSummary(submitted)}
|
||||
</Paragraph>
|
||||
@@ -155,7 +155,7 @@ export function RegisterPage() {
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
submitted.user.status === 0 && activationEmail && capabilities.email_activation ? (
|
||||
submitted.status === 0 && activationEmail && capabilities.email_activation ? (
|
||||
<Link key="activation" to={`/activate-account?email=${encodeURIComponent(activationEmail)}`}>
|
||||
<Button>重新发送激活邮件</Button>
|
||||
</Link>
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('auth service', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('submits first-admin bootstrap without auth headers', async () => {
|
||||
it('submits first-admin bootstrap with the bootstrap secret header', async () => {
|
||||
const { bootstrapAdmin } = await import('./auth')
|
||||
|
||||
await bootstrapAdmin({
|
||||
@@ -114,7 +114,7 @@ describe('auth service', () => {
|
||||
password: 'Bootstrap123!@#',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
nickname: 'Bootstrap Admin',
|
||||
})
|
||||
}, 'bootstrap-secret')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/auth/bootstrap-admin',
|
||||
@@ -124,7 +124,13 @@ describe('auth service', () => {
|
||||
email: 'bootstrap_admin@example.com',
|
||||
nickname: 'Bootstrap Admin',
|
||||
},
|
||||
{ auth: false, credentials: 'include' },
|
||||
{
|
||||
auth: false,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-Bootstrap-Secret': 'bootstrap-secret',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
LoginByPasswordRequest,
|
||||
LoginBySmsCodeRequest,
|
||||
OAuthAuthorizationResponse,
|
||||
PasswordLoginResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
ResendActivationEmailRequest,
|
||||
@@ -37,8 +38,8 @@ export async function getAuthCapabilities(): Promise<AuthCapabilities> {
|
||||
return normalizeAuthCapabilities(capabilities)
|
||||
}
|
||||
|
||||
export function loginByPassword(data: LoginByPasswordRequest): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/login', data, { auth: false, credentials: 'include' })
|
||||
export function loginByPassword(data: LoginByPasswordRequest): Promise<PasswordLoginResponse> {
|
||||
return post<PasswordLoginResponse>('/auth/login', data, { auth: false, credentials: 'include' })
|
||||
}
|
||||
|
||||
// Verify TOTP after password login when requires_totp is returned
|
||||
@@ -58,8 +59,17 @@ export function register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
return post<RegisterResponse>('/auth/register', data, { auth: false })
|
||||
}
|
||||
|
||||
export function bootstrapAdmin(data: BootstrapAdminRequest): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/bootstrap-admin', data, { auth: false, credentials: 'include' })
|
||||
export function bootstrapAdmin(
|
||||
data: BootstrapAdminRequest,
|
||||
bootstrapSecret: string,
|
||||
): Promise<TokenBundle> {
|
||||
return post<TokenBundle>('/auth/bootstrap-admin', data, {
|
||||
auth: false,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-Bootstrap-Secret': bootstrapSecret,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function activateEmail(token: string): Promise<ActionMessageResponse> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Permission, 'type' | 'children'> {
|
||||
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<Permission[]> {
|
||||
return get<Permission[]>('/permissions/tree')
|
||||
return get<RawPermission[]>('/permissions/tree').then((permissions) => permissions.map(normalizePermission))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +64,7 @@ export function getPermissionTree(): Promise<Permission[]> {
|
||||
* GET /api/v1/permissions
|
||||
*/
|
||||
export function listPermissions(): Promise<Permission[]> {
|
||||
return get<Permission[]>('/permissions')
|
||||
return get<RawPermission[]>('/permissions').then((permissions) => permissions.map(normalizePermission))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +72,7 @@ export function listPermissions(): Promise<Permission[]> {
|
||||
* GET /api/v1/permissions/:id
|
||||
*/
|
||||
export function getPermission(id: number): Promise<Permission> {
|
||||
return get<Permission>(`/permissions/${id}`)
|
||||
return get<RawPermission>(`/permissions/${id}`).then(normalizePermission)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +80,10 @@ export function getPermission(id: number): Promise<Permission> {
|
||||
* POST /api/v1/permissions
|
||||
*/
|
||||
export function createPermission(data: CreatePermissionRequest): Promise<Permission> {
|
||||
return post<Permission>('/permissions', data)
|
||||
return post<RawPermission>('/permissions', {
|
||||
...data,
|
||||
type: serializePermissionType(data.type),
|
||||
}).then(normalizePermission)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +91,7 @@ export function createPermission(data: CreatePermissionRequest): Promise<Permiss
|
||||
* PUT /api/v1/permissions/:id
|
||||
*/
|
||||
export function updatePermission(id: number, data: UpdatePermissionRequest): Promise<Permission> {
|
||||
return put<Permission>(`/permissions/${id}`, data)
|
||||
return put<RawPermission>(`/permissions/${id}`, data).then(normalizePermission)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,10 @@ export function uploadAvatar(userId: number, file: File): Promise<AvatarUploadRe
|
||||
}
|
||||
|
||||
export function updatePassword(userId: number, data: UpdatePasswordRequest): Promise<void> {
|
||||
return put<void>(`/users/${userId}/password`, data)
|
||||
return put<void>(`/users/${userId}/password`, {
|
||||
old_password: data.current_password,
|
||||
new_password: data.new_password,
|
||||
})
|
||||
}
|
||||
|
||||
export function getTOTPStatus(): Promise<TOTPStatusResponse> {
|
||||
|
||||
@@ -24,6 +24,11 @@ describe('additional service adapters', () => {
|
||||
})
|
||||
|
||||
it('routes the remaining users service methods through the HTTP client', async () => {
|
||||
getMock
|
||||
.mockResolvedValueOnce({ items: [], total: 0, page: 2, page_size: 50 })
|
||||
.mockResolvedValueOnce({ id: 7 })
|
||||
.mockResolvedValueOnce([])
|
||||
|
||||
const {
|
||||
listUsers,
|
||||
getUser,
|
||||
@@ -69,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<string, unknown>) => {
|
||||
if (url === '/permissions') {
|
||||
return { id: 6, ...payload }
|
||||
}
|
||||
return { id: 5, ...payload }
|
||||
})
|
||||
putMock.mockImplementation(async (url: string, payload: Record<string, unknown>) => {
|
||||
if (url === '/permissions/6') {
|
||||
return { id: 6, ...payload, type: 0 }
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const {
|
||||
listRoles,
|
||||
@@ -151,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' })
|
||||
@@ -238,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 })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,14 +45,10 @@ export interface SystemSettings {
|
||||
features: FeaturesInfo
|
||||
}
|
||||
|
||||
interface SettingsResponse {
|
||||
data: SystemSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统设置
|
||||
* GET /api/v1/admin/settings
|
||||
*/
|
||||
export function getSettings(): Promise<SystemSettings> {
|
||||
return get<SettingsResponse>('/admin/settings').then(res => res.data)
|
||||
return get<SystemSettings>('/admin/settings')
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('social account service', () => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
delMock.mockReset()
|
||||
getMock.mockResolvedValue([])
|
||||
getMock.mockResolvedValue({ accounts: [] })
|
||||
postMock.mockResolvedValue({ auth_url: 'https://oauth.example.com', state: 'state-demo' })
|
||||
delMock.mockResolvedValue(undefined)
|
||||
})
|
||||
@@ -23,9 +23,31 @@ describe('social account service', () => {
|
||||
it('lists current user social accounts', async () => {
|
||||
const { listSocialAccounts } = await import('./social-accounts')
|
||||
|
||||
await listSocialAccounts()
|
||||
getMock.mockResolvedValue({
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'github',
|
||||
open_id: 'github-open-id',
|
||||
union_id: '',
|
||||
nickname: 'octocat',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
gender: 0,
|
||||
email: 'octocat@example.com',
|
||||
phone: '',
|
||||
extra: '{}',
|
||||
status: 1,
|
||||
created_at: '2026-03-27 20:00:00',
|
||||
updated_at: '2026-03-27 20:00:00',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const accounts = await listSocialAccounts()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/users/me/social-accounts')
|
||||
expect(accounts).toHaveLength(1)
|
||||
expect(accounts[0]).toMatchObject({ provider: 'github', nickname: 'octocat' })
|
||||
})
|
||||
|
||||
it('starts social binding with the current verification payload', async () => {
|
||||
|
||||
@@ -6,8 +6,14 @@ import type {
|
||||
SocialBindingStartResponse,
|
||||
} from '@/types'
|
||||
|
||||
interface SocialAccountsResponse {
|
||||
accounts: SocialAccountInfo[] | null
|
||||
}
|
||||
|
||||
export function listSocialAccounts(): Promise<SocialAccountInfo[]> {
|
||||
return get<SocialAccountInfo[]>('/users/me/social-accounts')
|
||||
return get<SocialAccountsResponse>('/users/me/social-accounts').then((result) => (
|
||||
Array.isArray(result.accounts) ? result.accounts : []
|
||||
))
|
||||
}
|
||||
|
||||
export function startSocialBinding(
|
||||
|
||||
@@ -32,4 +32,44 @@ describe('users service', () => {
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/users', payload)
|
||||
})
|
||||
|
||||
it('normalizes the legacy backend user list response', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 11,
|
||||
username: 'legacy-admin',
|
||||
email: 'legacy-admin@example.com',
|
||||
nickname: 'Legacy Admin',
|
||||
status: '1',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
offset: 20,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
const { listUsers } = await import('./users')
|
||||
const result = await listUsers({ page: 3, page_size: 10, keyword: 'legacy' })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/users', {
|
||||
page: 3,
|
||||
page_size: 10,
|
||||
keyword: 'legacy',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 11,
|
||||
username: 'legacy-admin',
|
||||
email: 'legacy-admin@example.com',
|
||||
nickname: 'Legacy Admin',
|
||||
status: '1',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 3,
|
||||
page_size: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,12 +17,44 @@ import type {
|
||||
AssignUserRolesRequest,
|
||||
} from '@/types/user'
|
||||
|
||||
interface LegacyUserListResponse {
|
||||
users: User[]
|
||||
total: number
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
function isLegacyUserListResponse(
|
||||
result: PaginatedData<User> | LegacyUserListResponse,
|
||||
): result is LegacyUserListResponse {
|
||||
return Array.isArray((result as LegacyUserListResponse).users)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* GET /api/v1/users
|
||||
*/
|
||||
export function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
|
||||
return get<PaginatedData<User>>('/users', params as Record<string, string | number | boolean | undefined>)
|
||||
export async function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
|
||||
const result = await get<PaginatedData<User> | LegacyUserListResponse>(
|
||||
'/users',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
|
||||
if (!isLegacyUserListResponse(result)) {
|
||||
return result
|
||||
}
|
||||
|
||||
const pageSize = result.limit ?? params.page_size
|
||||
const page = pageSize && pageSize > 0
|
||||
? Math.floor((result.offset ?? 0) / pageSize) + 1
|
||||
: params.page
|
||||
|
||||
return {
|
||||
items: result.users,
|
||||
total: result.total,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('webhooks service', () => {
|
||||
|
||||
it('normalizes mixed raw event payloads from the API', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: [
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'String Events',
|
||||
@@ -87,7 +87,22 @@ describe('webhooks service', () => {
|
||||
created_at: '2026-03-27 20:15:00',
|
||||
updated_at: '2026-03-27 20:15:00',
|
||||
})
|
||||
getMock.mockResolvedValue([])
|
||||
getMock.mockResolvedValue({
|
||||
deliveries: [
|
||||
{
|
||||
id: 7,
|
||||
webhook_id: 9,
|
||||
event_type: 'user.updated',
|
||||
payload: '{"id":1}',
|
||||
status_code: 200,
|
||||
response_body: 'ok',
|
||||
attempt: 1,
|
||||
success: true,
|
||||
error: '',
|
||||
created_at: '2026-03-27 20:20:00',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const {
|
||||
createWebhook,
|
||||
@@ -121,7 +136,9 @@ describe('webhooks service', () => {
|
||||
await deleteWebhook(9)
|
||||
expect(delMock).toHaveBeenCalledWith('/webhooks/9')
|
||||
|
||||
await getWebhookDeliveries(9, { limit: 20 })
|
||||
const deliveries = await getWebhookDeliveries(9, { limit: 20 })
|
||||
expect(getMock).toHaveBeenCalledWith('/webhooks/9/deliveries', { limit: 20 })
|
||||
expect(deliveries).toHaveLength(1)
|
||||
expect(deliveries[0]).toMatchObject({ webhook_id: 9, status_code: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,18 +32,25 @@ function normalizeWebhook(webhook: RawWebhook): Webhook {
|
||||
}
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
interface WebhookListResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
interface WebhookDeliveriesResponse {
|
||||
deliveries: WebhookDelivery[]
|
||||
}
|
||||
|
||||
export async function listWebhooks(
|
||||
params?: WebhookListParams,
|
||||
): Promise<{ data: Webhook[]; total: number; page: number; page_size: number }> {
|
||||
const result = await get<PaginatedResponse<RawWebhook>>('/webhooks', params as Record<string, string | number | boolean | undefined>)
|
||||
const webhooks = result.data.map(normalizeWebhook)
|
||||
const result = await get<WebhookListResponse<RawWebhook>>(
|
||||
'/webhooks',
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
const webhooks = result.list.map(normalizeWebhook)
|
||||
return { data: webhooks, total: result.total, page: result.page, page_size: result.page_size }
|
||||
}
|
||||
|
||||
@@ -67,8 +74,8 @@ export function getWebhookDeliveries(
|
||||
id: number,
|
||||
params?: WebhookDeliveryListParams,
|
||||
): Promise<WebhookDelivery[]> {
|
||||
return get<WebhookDelivery[]>(
|
||||
return get<WebhookDeliveriesResponse>(
|
||||
`/webhooks/${id}/deliveries`,
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
)
|
||||
).then((result) => result.deliveries)
|
||||
}
|
||||
|
||||
@@ -15,16 +15,21 @@ export interface TokenBundle {
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
user: SessionUser
|
||||
// TOTP required response (when user has TOTP enabled but device is not trusted)
|
||||
requires_totp?: boolean
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
// TOTP verification request after password login
|
||||
export interface PasswordLoginChallenge {
|
||||
requires_totp: true
|
||||
user_id: number
|
||||
temp_token: string
|
||||
}
|
||||
|
||||
export type PasswordLoginResponse = TokenBundle | PasswordLoginChallenge
|
||||
|
||||
export interface TOTPVerifyRequest {
|
||||
user_id: number
|
||||
code: string
|
||||
device_id?: string
|
||||
temp_token: string
|
||||
}
|
||||
|
||||
export interface OAuthProviderInfo {
|
||||
@@ -94,10 +99,7 @@ export interface BootstrapAdminRequest {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
user: SessionUser
|
||||
message: string
|
||||
}
|
||||
export type RegisterResponse = SessionUser
|
||||
|
||||
export interface ActionMessageResponse {
|
||||
message: string
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -15,6 +16,11 @@ import (
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshTokenCookieName = "ums_refresh_token"
|
||||
sessionPresenceCookieName = "ums_session_present"
|
||||
)
|
||||
|
||||
// newBackgroundCtx 创建用于后台 goroutine 的带超时独立 context(与请求 context 无关)
|
||||
func newBackgroundCtx(timeoutSec int) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||||
@@ -27,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
|
||||
@@ -35,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 用户注册新账号,支持用户名+密码或手机号注册
|
||||
@@ -129,6 +143,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
@@ -150,20 +165,28 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
// @Router /api/v1/auth/login/totp-verify [post]
|
||||
func (h *AuthHandler) VerifyTOTPAfterPasswordLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
TempToken string `json:"temp_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(c.Request.Context(), req.UserID, req.Code, req.DeviceID)
|
||||
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(
|
||||
c.Request.Context(),
|
||||
req.UserID,
|
||||
req.Code,
|
||||
req.DeviceID,
|
||||
req.TempToken,
|
||||
)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
@@ -197,6 +220,10 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
|
||||
}
|
||||
|
||||
username, _ := c.Get("username")
|
||||
usernameStr, _ := username.(string)
|
||||
|
||||
@@ -206,6 +233,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
}
|
||||
_ = h.authService.Logout(c.Request.Context(), usernameStr, logoutReq)
|
||||
|
||||
clearSessionCookies(c)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
@@ -222,19 +251,27 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
// @Router /api/v1/auth/refresh-token [post]
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refresh_token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
@@ -298,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",
|
||||
@@ -480,6 +518,7 @@ func (h *AuthHandler) LoginByEmailCode(c *gin.Context) {
|
||||
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
|
||||
}()
|
||||
}
|
||||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
@@ -544,6 +583,7 @@ func (h *AuthHandler) BootstrapAdmin(c *gin.Context) {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"code": 0,
|
||||
@@ -673,6 +713,50 @@ func getUserIDFromContext(c *gin.Context) (int64, bool) {
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func setSessionCookies(c *gin.Context, authService *service.AuthService, refreshToken string) {
|
||||
if c == nil || strings.TrimSpace(refreshToken) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := 0
|
||||
if authService != nil {
|
||||
if ttl := authService.RefreshTokenTTLSeconds(); ttl > 0 {
|
||||
maxAge = int(ttl)
|
||||
}
|
||||
}
|
||||
secure := requestUsesHTTPS(c)
|
||||
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(refreshTokenCookieName, refreshToken, maxAge, "/", "", secure, true)
|
||||
c.SetCookie(sessionPresenceCookieName, "1", maxAge, "/", "", secure, false)
|
||||
}
|
||||
|
||||
func clearSessionCookies(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
secure := requestUsesHTTPS(c)
|
||||
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(refreshTokenCookieName, "", -1, "/", "", secure, true)
|
||||
c.SetCookie(sessionPresenceCookieName, "", -1, "/", "", secure, false)
|
||||
}
|
||||
|
||||
func requestUsesHTTPS(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -31,6 +31,46 @@ import (
|
||||
|
||||
var handlerDbCounter int64
|
||||
|
||||
func seedHandlerAuthzData(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
roleIDs := make(map[string]int64)
|
||||
for _, predefined := range domain.PredefinedRoles {
|
||||
role := predefined
|
||||
if err := db.Create(&role).Error; err != nil {
|
||||
t.Fatalf("seed role %s failed: %v", role.Code, err)
|
||||
}
|
||||
roleIDs[role.Code] = role.ID
|
||||
}
|
||||
|
||||
permissionIDs := make(map[string]int64)
|
||||
for _, predefined := range domain.DefaultPermissions() {
|
||||
permission := predefined
|
||||
if err := db.Create(&permission).Error; err != nil {
|
||||
t.Fatalf("seed permission %s failed: %v", permission.Code, err)
|
||||
}
|
||||
permissionIDs[permission.Code] = permission.ID
|
||||
}
|
||||
|
||||
adminRoleID := roleIDs["admin"]
|
||||
for _, permissionID := range permissionIDs {
|
||||
if err := db.Create(&domain.RolePermission{RoleID: adminRoleID, PermissionID: permissionID}).Error; err != nil {
|
||||
t.Fatalf("assign admin permission %d failed: %v", permissionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
userRoleID := roleIDs["user"]
|
||||
for _, code := range []string{"profile:view", "profile:edit", "log:view_own"} {
|
||||
permissionID, ok := permissionIDs[code]
|
||||
if !ok {
|
||||
t.Fatalf("seeded permissions missing %s", code)
|
||||
}
|
||||
if err := db.Create(&domain.RolePermission{RoleID: userRoleID, PermissionID: permissionID}).Error; err != nil {
|
||||
t.Fatalf("assign user permission %s failed: %v", code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -64,6 +104,8 @@ func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Fatalf("db migration failed: %v", err)
|
||||
}
|
||||
|
||||
seedHandlerAuthzData(t, db)
|
||||
|
||||
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
|
||||
HS256Secret: "test-handler-secret-key",
|
||||
AccessTokenExpire: 15 * time.Minute,
|
||||
@@ -176,6 +218,18 @@ func doDelete(url, token string) (*http.Response, string) {
|
||||
return doRequest("DELETE", url, token, nil)
|
||||
}
|
||||
|
||||
func getCookie(resp *http.Response, name string) *http.Cookie {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getToken(baseURL, username, password string) string {
|
||||
resp, body := doPost(baseURL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": username,
|
||||
@@ -207,6 +261,111 @@ func registerUser(baseURL, username, email, password string) bool {
|
||||
return resp.StatusCode == http.StatusCreated
|
||||
}
|
||||
|
||||
func bootstrapAdmin(baseURL, secret, username, email, password string) string {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/bootstrap-admin", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Bootstrap-Secret", secret)
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return ""
|
||||
}
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok || data["access_token"] == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
token, _ := data["access_token"].(string)
|
||||
return token
|
||||
}
|
||||
|
||||
func setupEnabledTOTPUser(t *testing.T, baseURL, username, email, password string) (int64, string) {
|
||||
t.Helper()
|
||||
|
||||
if ok := registerUser(baseURL, username, email, password); !ok {
|
||||
t.Fatalf("registration failed for %s", username)
|
||||
}
|
||||
|
||||
token := getToken(baseURL, username, password)
|
||||
if token == "" {
|
||||
t.Fatalf("failed to get token for %s", username)
|
||||
}
|
||||
|
||||
userInfoResp, userInfoBody := doGet(baseURL+"/api/v1/auth/userinfo", token)
|
||||
defer userInfoResp.Body.Close()
|
||||
if userInfoResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("userinfo failed: status=%d body=%s", userInfoResp.StatusCode, userInfoBody)
|
||||
}
|
||||
|
||||
var userInfoResult map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(userInfoBody), &userInfoResult); err != nil {
|
||||
t.Fatalf("failed to parse userinfo response: %v", err)
|
||||
}
|
||||
userData, ok := userInfoResult["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("userinfo response missing data: %s", userInfoBody)
|
||||
}
|
||||
userID, ok := userData["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("userinfo response missing id: %s", userInfoBody)
|
||||
}
|
||||
|
||||
setupResp, setupBody := doGet(baseURL+"/api/v1/auth/2fa/setup", token)
|
||||
defer setupResp.Body.Close()
|
||||
if setupResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("2fa setup failed: status=%d body=%s", setupResp.StatusCode, setupBody)
|
||||
}
|
||||
|
||||
var setupResult map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(setupBody), &setupResult); err != nil {
|
||||
t.Fatalf("failed to parse 2fa setup response: %v", err)
|
||||
}
|
||||
setupData, ok := setupResult["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("2fa setup response missing data: %s", setupBody)
|
||||
}
|
||||
secret, ok := setupData["secret"].(string)
|
||||
if !ok || secret == "" {
|
||||
t.Fatalf("2fa setup response missing secret: %s", setupBody)
|
||||
}
|
||||
|
||||
code, err := auth.NewTOTPManager().GenerateCurrentCode(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate TOTP code: %v", err)
|
||||
}
|
||||
|
||||
enableResp, enableBody := doPost(baseURL+"/api/v1/auth/2fa/enable", token, map[string]interface{}{
|
||||
"code": code,
|
||||
})
|
||||
defer enableResp.Body.Close()
|
||||
if enableResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("2fa enable failed: status=%d body=%s", enableResp.StatusCode, enableBody)
|
||||
}
|
||||
|
||||
return int64(userID), secret
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth Handler Tests
|
||||
// =============================================================================
|
||||
@@ -292,6 +451,38 @@ func TestAuthHandler_Login_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_SetsSessionCookies(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "logincookieuser", "logincookie@example.com", "Password123!")
|
||||
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "logincookieuser",
|
||||
"password": "Password123!",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
refreshCookie := getCookie(resp, "ums_refresh_token")
|
||||
if refreshCookie == nil || refreshCookie.Value == "" {
|
||||
t.Fatalf("login response missing refresh cookie, cookies=%v", resp.Cookies())
|
||||
}
|
||||
if !refreshCookie.HttpOnly {
|
||||
t.Fatalf("refresh cookie should be HttpOnly, got %+v", refreshCookie)
|
||||
}
|
||||
|
||||
presenceCookie := getCookie(resp, "ums_session_present")
|
||||
if presenceCookie == nil || presenceCookie.Value != "1" {
|
||||
t.Fatalf("login response missing presence cookie, cookies=%v", resp.Cookies())
|
||||
}
|
||||
if presenceCookie.HttpOnly {
|
||||
t.Fatalf("presence cookie should be readable by the frontend, got %+v", presenceCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
@@ -358,6 +549,74 @@ 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) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, _ = setupEnabledTOTPUser(t, server.URL, "totplogin", "totplogin@example.com", "Password123!")
|
||||
|
||||
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "totplogin",
|
||||
"password": "Password123!",
|
||||
"device_id": "device-login-1",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||
t.Fatalf("failed to parse login response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected login response data, got %s", body)
|
||||
}
|
||||
|
||||
if data["requires_totp"] != true {
|
||||
t.Fatalf("expected requires_totp=true, got %+v", data)
|
||||
}
|
||||
|
||||
tempToken, ok := data["temp_token"].(string)
|
||||
if !ok || tempToken == "" {
|
||||
t.Fatalf("expected temp_token in TOTP challenge response, got %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_VerifyTOTPAfterPasswordLogin_RequiresTempToken(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpreverify", "totpreverify@example.com", "Password123!")
|
||||
|
||||
code, err := auth.NewTOTPManager().GenerateCurrentCode(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate TOTP code: %v", err)
|
||||
}
|
||||
|
||||
resp, body := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"code": code,
|
||||
"device_id": "device-login-1",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d when temp_token is missing, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -451,6 +710,26 @@ func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_AdminCanUpdateAnotherUser(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret")
|
||||
token := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "updateadmin", "updateadmin@test.com", "AdminPass123!")
|
||||
registerUser(server.URL, "targetuser", "targetuser@test.com", "UserPass123!")
|
||||
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin should return access token")
|
||||
}
|
||||
|
||||
resp, body := doPut(server.URL+"/api/v1/users/2", token, map[string]string{"nickname": "Updated By Admin"})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_DeleteUser_NonAdmin_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
@@ -515,6 +794,26 @@ func TestUserHandler_GetUserRoles_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_GetUserRoles_AdminCanViewAnotherUser(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret")
|
||||
token := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "rolesadmin2", "rolesadmin2@test.com", "AdminPass123!")
|
||||
registerUser(server.URL, "roles-target", "roles-target@test.com", "UserPass123!")
|
||||
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin should return access token")
|
||||
}
|
||||
|
||||
resp, body := doGet(server.URL+"/api/v1/users/2/roles", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_AssignRoles_RequiresAdmin(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
@@ -714,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
|
||||
// =============================================================================
|
||||
@@ -1253,6 +1665,187 @@ func TestAuthHandler_RefreshToken_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RefreshToken_AcceptsRefreshCookie(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "refreshcookieuser", "refreshcookie@example.com", "Password123!")
|
||||
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "refreshcookieuser",
|
||||
"password": "Password123!",
|
||||
})
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
if loginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, loginResp.StatusCode, loginBody)
|
||||
}
|
||||
|
||||
refreshCookie := getCookie(loginResp, "ums_refresh_token")
|
||||
if refreshCookie == nil || refreshCookie.Value == "" {
|
||||
t.Fatalf("login response missing refresh cookie, cookies=%v", loginResp.Cookies())
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", server.URL+"/api/v1/auth/refresh", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create refresh request failed: %v", err)
|
||||
}
|
||||
req.AddCookie(refreshCookie)
|
||||
req.AddCookie(&http.Cookie{Name: "ums_session_present", Value: "1"})
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read refresh response failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
rotatedCookie := getCookie(resp, "ums_refresh_token")
|
||||
if rotatedCookie == nil || rotatedCookie.Value == "" {
|
||||
t.Fatalf("refresh response missing rotated refresh cookie, cookies=%v", resp.Cookies())
|
||||
}
|
||||
if rotatedCookie.Value == refreshCookie.Value {
|
||||
t.Fatalf("refresh should rotate cookie value, old=%q new=%q", refreshCookie.Value, rotatedCookie.Value)
|
||||
}
|
||||
|
||||
presenceCookie := getCookie(resp, "ums_session_present")
|
||||
if presenceCookie == nil || presenceCookie.Value != "1" {
|
||||
t.Fatalf("refresh response missing presence cookie, cookies=%v", resp.Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RefreshToken_AllowsImmediateRetryWithPreviousCookie(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "refreshretryuser", "refreshretry@example.com", "Password123!")
|
||||
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "refreshretryuser",
|
||||
"password": "Password123!",
|
||||
})
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
if loginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, loginResp.StatusCode, loginBody)
|
||||
}
|
||||
|
||||
refreshCookie := getCookie(loginResp, "ums_refresh_token")
|
||||
if refreshCookie == nil || refreshCookie.Value == "" {
|
||||
t.Fatalf("login response missing refresh cookie, cookies=%v", loginResp.Cookies())
|
||||
}
|
||||
|
||||
newRefreshRequest := func(cookie *http.Cookie) *http.Response {
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/auth/refresh", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create refresh request failed: %v", err)
|
||||
}
|
||||
req.AddCookie(cookie)
|
||||
req.AddCookie(&http.Cookie{Name: "ums_session_present", Value: "1"})
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh request failed: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
firstResp := newRefreshRequest(refreshCookie)
|
||||
defer firstResp.Body.Close()
|
||||
firstBody, err := io.ReadAll(firstResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read first refresh response failed: %v", err)
|
||||
}
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected first refresh status %d, got %d, body: %s", http.StatusOK, firstResp.StatusCode, string(firstBody))
|
||||
}
|
||||
|
||||
retryResp := newRefreshRequest(refreshCookie)
|
||||
defer retryResp.Body.Close()
|
||||
retryBody, err := io.ReadAll(retryResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read retry refresh response failed: %v", err)
|
||||
}
|
||||
if retryResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected retry refresh status %d, got %d, body: %s", http.StatusOK, retryResp.StatusCode, string(retryBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Logout_ClearsSessionCookies(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "logoutcookieuser", "logoutcookie@example.com", "Password123!")
|
||||
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "logoutcookieuser",
|
||||
"password": "Password123!",
|
||||
})
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
if loginResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, loginResp.StatusCode, loginBody)
|
||||
}
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(loginBody), &loginResult); err != nil {
|
||||
t.Fatalf("parse login response failed: %v", err)
|
||||
}
|
||||
loginData, ok := loginResult["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("login response missing data: %s", loginBody)
|
||||
}
|
||||
accessToken, ok := loginData["access_token"].(string)
|
||||
if !ok || accessToken == "" {
|
||||
t.Fatalf("login response missing access token: %s", loginBody)
|
||||
}
|
||||
|
||||
refreshCookie := getCookie(loginResp, "ums_refresh_token")
|
||||
if refreshCookie == nil || refreshCookie.Value == "" {
|
||||
t.Fatalf("login response missing refresh cookie, cookies=%v", loginResp.Cookies())
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", server.URL+"/api/v1/auth/logout", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create logout request failed: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.AddCookie(refreshCookie)
|
||||
req.AddCookie(&http.Cookie{Name: "ums_session_present", Value: "1"})
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("logout request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read logout response failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
clearedRefreshCookie := getCookie(resp, "ums_refresh_token")
|
||||
if clearedRefreshCookie == nil || clearedRefreshCookie.Value != "" {
|
||||
t.Fatalf("logout response should clear refresh cookie, cookies=%v", resp.Cookies())
|
||||
}
|
||||
|
||||
clearedPresenceCookie := getCookie(resp, "ums_session_present")
|
||||
if clearedPresenceCookie == nil || clearedPresenceCookie.Value != "" {
|
||||
t.Fatalf("logout response should clear presence cookie, cookies=%v", resp.Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RefreshToken_InvalidToken(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -116,6 +116,7 @@ func (h *SMSHandler) LoginByCode(c *gin.Context) {
|
||||
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
|
||||
}()
|
||||
}
|
||||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/api/middleware"
|
||||
"github.com/user-management-system/internal/auth"
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/service"
|
||||
@@ -187,15 +188,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
|
||||
// Authorization: only self or admin can update user profile
|
||||
currentUserID := c.GetInt64("user_id")
|
||||
isAdmin := false
|
||||
if roles, ok := c.Get("user_roles"); ok {
|
||||
for _, role := range roles.([]*domain.Role) {
|
||||
if role.Code == "admin" {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
isAdmin := middleware.IsAdmin(c)
|
||||
if currentUserID != id && !isAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"})
|
||||
return
|
||||
@@ -370,15 +363,7 @@ func (h *UserHandler) GetUserRoles(c *gin.Context) {
|
||||
|
||||
// Authorization: only self or admin can view user roles
|
||||
currentUserID := c.GetInt64("user_id")
|
||||
isAdmin := false
|
||||
if roles, ok := c.Get("user_roles"); ok {
|
||||
for _, role := range roles.([]*domain.Role) {
|
||||
if role.Code == "admin" {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
isAdmin := middleware.IsAdmin(c)
|
||||
if currentUserID != id && !isAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"})
|
||||
return
|
||||
|
||||
103
internal/api/middleware/auth_bootstrap_test.go
Normal file
103
internal/api/middleware/auth_bootstrap_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/user-management-system/internal/auth"
|
||||
"github.com/user-management-system/internal/cache"
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/repository"
|
||||
"github.com/user-management-system/internal/service"
|
||||
gormsqlite "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestAuthMiddleware_AcceptsBootstrapAdminTokenImmediately(t *testing.T) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
||||
DriverName: "sqlite",
|
||||
DSN: "file:middleware_bootstrap_test?mode=memory&cache=shared",
|
||||
}), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&domain.User{}, &domain.Role{}, &domain.UserRole{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&domain.Role{
|
||||
Name: "管理员",
|
||||
Code: "admin",
|
||||
IsSystem: true,
|
||||
Status: domain.RoleStatusEnabled,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed admin role failed: %v", err)
|
||||
}
|
||||
|
||||
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
|
||||
HS256Secret: "test-bootstrap-token-secret-at-least-32-chars",
|
||||
AccessTokenExpire: 15 * time.Minute,
|
||||
RefreshTokenExpire: 7 * 24 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create jwt manager failed: %v", err)
|
||||
}
|
||||
|
||||
l1Cache := cache.NewL1Cache()
|
||||
l2Cache := cache.NewRedisCache(false)
|
||||
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
roleRepo := repository.NewRoleRepository(db)
|
||||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||||
|
||||
authService := service.NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
|
||||
authService.SetRoleRepositories(userRoleRepo, roleRepo)
|
||||
|
||||
loginResponse, err := authService.BootstrapAdmin(context.Background(), &service.BootstrapAdminRequest{
|
||||
Username: "bootstrap_admin",
|
||||
Email: "bootstrap_admin@example.com",
|
||||
Password: "AdminPass123!",
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap admin failed: %v", err)
|
||||
}
|
||||
if loginResponse == nil || loginResponse.AccessToken == "" {
|
||||
t.Fatalf("expected bootstrap access token, got %+v", loginResponse)
|
||||
}
|
||||
|
||||
if _, err := jwtManager.ValidateAccessToken(loginResponse.AccessToken); err != nil {
|
||||
t.Fatalf("bootstrap access token should validate immediately: %v", err)
|
||||
}
|
||||
|
||||
authMiddleware := NewAuthMiddleware(jwtManager, userRepo, userRoleRepo, l1Cache)
|
||||
authMiddleware.SetCacheManager(cacheManager)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, engine := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
ctx.Request.Header.Set("Authorization", "Bearer "+loginResponse.AccessToken)
|
||||
|
||||
engine.Use(authMiddleware.Required())
|
||||
engine.GET("/protected", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||
})
|
||||
|
||||
engine.ServeHTTP(recorder, ctx.Request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected bootstrap token to pass auth middleware immediately, got %d body: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/config"
|
||||
)
|
||||
|
||||
// RateLimitMiddleware 限流中间件
|
||||
// RateLimitMiddleware provides simple in-memory sliding-window rate limiting.
|
||||
type RateLimitMiddleware struct {
|
||||
cfg config.RateLimitConfig
|
||||
limiters map[string]*SlidingWindowLimiter
|
||||
@@ -16,7 +23,7 @@ type RateLimitMiddleware struct {
|
||||
cleanupInt time.Duration
|
||||
}
|
||||
|
||||
// SlidingWindowLimiter 滑动窗口限流器
|
||||
// SlidingWindowLimiter enforces a fixed-capacity sliding window.
|
||||
type SlidingWindowLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
@@ -24,7 +31,6 @@ type SlidingWindowLimiter struct {
|
||||
requests []int64
|
||||
}
|
||||
|
||||
// NewSlidingWindowLimiter 创建滑动窗口限流器
|
||||
func NewSlidingWindowLimiter(window time.Duration, capacity int64) *SlidingWindowLimiter {
|
||||
return &SlidingWindowLimiter{
|
||||
window: window,
|
||||
@@ -33,7 +39,6 @@ func NewSlidingWindowLimiter(window time.Duration, capacity int64) *SlidingWindo
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
func (l *SlidingWindowLimiter) Allow() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -41,16 +46,14 @@ func (l *SlidingWindowLimiter) Allow() bool {
|
||||
now := time.Now().UnixMilli()
|
||||
cutoff := now - l.window.Milliseconds()
|
||||
|
||||
// 清理过期请求
|
||||
var validRequests []int64
|
||||
for _, t := range l.requests {
|
||||
if t > cutoff {
|
||||
validRequests = append(validRequests, t)
|
||||
validRequests := make([]int64, 0, len(l.requests))
|
||||
for _, ts := range l.requests {
|
||||
if ts > cutoff {
|
||||
validRequests = append(validRequests, ts)
|
||||
}
|
||||
}
|
||||
l.requests = validRequests
|
||||
|
||||
// 检查容量
|
||||
if int64(len(l.requests)) >= l.capacity {
|
||||
return false
|
||||
}
|
||||
@@ -59,7 +62,6 @@ func (l *SlidingWindowLimiter) Allow() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NewRateLimitMiddleware 创建限流中间件
|
||||
func NewRateLimitMiddleware(cfg config.RateLimitConfig) *RateLimitMiddleware {
|
||||
return &RateLimitMiddleware{
|
||||
cfg: cfg,
|
||||
@@ -68,30 +70,28 @@ func NewRateLimitMiddleware(cfg config.RateLimitConfig) *RateLimitMiddleware {
|
||||
}
|
||||
}
|
||||
|
||||
// Register 返回注册接口的限流中间件
|
||||
func (m *RateLimitMiddleware) Register() gin.HandlerFunc {
|
||||
return m.limitForKey("register", 60, 10)
|
||||
}
|
||||
|
||||
// Login 返回登录接口的限流中间件
|
||||
func (m *RateLimitMiddleware) Login() gin.HandlerFunc {
|
||||
return m.limitForKey("login", 60, 5)
|
||||
}
|
||||
|
||||
// API 返回 API 接口的限流中间件
|
||||
func (m *RateLimitMiddleware) API() gin.HandlerFunc {
|
||||
return m.limitForKey("api", 60, 100)
|
||||
}
|
||||
|
||||
// Refresh 返回刷新令牌的限流中间件
|
||||
func (m *RateLimitMiddleware) Refresh() gin.HandlerFunc {
|
||||
return m.limitForKey("refresh", 60, 10)
|
||||
}
|
||||
|
||||
func (m *RateLimitMiddleware) limitForKey(key string, windowSeconds int, capacity int64) gin.HandlerFunc {
|
||||
limiter := m.getOrCreateLimiter(key, time.Duration(windowSeconds)*time.Second, capacity)
|
||||
func (m *RateLimitMiddleware) limitForKey(bucket string, windowSeconds int, capacity int64) gin.HandlerFunc {
|
||||
window := time.Duration(windowSeconds) * time.Second
|
||||
|
||||
return func(c *gin.Context) {
|
||||
limiterKey := m.resolveLimiterKey(c, bucket)
|
||||
limiter := m.getOrCreateLimiter(limiterKey, window, capacity)
|
||||
if !limiter.Allow() {
|
||||
c.JSON(429, gin.H{
|
||||
"code": 429,
|
||||
@@ -104,6 +104,81 @@ func (m *RateLimitMiddleware) limitForKey(key string, windowSeconds int, capacit
|
||||
}
|
||||
}
|
||||
|
||||
func (m *RateLimitMiddleware) resolveLimiterKey(c *gin.Context, bucket string) string {
|
||||
if bucket == "refresh" {
|
||||
if refreshToken := extractRefreshToken(c); refreshToken != "" {
|
||||
return fmt.Sprintf("%s:token:%s", bucket, fingerprintValue(refreshToken))
|
||||
}
|
||||
}
|
||||
|
||||
identity := "anonymous"
|
||||
if c != nil {
|
||||
if userID, ok := c.Get("user_id"); ok {
|
||||
identity = fmt.Sprintf("user:%v", userID)
|
||||
} else if ip := c.ClientIP(); ip != "" {
|
||||
identity = "ip:" + ip
|
||||
}
|
||||
}
|
||||
|
||||
if bucket == "api" {
|
||||
method := ""
|
||||
route := ""
|
||||
if c != nil {
|
||||
if c.Request != nil {
|
||||
method = c.Request.Method
|
||||
if c.Request.URL != nil {
|
||||
route = c.Request.URL.Path
|
||||
}
|
||||
}
|
||||
if fullPath := c.FullPath(); fullPath != "" {
|
||||
route = fullPath
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:%s:%s", bucket, method, route, identity)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s:%s", bucket, identity)
|
||||
}
|
||||
|
||||
func extractRefreshToken(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if refreshToken, err := c.Cookie("ums_refresh_token"); err == nil && refreshToken != "" {
|
||||
return refreshToken
|
||||
}
|
||||
|
||||
if c.Request == nil || c.Request.Body == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return payload.RefreshToken
|
||||
}
|
||||
|
||||
func fingerprintValue(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func (m *RateLimitMiddleware) getOrCreateLimiter(key string, window time.Duration, capacity int64) *SlidingWindowLimiter {
|
||||
m.mu.RLock()
|
||||
limiter, exists := m.limiters[key]
|
||||
@@ -116,7 +191,6 @@ func (m *RateLimitMiddleware) getOrCreateLimiter(key string, window time.Duratio
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 双重检查
|
||||
if limiter, exists = m.limiters[key]; exists {
|
||||
return limiter
|
||||
}
|
||||
|
||||
140
internal/api/middleware/ratelimit_test.go
Normal file
140
internal/api/middleware/ratelimit_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/user-management-system/internal/config"
|
||||
)
|
||||
|
||||
func performRateLimitedRequest(router *gin.Engine, path string, userID int64) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
req.Header.Set("X-Test-User-ID", strconv.FormatInt(userID, 10))
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func performRefreshRateLimitedRequestWithCookie(router *gin.Engine, refreshToken string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if refreshToken != "" {
|
||||
req.AddCookie(&http.Cookie{Name: "ums_refresh_token", Value: refreshToken})
|
||||
}
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func performRefreshRateLimitedRequestWithBody(router *gin.Engine, refreshToken string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"refresh_token":"` + refreshToken + `"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", body)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware_API_ScopesBudgetByRouteForAuthenticatedUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rateLimitMiddleware := NewRateLimitMiddleware(config.RateLimitConfig{})
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
rawUserID := c.GetHeader("X-Test-User-ID")
|
||||
if rawUserID != "" {
|
||||
userID, err := strconv.ParseInt(rawUserID, 10, 64)
|
||||
if err == nil {
|
||||
c.Set("user_id", userID)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
protected := router.Group("")
|
||||
protected.Use(rateLimitMiddleware.API())
|
||||
protected.GET("/users", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
protected.GET("/roles", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
recorder := performRateLimitedRequest(router, "/users", 1)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("request %d to /users returned %d, want %d", i+1, recorder.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
sameRouteOverflow := performRateLimitedRequest(router, "/users", 1)
|
||||
if sameRouteOverflow.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("overflow request to /users returned %d, want %d", sameRouteOverflow.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
differentRoute := performRateLimitedRequest(router, "/roles", 1)
|
||||
if differentRoute.Code != http.StatusOK {
|
||||
t.Fatalf("request to /roles after exhausting /users budget returned %d, want %d", differentRoute.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware_Refresh_ScopesBudgetByRefreshCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rateLimitMiddleware := NewRateLimitMiddleware(config.RateLimitConfig{})
|
||||
router := gin.New()
|
||||
router.POST("/auth/refresh", rateLimitMiddleware.Refresh(), func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
recorder := performRefreshRateLimitedRequestWithCookie(router, "refresh-token-a")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("request %d for refresh-token-a returned %d, want %d", i+1, recorder.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
sameTokenOverflow := performRefreshRateLimitedRequestWithCookie(router, "refresh-token-a")
|
||||
if sameTokenOverflow.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("overflow request for refresh-token-a returned %d, want %d", sameTokenOverflow.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
differentToken := performRefreshRateLimitedRequestWithCookie(router, "refresh-token-b")
|
||||
if differentToken.Code != http.StatusOK {
|
||||
t.Fatalf("request for refresh-token-b after exhausting refresh-token-a budget returned %d, want %d", differentToken.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware_Refresh_ScopesBudgetByRefreshTokenBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rateLimitMiddleware := NewRateLimitMiddleware(config.RateLimitConfig{})
|
||||
router := gin.New()
|
||||
router.POST("/auth/refresh", rateLimitMiddleware.Refresh(), func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
recorder := performRefreshRateLimitedRequestWithBody(router, "refresh-token-a")
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("request %d for refresh-token-a body returned %d, want %d", i+1, recorder.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
sameTokenOverflow := performRefreshRateLimitedRequestWithBody(router, "refresh-token-a")
|
||||
if sameTokenOverflow.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("overflow request for refresh-token-a body returned %d, want %d", sameTokenOverflow.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
differentToken := performRefreshRateLimitedRequestWithBody(router, "refresh-token-b")
|
||||
if differentToken.Code != http.StatusOK {
|
||||
t.Fatalf("request for refresh-token-b body after exhausting refresh-token-a budget returned %d, want %d", differentToken.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -43,10 +45,12 @@ func Serve(cfg *config.Config) error {
|
||||
// P1-3:Argon2id 启动时自适应校准
|
||||
auth.CalibrateArgon2id(500 * time.Millisecond)
|
||||
|
||||
accessTokenExpire := resolveJWTAccessTokenExpire(cfg)
|
||||
|
||||
// 初始化 JWT 管理器
|
||||
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
|
||||
HS256Secret: cfg.JWT.Secret,
|
||||
AccessTokenExpire: time.Duration(cfg.JWT.AccessTokenExpireMinutes) * time.Minute,
|
||||
AccessTokenExpire: accessTokenExpire,
|
||||
RefreshTokenExpire: time.Duration(cfg.JWT.RefreshTokenExpireDays) * 24 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -125,6 +129,9 @@ func Serve(cfg *config.Config) error {
|
||||
totpService := service.NewTOTPService(userRepo)
|
||||
|
||||
passwordResetConfig := service.DefaultPasswordResetConfig()
|
||||
if err := configureAuthEmailServices(cfg, cacheManager, authService, passwordResetConfig); err != nil {
|
||||
return fmt.Errorf("configure auth email services failed: %w", err)
|
||||
}
|
||||
passwordResetService := service.NewPasswordResetService(userRepo, cacheManager, passwordResetConfig).
|
||||
WithPasswordHistoryRepo(passwordHistoryRepo)
|
||||
|
||||
@@ -259,3 +266,100 @@ func resolveGinMode(mode string) string {
|
||||
return gin.ReleaseMode
|
||||
}
|
||||
}
|
||||
|
||||
func configureAuthEmailServices(
|
||||
cfg *config.Config,
|
||||
cacheManager *cache.CacheManager,
|
||||
authService *service.AuthService,
|
||||
passwordResetConfig *service.PasswordResetConfig,
|
||||
) error {
|
||||
smtpConfig, enabled, err := resolveSMTPEmailConfigFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled || cacheManager == nil || authService == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
siteURL := resolveAuthEmailSiteURL(cfg)
|
||||
siteName := resolveAuthEmailSiteName(cfg)
|
||||
provider := service.NewSMTPEmailProvider(smtpConfig)
|
||||
|
||||
authService.SetEmailActivationService(
|
||||
service.NewEmailActivationService(provider, cacheManager, siteURL, siteName),
|
||||
)
|
||||
|
||||
emailCodeConfig := service.DefaultEmailCodeConfig()
|
||||
emailCodeConfig.SiteURL = siteURL
|
||||
emailCodeConfig.SiteName = siteName
|
||||
authService.SetEmailCodeService(service.NewEmailCodeService(provider, cacheManager, emailCodeConfig))
|
||||
|
||||
if passwordResetConfig != nil {
|
||||
passwordResetConfig.SMTPHost = smtpConfig.Host
|
||||
passwordResetConfig.SMTPPort = smtpConfig.Port
|
||||
passwordResetConfig.SMTPUser = smtpConfig.Username
|
||||
passwordResetConfig.SMTPPass = smtpConfig.Password
|
||||
passwordResetConfig.FromEmail = smtpConfig.FromEmail
|
||||
passwordResetConfig.SiteURL = siteURL
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSMTPEmailConfigFromEnv() (service.SMTPEmailConfig, bool, error) {
|
||||
host := strings.TrimSpace(os.Getenv("EMAIL_HOST"))
|
||||
if host == "" {
|
||||
return service.SMTPEmailConfig{}, false, nil
|
||||
}
|
||||
|
||||
port := 587
|
||||
if rawPort := strings.TrimSpace(os.Getenv("EMAIL_PORT")); rawPort != "" {
|
||||
parsedPort, err := strconv.Atoi(rawPort)
|
||||
if err != nil || parsedPort <= 0 {
|
||||
return service.SMTPEmailConfig{}, false, fmt.Errorf("invalid EMAIL_PORT %q", rawPort)
|
||||
}
|
||||
port = parsedPort
|
||||
}
|
||||
|
||||
fromEmail := strings.TrimSpace(os.Getenv("EMAIL_FROM_EMAIL"))
|
||||
if fromEmail == "" {
|
||||
fromEmail = service.DefaultPasswordResetConfig().FromEmail
|
||||
}
|
||||
|
||||
return service.SMTPEmailConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: strings.TrimSpace(os.Getenv("EMAIL_USER")),
|
||||
Password: os.Getenv("EMAIL_PASS"),
|
||||
FromEmail: fromEmail,
|
||||
FromName: strings.TrimSpace(os.Getenv("EMAIL_FROM_NAME")),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func resolveAuthEmailSiteURL(cfg *config.Config) string {
|
||||
if cfg != nil {
|
||||
if siteURL := strings.TrimSpace(cfg.Server.FrontendURL); siteURL != "" {
|
||||
return siteURL
|
||||
}
|
||||
}
|
||||
return service.DefaultEmailCodeConfig().SiteURL
|
||||
}
|
||||
|
||||
func resolveAuthEmailSiteName(cfg *config.Config) string {
|
||||
if cfg != nil {
|
||||
if siteName := strings.TrimSpace(cfg.Log.ServiceName); siteName != "" {
|
||||
return siteName
|
||||
}
|
||||
}
|
||||
return service.DefaultEmailCodeConfig().SiteName
|
||||
}
|
||||
|
||||
func resolveJWTAccessTokenExpire(cfg *config.Config) time.Duration {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
if cfg.JWT.AccessTokenExpireMinutes > 0 {
|
||||
return time.Duration(cfg.JWT.AccessTokenExpireMinutes) * time.Minute
|
||||
}
|
||||
return time.Duration(cfg.JWT.ExpireHour) * time.Hour
|
||||
}
|
||||
|
||||
73
internal/server/server_test.go
Normal file
73
internal/server/server_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/user-management-system/internal/cache"
|
||||
"github.com/user-management-system/internal/config"
|
||||
"github.com/user-management-system/internal/service"
|
||||
)
|
||||
|
||||
func TestResolveJWTAccessTokenExpire_UsesExpireHourFallback(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.JWT.ExpireHour = 24
|
||||
cfg.JWT.AccessTokenExpireMinutes = 0
|
||||
|
||||
expire := resolveJWTAccessTokenExpire(cfg)
|
||||
|
||||
if expire != 24*time.Hour {
|
||||
t.Fatalf("resolveJWTAccessTokenExpire() = %v, want %v", expire, 24*time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveJWTAccessTokenExpire_PrefersMinuteOverride(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.JWT.ExpireHour = 24
|
||||
cfg.JWT.AccessTokenExpireMinutes = 90
|
||||
|
||||
expire := resolveJWTAccessTokenExpire(cfg)
|
||||
|
||||
if expire != 90*time.Minute {
|
||||
t.Fatalf("resolveJWTAccessTokenExpire() = %v, want %v", expire, 90*time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureAuthEmailServices_UsesSMTPEnvironment(t *testing.T) {
|
||||
t.Setenv("EMAIL_HOST", "127.0.0.1")
|
||||
t.Setenv("EMAIL_PORT", "2525")
|
||||
t.Setenv("EMAIL_FROM_EMAIL", "noreply@test.local")
|
||||
t.Setenv("EMAIL_FROM_NAME", "UMS E2E")
|
||||
t.Setenv("EMAIL_USER", "smtp-user")
|
||||
t.Setenv("EMAIL_PASS", "smtp-pass")
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Server.FrontendURL = "http://127.0.0.1:3000"
|
||||
cfg.Log.ServiceName = "UMS E2E"
|
||||
|
||||
cacheManager := cache.NewCacheManager(cache.NewL1Cache(), cache.NewRedisCache(false))
|
||||
authService := service.NewAuthService(nil, nil, nil, cacheManager, 8, 5, time.Minute)
|
||||
passwordResetConfig := service.DefaultPasswordResetConfig()
|
||||
|
||||
if err := configureAuthEmailServices(cfg, cacheManager, authService, passwordResetConfig); err != nil {
|
||||
t.Fatalf("configureAuthEmailServices() error = %v", err)
|
||||
}
|
||||
if !authService.SupportsEmailActivation() {
|
||||
t.Fatal("SupportsEmailActivation() = false, want true")
|
||||
}
|
||||
if !authService.HasEmailCodeService() {
|
||||
t.Fatal("HasEmailCodeService() = false, want true")
|
||||
}
|
||||
if passwordResetConfig.SMTPHost != "127.0.0.1" {
|
||||
t.Fatalf("password reset SMTP host = %q, want %q", passwordResetConfig.SMTPHost, "127.0.0.1")
|
||||
}
|
||||
if passwordResetConfig.SMTPPort != 2525 {
|
||||
t.Fatalf("password reset SMTP port = %d, want %d", passwordResetConfig.SMTPPort, 2525)
|
||||
}
|
||||
if passwordResetConfig.FromEmail != "noreply@test.local" {
|
||||
t.Fatalf("password reset FromEmail = %q, want %q", passwordResetConfig.FromEmail, "noreply@test.local")
|
||||
}
|
||||
if passwordResetConfig.SiteURL != "http://127.0.0.1:3000" {
|
||||
t.Fatalf("password reset SiteURL = %q, want %q", passwordResetConfig.SiteURL, "http://127.0.0.1:3000")
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -19,11 +22,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
userInfoCachePrefix = "auth_user_info:"
|
||||
tokenBlacklistPrefix = "auth_token_blacklist:"
|
||||
defaultUserCacheTTL = 15 * time.Minute
|
||||
defaultBlacklistTTL = time.Hour
|
||||
defaultPasswordMinLen = 8
|
||||
userInfoCachePrefix = "auth_user_info:"
|
||||
tokenBlacklistPrefix = "auth_token_blacklist:"
|
||||
totpChallengePrefix = "auth_totp_challenge:"
|
||||
defaultUserCacheTTL = 15 * time.Minute
|
||||
defaultBlacklistTTL = time.Hour
|
||||
defaultTOTPChallengeTTL = 5 * time.Minute
|
||||
defaultPasswordMinLen = 8
|
||||
refreshTokenRetryGrace = 10 * time.Second
|
||||
)
|
||||
|
||||
type userRepositoryInterface interface {
|
||||
@@ -122,13 +128,18 @@ type LoginResponse struct {
|
||||
ExpiresIn int64 `json:"expires_in,omitempty"`
|
||||
User *UserInfo `json:"user,omitempty"`
|
||||
// RequiresTOTP 指示登录需要额外的TOTP验证(当设备未信任时)
|
||||
RequiresTOTP bool `json:"requires_totp,omitempty"`
|
||||
RequiresTOTP bool `json:"requires_totp,omitempty"`
|
||||
// TempToken 临时令牌,用于TOTP验证阶段(短生命周期,不可用于常规API)
|
||||
TempToken string `json:"temp_token,omitempty"`
|
||||
// UserID 当RequiresTOTP为true时返回,用于后续TOTP验证
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type totpLoginChallenge struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
type LogoutRequest struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -432,6 +443,38 @@ func (s *AuthService) blacklistTokenClaims(ctx context.Context, token string, va
|
||||
return s.cache.Set(ctx, tokenBlacklistPrefix+claims.JTI, true, ttl, ttl)
|
||||
}
|
||||
|
||||
func (s *AuthService) getTokenBlacklistValue(ctx context.Context, jti string) (interface{}, bool) {
|
||||
if s == nil || s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
jti = strings.TrimSpace(jti)
|
||||
if jti == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return s.cache.Get(ctx, tokenBlacklistPrefix+jti)
|
||||
}
|
||||
|
||||
func tokenBlacklistRevokedAt(value interface{}) (time.Time, bool) {
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
return time.Unix(0, v), true
|
||||
case int:
|
||||
return time.Unix(0, int64(v)), true
|
||||
case float64:
|
||||
return time.Unix(0, int64(v)), true
|
||||
case string:
|
||||
timestamp, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(0, timestamp), true
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) recordLoginAnomaly(ctx context.Context, userID *int64, ip, location, deviceFingerprint string, success bool) {
|
||||
if s == nil || s.anomalyDetector == nil || userID == nil {
|
||||
return
|
||||
@@ -601,6 +644,93 @@ func userInfoFromCacheValue(value interface{}) (*UserInfo, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func generateTemporaryLoginToken() (string, error) {
|
||||
payload := make([]byte, 32)
|
||||
if _, err := cryptorand.Read(payload); err != nil {
|
||||
return "", fmt.Errorf("generate temporary login token failed: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
func totpLoginChallengeFromCacheValue(value interface{}) (*totpLoginChallenge, bool) {
|
||||
switch typed := value.(type) {
|
||||
case *totpLoginChallenge:
|
||||
return typed, true
|
||||
case totpLoginChallenge:
|
||||
challenge := typed
|
||||
return &challenge, true
|
||||
case map[string]interface{}:
|
||||
payload, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var challenge totpLoginChallenge
|
||||
if err := json.Unmarshal(payload, &challenge); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &challenge, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTOTPLoginChallenge(ctx context.Context, user *domain.User, deviceID string) (string, error) {
|
||||
if s == nil || s.cache == nil {
|
||||
return "", errors.New("temporary login token storage is unavailable")
|
||||
}
|
||||
if user == nil {
|
||||
return "", errors.New("temporary login token requires a user")
|
||||
}
|
||||
|
||||
tempToken, err := generateTemporaryLoginToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
challenge := &totpLoginChallenge{
|
||||
UserID: user.ID,
|
||||
DeviceID: strings.TrimSpace(deviceID),
|
||||
}
|
||||
if err := s.cache.Set(
|
||||
ctx,
|
||||
totpChallengePrefix+tempToken,
|
||||
challenge,
|
||||
defaultTOTPChallengeTTL,
|
||||
defaultTOTPChallengeTTL,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("temporary login token storage failed: %w", err)
|
||||
}
|
||||
|
||||
return tempToken, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) validateTOTPLoginChallenge(ctx context.Context, userID int64, deviceID, tempToken string) error {
|
||||
if s == nil || s.cache == nil {
|
||||
return errors.New("temporary login token storage is unavailable")
|
||||
}
|
||||
|
||||
normalizedToken := strings.TrimSpace(tempToken)
|
||||
if normalizedToken == "" {
|
||||
return errors.New("temporary login token is required")
|
||||
}
|
||||
|
||||
value, ok := s.cache.Get(ctx, totpChallengePrefix+normalizedToken)
|
||||
if !ok {
|
||||
return errors.New("temporary login token is invalid or expired")
|
||||
}
|
||||
|
||||
challenge, ok := totpLoginChallengeFromCacheValue(value)
|
||||
if !ok || challenge == nil {
|
||||
return errors.New("temporary login token is invalid or expired")
|
||||
}
|
||||
|
||||
if challenge.UserID != userID || strings.TrimSpace(challenge.DeviceID) != strings.TrimSpace(deviceID) {
|
||||
return errors.New("temporary login token does not match the requested login flow")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*UserInfo, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("注册请求不能为空")
|
||||
@@ -628,6 +758,9 @@ func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*User
|
||||
if err := s.verifyPhoneRegistration(ctx, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.emailActivationSvc != nil && req.Email != "" {
|
||||
return s.RegisterWithActivation(ctx, req)
|
||||
}
|
||||
|
||||
exists, err := s.userRepo.ExistsByUsername(ctx, req.Username)
|
||||
if err != nil {
|
||||
@@ -759,11 +892,17 @@ func (s *AuthService) Login(ctx context.Context, req *LoginRequest, ip string) (
|
||||
|
||||
// P0-07 安全修复:检查是否需要TOTP验证(用户启用了TOTP且设备未信任)
|
||||
if s.isTOTPRequiredForLogin(ctx, user, req.DeviceID) {
|
||||
tempToken, err := s.issueTOTPLoginChallenge(ctx, user, req.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 返回RequiresTOTP指示前端需要完成TOTP验证
|
||||
// 前端应调用 /auth/login/totp-verify 接口完成验证
|
||||
return &LoginResponse{
|
||||
RequiresTOTP: true,
|
||||
UserID: user.ID,
|
||||
TempToken: tempToken,
|
||||
UserID: user.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -808,10 +947,13 @@ func (s *AuthService) isTOTPRequiredForLogin(ctx context.Context, user *domain.U
|
||||
// VerifyTOTPAfterPasswordLogin 完成密码登录后的TOTP验证
|
||||
// 当用户启用了TOTP但设备未信任时,密码登录会返回RequiresTOTP=true
|
||||
// 前端需要调用此接口完成TOTP验证以获取令牌
|
||||
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID string) (*LoginResponse, error) {
|
||||
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID, tempToken string) (*LoginResponse, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("auth service is not initialized")
|
||||
}
|
||||
if err := s.validateTOTPLoginChallenge(ctx, userID, deviceID, tempToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -827,6 +969,10 @@ func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.cache.Delete(ctx, totpChallengePrefix+strings.TrimSpace(tempToken)); err != nil {
|
||||
return nil, fmt.Errorf("temporary login token cleanup failed: %w", err)
|
||||
}
|
||||
|
||||
// TOTP验证成功,返回完整登录响应
|
||||
return s.generateLoginResponseWithoutRemember(ctx, user)
|
||||
}
|
||||
@@ -841,8 +987,11 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.IsTokenBlacklisted(ctx, claims.JTI) {
|
||||
return nil, errors.New("refresh token has been revoked")
|
||||
if blacklistValue, blacklisted := s.getTokenBlacklistValue(ctx, claims.JTI); blacklisted {
|
||||
revokedAt, hasRevocationTimestamp := tokenBlacklistRevokedAt(blacklistValue)
|
||||
if !hasRevocationTimestamp || time.Since(revokedAt) > refreshTokenRetryGrace {
|
||||
return nil, errors.New("refresh token has been revoked")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||
@@ -861,7 +1010,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
|
||||
if claims.ExpiresAt != nil {
|
||||
remaining := time.Until(claims.ExpiresAt.Time)
|
||||
if remaining > 0 {
|
||||
if err := s.cache.Set(ctx, blacklistKey, "1", 5*time.Minute, remaining); err != nil {
|
||||
if err := s.cache.Set(ctx, blacklistKey, time.Now().UnixNano(), 5*time.Minute, remaining); err != nil {
|
||||
return nil, fmt.Errorf("token revocation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,13 +69,17 @@ func (s *AuthService) RegisterWithActivation(ctx context.Context, req *RegisterR
|
||||
if s.emailActivationSvc != nil && req.Email != "" {
|
||||
initialStatus = domain.UserStatusInactive
|
||||
}
|
||||
nickname := req.Nickname
|
||||
if nickname == "" {
|
||||
nickname = req.Username
|
||||
}
|
||||
|
||||
user := &domain.User{
|
||||
Username: req.Username,
|
||||
Email: domain.StrPtr(req.Email),
|
||||
Phone: domain.StrPtr(req.Phone),
|
||||
Password: hashedPassword,
|
||||
Nickname: req.Nickname,
|
||||
Nickname: nickname,
|
||||
Status: initialStatus,
|
||||
}
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
@@ -85,10 +89,6 @@ func (s *AuthService) RegisterWithActivation(ctx context.Context, req *RegisterR
|
||||
s.bestEffortAssignDefaultRoles(ctx, user.ID, "register_with_activation")
|
||||
|
||||
if s.emailActivationSvc != nil && req.Email != "" {
|
||||
nickname := req.Nickname
|
||||
if nickname == "" {
|
||||
nickname = req.Username
|
||||
}
|
||||
// #nosec G118 - 使用独立上下文避免请求结束后被取消
|
||||
go func() { // #nosec G118
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
||||
@@ -375,6 +375,51 @@ func TestAuthService_RegisterWithActivation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService_Register_UsesEmailActivationFlowWhenConfigured(t *testing.T) {
|
||||
svc, db := setupAuthEmailTestEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
l1Cache := cache.NewL1Cache()
|
||||
l2Cache := cache.NewRedisCache(false)
|
||||
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
|
||||
emailActivationSvc := service.NewEmailActivationService(
|
||||
&service.MockEmailProvider{},
|
||||
cacheManager,
|
||||
"http://localhost:8080",
|
||||
"TestSite",
|
||||
)
|
||||
svc.SetEmailActivationService(emailActivationSvc)
|
||||
|
||||
userInfo, err := svc.Register(ctx, &service.RegisterRequest{
|
||||
Username: "register_activation_enabled",
|
||||
Password: "Password123!",
|
||||
Email: "register-activation-enabled@test.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
if userInfo == nil {
|
||||
t.Fatal("Register returned nil user info")
|
||||
}
|
||||
if userInfo.Status != domain.UserStatusInactive {
|
||||
t.Fatalf("Register status = %d, want %d", userInfo.Status, domain.UserStatusInactive)
|
||||
}
|
||||
if userInfo.Nickname != "register_activation_enabled" {
|
||||
t.Fatalf("Register nickname = %q, want %q", userInfo.Nickname, "register_activation_enabled")
|
||||
}
|
||||
|
||||
var storedUser domain.User
|
||||
if err := db.WithContext(ctx).Where("username = ?", "register_activation_enabled").First(&storedUser).Error; err != nil {
|
||||
t.Fatalf("load stored user: %v", err)
|
||||
}
|
||||
if storedUser.Status != domain.UserStatusInactive {
|
||||
t.Fatalf("stored user status = %d, want %d", storedUser.Status, domain.UserStatusInactive)
|
||||
}
|
||||
if storedUser.Nickname != "register_activation_enabled" {
|
||||
t.Fatalf("stored user nickname = %q, want %q", storedUser.Nickname, "register_activation_enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Login By Email Code Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
@@ -3,10 +3,12 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/user-management-system/internal/auth"
|
||||
"github.com/user-management-system/internal/cache"
|
||||
"github.com/user-management-system/internal/domain"
|
||||
"github.com/user-management-system/internal/repository"
|
||||
"github.com/user-management-system/internal/security"
|
||||
@@ -359,6 +361,73 @@ func TestBuildDeviceFingerprint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_IssuesTOTPChallengeTokenWhenSecondFactorIsRequired(t *testing.T) {
|
||||
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
||||
DriverName: "sqlite",
|
||||
DSN: fmt.Sprintf("file:login_totp_challenge_%d?mode=memory&cache=shared", time.Now().UnixNano()),
|
||||
}), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect database: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&domain.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
|
||||
HS256Secret: "totp-challenge-secret",
|
||||
AccessTokenExpire: 15 * time.Minute,
|
||||
RefreshTokenExpire: 7 * 24 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create jwt manager: %v", err)
|
||||
}
|
||||
|
||||
cacheManager := cache.NewCacheManager(cache.NewL1Cache(), cache.NewRedisCache(false))
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
svc := NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
|
||||
|
||||
hashedPassword, err := auth.HashPassword("Password123!")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
user := &domain.User{
|
||||
Username: "totpchallenge",
|
||||
Password: hashedPassword,
|
||||
Status: domain.UserStatusActive,
|
||||
TOTPEnabled: true,
|
||||
TOTPSecret: "JBSWY3DPEHPK3PXP",
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
resp, err := svc.Login(context.Background(), &LoginRequest{
|
||||
Account: "totpchallenge",
|
||||
Password: "Password123!",
|
||||
DeviceID: "device-1",
|
||||
}, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("login failed: %v", err)
|
||||
}
|
||||
|
||||
if !resp.RequiresTOTP {
|
||||
t.Fatalf("expected requires_totp response, got %+v", resp)
|
||||
}
|
||||
if resp.UserID != user.ID {
|
||||
t.Fatalf("expected user id %d, got %d", user.ID, resp.UserID)
|
||||
}
|
||||
if strings.TrimSpace(resp.TempToken) == "" {
|
||||
t.Fatalf("expected temp token when TOTP is required, got %+v", resp)
|
||||
}
|
||||
if resp.AccessToken != "" || resp.RefreshToken != "" {
|
||||
t.Fatalf("expected no full session tokens before TOTP verification, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceDefaultConfig(t *testing.T) {
|
||||
// Test that default configuration is applied correctly
|
||||
svc := NewAuthService(nil, nil, nil, nil, 0, 0, 0)
|
||||
|
||||
Reference in New Issue
Block a user