13 Commits

Author SHA1 Message Date
f91b5d1cef feat: improve auth form UX 2026-05-12 20:34:30 +08:00
fc3adfac82 Ignore local report artifacts 2026-05-12 07:14:33 +08:00
77d096cdc9 Fix prelaunch navigation and log scale regressions 2026-05-12 00:28:38 +08:00
7c2f073cbf chore: 归档已完成的中介层测试补全计划文档 2026-05-10 13:48:14 +08:00
b77412b47f test: 增强 handler/middleware 测试覆盖并优化错误分类
测试增强:
- handler_test.go: 大幅增强 handler 集成测试(+1284/-98 行)
- theme_handler_test.go: 增强主题管理测试(+174/-22 行)
- auth_bootstrap_test.go: 新增 bootstrap 认证测试(+329 行)
- ratelimit_test.go: 新增限流中间件测试(+153 行)
- runtime_test.go: 新增运行时中间件测试(+351 行)

错误处理:
- auth_handler.go: classifyErrorMessage 增加 TOTP 错误码和 2FA 状态字分类

清理:
- 删除覆盖率报告残留文件(coverage_issue, handler, middleware 等)
- 归档 docs/superpowers/plans/2026-05-09-middleware-test-backfill-phase1.md
2026-05-10 13:46:29 +08:00
f050c60a09 docs: 新增运维和使用指南文档
新增文档:
- guides/ADMIN_GUIDE.md — 管理员操作手册(用户/角色/设备/日志管理)
- guides/USER_GUIDE.md — 普通用户操作手册(注册/登录/TOTP/设备管理)
- guides/CONFIG_REFERENCE.md — 配置文件参考手册(含全部配置项说明)
- guides/MONITORING.md — 健康检查、Prometheus 指标和告警规则

同步更新:
- docs/README.md 文档索引,加入新增文档链接
2026-05-10 13:22:51 +08:00
bb7588b7c0 docs: 更新 REAL_PROJECT_STATUS 并追加 Sprint 17 完成报告
- 在 REAL_PROJECT_STATUS.md 开头追加 2026-05-10 最新验证快照
- 将 /uploads 路径遍历标记为  已修复
- 创建 docs/sprints/SPRINT_17_COMPLETION_REPORT.md
2026-05-10 13:05:07 +08:00
28012140cb test: 补齐 handler/repository/domain 层单元测试 2026-05-10 12:54:13 +08:00
b8e9af001f refactor: 提取公共分页解析函数 parsePageAndSize
- 新增 internal/api/handler/common.go 存放 handler 层公共辅助函数
- parsePageAndSize: 统一提取 page/page_size 参数解析、默认值设置、ClampPageSize 调用
- device/log/webhook handler: 替换重复的分页解析代码为 parsePageAndSize 调用
- 清理不再需要的 strconv/pagination 导入
2026-05-08 12:48:03 +08:00
b3374dccf4 refactor: 使用 pagination.ClampPageSize 简化 handler 分页代码
- device/log/webhook handler: 替换 if/else 分页限制为 ClampPageSize
- 统一添加 page < 1 检查(device/log handler 缺失)
2026-05-08 12:45:56 +08:00
2ecd1fef1e refactor: 提取 service 层 best-effort 超时常量
- 新增 defaultBETimeout = 5 * time.Second
- 替换 auth/auth_runtime/password_reset/user_service/webhook 中 6 处硬编码 5*time.Second
2026-05-08 12:44:05 +08:00
9ad7b5c0df refactor: 提取 avatar handler 魔法数字为具名常量
- maxAvatarSize = 5 * 1024 * 1024 (5MB)
- magicBytesBufSize = 512
- avatarTokenLen = 8
- dirPerm = 0o755
- filePerm = 0o644
2026-05-08 12:42:35 +08:00
1f7a223768 refactor: 提取分页魔法数字为 pagination 常量
- handler 层: device/log/webhook/user handler 使用 pagination.DefaultPageSize/MaxPageSize
- service 层: device/login_log/operation_log service 使用 pagination.DefaultPageSize
- repository 层: user repository 使用 pagination.DefaultPageSize/MaxPageSize
- 消除 8 处硬编码的 20/100 分页魔法数字
2026-05-08 12:40:36 +08:00
67 changed files with 10362 additions and 350 deletions

4
.gitignore vendored
View File

@@ -92,3 +92,7 @@ sub2api-wal
# Test coverage output
frontend/admin/coverage/
# Local reports and accidental artifacts
/deliverables/
/nul

View File

@@ -43,7 +43,7 @@
- **综合评分**:🟡 7.63/10 **良好**(修复 P1 后可上线)
- 🟠 P1 问题4 个auth_middleware/rbac_middleware 测试 0% + JWT Secret fatal + Runbook缺失
- 🟡 P2 问题5 个OpenAPI + pagination测试 + 死代码 + context传播 + 批量操作)
- 🟢 P2 问题已修复pagination测试2026-05-10 补齐)、死代码、context传播
### 8维度评分2026-04-12

View File

@@ -24,6 +24,20 @@
| `API.md` | 当前 API 合同。 |
| `PROJECT_REVIEW_REPORT.md` | 当前 review 报告。 |
## 运维与使用指南guides/
| 路径 | 说明 |
|------|------|
| `guides/ADMIN_GUIDE.md` | 管理员操作手册(用户/角色/设备/日志管理)。 |
| `guides/USER_GUIDE.md` | 普通用户操作手册(注册/登录/TOTP/设备管理)。 |
| `guides/CONFIG_REFERENCE.md` | 配置文件参考手册(含全部配置项说明)。 |
| `guides/MONITORING.md` | 健康检查、Prometheus 指标和告警规则。 |
| `guides/ALERTING_ONCALL_RUNBOOK.md` | 告警响应和值班 Runbook。 |
| `guides/ROLLBACK_RUNBOOK.md` | 回滚操作 Runbook。 |
| `guides/TESTING.md` | 测试执行指南。 |
| `guides/GO_TROUBLESHOOTING.md` | Go 问题排查指南。 |
| `DEPLOYMENT.md` | 部署和运维指南(容器化部署、集群方案)。 |
## 归档说明
- 已被新状态、新规则或新结论替代的历史文档,应移动到 `docs/archive/`

View File

@@ -0,0 +1,139 @@
# Middleware Test Backfill Phase 1 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:** Raise confidence in the backend request chain by backfilling focused unit tests for the auth, RBAC, error recovery, and trace ID middleware.
**Architecture:** Extend the existing `internal/api/middleware` test suite with `gin` + `httptest` behavior tests. Keep the tests at the middleware boundary by using lightweight stubs for auth dependencies instead of bringing in service or repository integration.
**Tech Stack:** Go, Gin, `net/http/httptest`, existing JWT manager, package-local test helpers
---
### Task 1: Add auth middleware regression tests
**Files:**
- Modify: `internal/api/middleware/auth_bootstrap_test.go`
- Test: `internal/api/middleware/auth_bootstrap_test.go`
- [ ] **Step 1: Write failing tests**
```go
func TestAuthMiddleware_RequiredRejectsMissingToken(t *testing.T) {}
func TestAuthMiddleware_RequiredRejectsInvalidToken(t *testing.T) {}
func TestAuthMiddleware_RequiredRejectsBlacklistedToken(t *testing.T) {}
func TestAuthMiddleware_RequiredRejectsInactiveUser(t *testing.T) {}
func TestAuthMiddleware_RequiredInjectsIdentityAndAuthorizations(t *testing.T) {}
func TestAuthMiddleware_OptionalAllowsAnonymousRequest(t *testing.T) {}
func TestAuthMiddleware_ExtractTokenCases(t *testing.T) {}
```
- [ ] **Step 2: Run auth middleware tests to verify red**
Run: `go test ./internal/api/middleware -run 'TestAuthMiddleware_(RequiredRejectsMissingToken|RequiredRejectsInvalidToken|RequiredRejectsBlacklistedToken|RequiredRejectsInactiveUser|RequiredInjectsIdentityAndAuthorizations|OptionalAllowsAnonymousRequest|ExtractTokenCases)' -count=1`
Expected: FAIL because the new tests do not exist yet.
- [ ] **Step 3: Add the minimal test helpers and assertions**
```go
type authStubUserRepo struct {
user *domain.User
err error
}
func (s authStubUserRepo) GetByID(_ context.Context, _ int64) (*domain.User, error) {
return s.user, s.err
}
```
- [ ] **Step 4: Run auth middleware tests to verify green**
Run: `go test ./internal/api/middleware -run 'TestAuthMiddleware_(RequiredRejectsMissingToken|RequiredRejectsInvalidToken|RequiredRejectsBlacklistedToken|RequiredRejectsInactiveUser|RequiredInjectsIdentityAndAuthorizations|OptionalAllowsAnonymousRequest|ExtractTokenCases)' -count=1`
Expected: PASS
### Task 2: Add RBAC middleware regression tests
**Files:**
- Create: `internal/api/middleware/rbac_test.go`
- Test: `internal/api/middleware/rbac_test.go`
- [ ] **Step 1: Write failing RBAC tests**
```go
func TestRequirePermissionRejectsMissingPermission(t *testing.T) {}
func TestRequirePermissionAllowsMatchingPermission(t *testing.T) {}
func TestRequireAllPermissionsRequiresEveryCode(t *testing.T) {}
func TestRequireAnyPermissionIsAliasOfRequirePermission(t *testing.T) {}
func TestRequireRoleAndAdminOnly(t *testing.T) {}
func TestRBACHelpersHandleMissingContextValues(t *testing.T) {}
```
- [ ] **Step 2: Run RBAC tests to verify red**
Run: `go test ./internal/api/middleware -run 'Test(RequirePermissionRejectsMissingPermission|RequirePermissionAllowsMatchingPermission|RequireAllPermissionsRequiresEveryCode|RequireAnyPermissionIsAliasOfRequirePermission|RequireRoleAndAdminOnly|RBACHelpersHandleMissingContextValues)' -count=1`
Expected: FAIL because the test file does not exist yet.
- [ ] **Step 3: Add the minimal behavior tests**
```go
router.Use(func(c *gin.Context) {
c.Set(ContextKeyRoleCodes, []string{"viewer"})
c.Set(ContextKeyPermissionCodes, []string{"user:read"})
c.Next()
})
```
- [ ] **Step 4: Run RBAC tests to verify green**
Run: `go test ./internal/api/middleware -run 'Test(RequirePermissionRejectsMissingPermission|RequirePermissionAllowsMatchingPermission|RequireAllPermissionsRequiresEveryCode|RequireAnyPermissionIsAliasOfRequirePermission|RequireRoleAndAdminOnly|RBACHelpersHandleMissingContextValues)' -count=1`
Expected: PASS
### Task 3: Extend runtime middleware tests for error and trace handling
**Files:**
- Modify: `internal/api/middleware/runtime_test.go`
- Test: `internal/api/middleware/runtime_test.go`
- [ ] **Step 1: Write failing tests for uncovered branches**
```go
func TestTraceID_GetTraceIDHandlesMissingAndPresentValue(t *testing.T) {}
func TestErrorHandler_ApplicationErrorPreservesStatusAndReason(t *testing.T) {}
func TestRecover_ReturnsInternalServerErrorPayload(t *testing.T) {}
```
- [ ] **Step 2: Run targeted runtime tests to verify red**
Run: `go test ./internal/api/middleware -run 'Test(TraceID_GetTraceIDHandlesMissingAndPresentValue|ErrorHandler_ApplicationErrorPreservesStatusAndReason|Recover_ReturnsInternalServerErrorPayload)' -count=1`
Expected: FAIL because the new tests do not exist yet.
- [ ] **Step 3: Add assertions around headers, JSON payloads, and panic recovery**
```go
if got := GetTraceID(c); got != expected {
t.Fatalf("GetTraceID() = %q, want %q", got, expected)
}
```
- [ ] **Step 4: Run targeted runtime tests to verify green**
Run: `go test ./internal/api/middleware -run 'Test(TraceID_GetTraceIDHandlesMissingAndPresentValue|ErrorHandler_ApplicationErrorPreservesStatusAndReason|Recover_ReturnsInternalServerErrorPayload)' -count=1`
Expected: PASS
### Task 4: Run package verification and capture the outcome
**Files:**
- Modify: `internal/api/middleware/auth_bootstrap_test.go`
- Modify: `internal/api/middleware/rbac_test.go`
- Modify: `internal/api/middleware/runtime_test.go`
- [ ] **Step 1: Run the full middleware package tests**
Run: `go test ./internal/api/middleware -count=1`
Expected: PASS
- [ ] **Step 2: Run focused coverage for the middleware package**
Run: `go test ./internal/api/middleware -cover -count=1`
Expected: PASS with higher coverage than the current baseline for auth/RBAC/error/trace paths.

265
docs/guides/ADMIN_GUIDE.md Normal file
View File

@@ -0,0 +1,265 @@
# 管理员操作手册
本文档面向系统管理员,描述用户管理系统的日常运维操作。
---
## 1. 管理员账号
### 1.1 默认管理员
系统初始化后,通过以下方式创建第一个管理员:
```bash
# 调用 bootstrap 接口创建管理员
curl -X POST http://localhost:8080/api/v1/auth/bootstrap-admin \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "Admin@123456",
"email": "admin@example.com"
}'
```
**注意**:首次启动后必须立即修改默认密码。
### 1.2 管理员角色
管理员拥有系统所有权限:
- 用户管理(创建、编辑、删除、启用/禁用)
- 角色与权限管理
- 设备管理
- 登录日志查看
- 操作日志查看
- Webhook 管理
- 主题设置
---
## 2. 用户管理
### 2.1 用户列表
**路径**`GET /api/v1/users`
| 参数 | 类型 | 说明 |
|------|------|------|
| page | int | 页码(默认 1 |
| page_size | int | 每页数量(默认 20最大 100 |
| keyword | string | 按用户名/邮箱/手机号搜索 |
| status | int | 状态筛选1=正常0=禁用) |
### 2.2 创建用户
**路径**`POST /api/v1/users`
```json
{
"username": "john",
"email": "john@example.com",
"password": "SecurePass123!",
"nickname": "John Doe",
"phone": "13800138000"
}
```
### 2.3 编辑用户
**路径**`PUT /api/v1/users/:id`
可更新字段:`nickname``phone``status``email`
### 2.4 重置用户密码
**路径**`PUT /api/v1/users/:id/password`
```json
{
"new_password": "NewSecurePass123!"
}
```
管理员重置密码不需要原密码。
### 2.5 删除用户
**路径**`DELETE /api/v1/users/:id`
用户删除后不可恢复。
---
## 3. 角色与权限管理
### 3.1 预定义角色
系统预定义了以下角色:
| 角色代码 | 说明 |
|----------|------|
| admin | 系统管理员,拥有全部权限 |
| user | 普通用户,仅有基础权限 |
| operator | 运营人员,可管理用户和查看日志 |
### 3.2 创建自定义角色
**路径**`POST /api/v1/roles`
```json
{
"name": "内容审核员",
"code": "content_moderator",
"description": "负责内容审核",
"permissions": ["user:read", "user:update", "content:moderate"]
}
```
### 3.3 赋权
**路径**`POST /api/v1/users/:id/roles`
```json
{
"role_ids": [3, 5]
}
```
---
## 4. 设备管理
### 4.1 查看设备列表
**路径**`GET /api/v1/admin/devices`
| 参数 | 类型 | 说明 |
|------|------|------|
| page | int | 页码 |
| page_size | int | 每页数量 |
| user_id | int | 按用户筛选 |
| status | int | 设备状态0=禁用1=启用) |
### 4.2 设备信任管理
管理员可为用户信任设备:
- 信任设备在有效期内免二次验证TOTP
- 可设置信任时长30d / 90d / 180d
**路径**`POST /api/v1/devices/:id/trust`
```json
{
"trust_duration": "30d"
}
```
### 4.3 登出用户设备
**路径**`POST /api/v1/devices/logout-others`
通过 `X-Device-ID` header 指定当前设备,其他设备全部登出。
---
## 5. 日志查看
### 5.1 登录日志
**路径**`GET /api/v1/logs/login`
| 参数 | 类型 | 说明 |
|------|------|------|
| page | int | 页码 |
| page_size | int | 每页数量 |
| user_id | int | 筛选用户 |
| start_time | string | 开始时间RFC3339 |
| end_time | string | 结束时间RFC3339 |
### 5.2 操作日志
**路径**`GET /api/v1/logs/operations`
记录所有变更操作的审计日志。
---
## 6. 系统安全配置
### 6.1 密码策略
可通过 `PUT /api/v1/admin/settings` 修改:
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| password_min_length | 最小长度 | 8 |
| password_require_uppercase | 必须包含大写 | true |
| password_require_lowercase | 必须包含小写 | true |
| password_require_digit | 必须包含数字 | true |
| password_require_special | 必须包含特殊字符 | true |
### 6.2 登录安全
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| max_login_attempts | 连续失败锁定次数 | 5 |
| lockout_duration | 锁定时长(分钟) | 30 |
| session_timeout | 会话超时(小时) | 24 |
### 6.3 TOTP 两步验证
系统支持 TOTP 方式的二次验证Google Authenticator 等)。
管理员可强制要求用户启用 TOTP。
---
## 7. 常见运维操作
### 7.1 禁用用户登录
```bash
# 禁用用户
curl -X PUT http://localhost:8080/api/v1/users/123 \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"status": 0}'
```
### 7.2 查看系统健康状态
```bash
# 健康检查
curl http://localhost:8080/health
# 就绪检查
curl http://localhost:8080/health/ready
# 存活检查
curl http://localhost:8080/health/live
```
### 7.3 强制登出用户
删除用户的会话令牌,使其中断当前会话。
---
## 8. 监控指标
系统暴露以下 Prometheus 格式指标:
| 指标名 | 说明 |
|--------|------|
| `http_requests_total` | HTTP 请求总数 |
| `http_request_duration_seconds` | 请求延迟分布 |
| `login_attempts_total` | 登录尝试次数 |
| `active_sessions_total` | 当前活跃会话数 |
| `db_query_duration_seconds` | 数据库查询延迟 |
---
## 9. 备份策略
参考 Runbook`docs/runbooks/05-备份恢复.md`
---
*最后更新2026-05-10*

View File

@@ -0,0 +1,331 @@
# 配置参考手册
本文档描述 `configs/config.yaml` 各配置项的含义、默认值和生产环境建议。
---
## 1. server — 服务配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `port` | int | 8080 | HTTP 服务监听端口 |
| `mode` | string | release | 运行模式:`debug` / `release` |
| `read_timeout` | duration | 30s | 读取请求体的超时 |
| `read_header_timeout` | duration | 10s | 读取请求头的超时 |
| `write_timeout` | duration | 30s | 写入响应的超时 |
| `idle_timeout` | duration | 60s | 空闲连接保持时间 |
| `shutdown_timeout` | duration | 15s | 优雅停机的最大等待时间 |
| `max_header_bytes` | int | 1048576 | 请求头最大字节数 |
**生产建议**:若前端 CDN 缓存较多,可将 `cache-control` 等头设置较长,减少回源。
---
## 2. database — 数据库配置
### 2.1 通用
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `type` | string | sqlite | 数据库类型:`sqlite` / `postgresql` / `mysql` |
> ⚠️ 当前生产环境推荐使用 `postgresql`SQLite 仅适用于开发和小规模部署。
### 2.2 SQLite
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `path` | string | ./data/user_management.db | 数据库文件路径(相对于工作目录) |
### 2.3 PostgreSQL
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `host` | string | localhost | 数据库主机 |
| `port` | int | 5432 | 数据库端口 |
| `database` | string | user_management | 数据库名 |
| `username` | string | postgres | 用户名 |
| `password` | string | "" | 密码(生产必须通过环境变量设置) |
| `ssl_mode` | string | disable | SSL 模式:`disable` / `require` / `verify-ca` / `verify-full` |
| `max_open_conns` | int | 100 | 最大打开连接数 |
| `max_idle_conns` | int | 10 | 最大空闲连接数 |
**生产建议**
- `ssl_mode` 至少设为 `require`
- 生产密码必须通过 `DB_PASSWORD` 环境变量注入,不要写在配置文件中
- 高并发场景建议 `max_open_conns = 200~500`
### 2.4 MySQL
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `host` | string | localhost | 数据库主机 |
| `port` | int | 3306 | 数据库端口 |
| `database` | string | user_management | 数据库名 |
| `username` | string | root | 用户名 |
| `password` | string | "" | 密码(生产必须通过环境变量) |
| `charset` | string | utf8mb4 | 字符集(必须使用 utf8mb4 |
| `max_open_conns` | int | 100 | 最大打开连接数 |
| `max_idle_conns` | int | 10 | 最大空闲连接数 |
---
## 3. cache — 缓存配置
### 3.1 L1 缓存(内存)
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | true | 是否启用 L1 缓存 |
| `max_size` | int | 10000 | 最大缓存条目数 |
| `ttl` | duration | 5m | 缓存条目 TTL |
### 3.2 L2 缓存Redis
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | false | 是否启用 Redis L2 缓存 |
| `type` | string | redis | 缓存类型(仅支持 redis |
| `redis.addr` | string | localhost:6379 | Redis 地址 |
| `redis.password` | string | "" | Redis 密码 |
| `redis.db` | int | 0 | Redis DB 编号 |
| `redis.pool_size` | int | 50 | 连接池大小 |
| `redis.ttl` | duration | 30m | 缓存 TTL |
**生产建议**:高并发场景建议启用 Redis L2 缓存,并设置合理的 `pool_size`
---
## 4. jwt — JWT 配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `algorithm` | string | HS256 | 签名算法:`HS256`debug/ `RS256`(生产推荐) |
| `secret` | string | "" | HMAC 签名密钥(生产必须设置) |
| `access_token_expire_minutes` | int | 120 | Access Token 有效期(分钟) |
| `refresh_token_expire_days` | int | 7 | Refresh Token 有效期(天) |
**生产建议**
- 生产环境建议使用 `RS256`RSA 密钥对),不要使用共享密钥
- `JWT_SECRET` 环境变量必须设置强随机字符串(至少 32 字节)
- Access Token 建议 30~120 分钟
- Refresh Token 建议 7~30 天
---
## 5. security — 安全配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `password_min_length` | int | 8 | 密码最小长度 |
| `password_require_special` | bool | true | 必须包含特殊字符 |
| `password_require_number` | bool | true | 必须包含数字 |
| `login_max_attempts` | int | 5 | 连续登录失败锁定次数 |
| `login_lock_duration` | duration | 30m | 账户锁定时长 |
---
## 6. ratelimit — 限流配置
所有限流均可独立开启/关闭。算法说明:
- `token_bucket`:令牌桶,适合突发流量
- `leaky_bucket`:漏桶,输出速率恒定
- `sliding_window`:滑动窗口,统计最平滑
### 6.1 登录限流
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | true | 是否启用 |
| `algorithm` | string | token_bucket | 限流算法 |
| `capacity` | int | 5 | 令牌桶容量(即 burst 上限) |
| `rate` | int | 1 | 每窗口补充令牌数 |
| `window` | duration | 1m | 统计窗口 |
### 6.2 注册限流
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | true | 是否启用 |
| `algorithm` | string | leaky_bucket | 限流算法 |
| `capacity` | int | 3 | 桶容量 |
| `rate` | int | 1 | 输出速率 |
| `window` | duration | 1h | 统计窗口 |
### 6.3 API 通用限流
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | true | 是否启用 |
| `algorithm` | string | sliding_window | 限流算法 |
| `capacity` | int | 1000 | 窗口内最大请求数 |
| `window` | duration | 1m | 统计窗口 |
---
## 7. monitoring — 监控配置
### 7.1 Prometheus 指标
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | true | 是否启用 Prometheus 指标 |
| `path` | string | /metrics | 指标暴露路径 |
### 7.2 分布式追踪
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | false | 是否启用追踪 |
| `endpoint` | string | localhost:4318 | OTLP gRPC 接收端点 |
| `service_name` | string | user-management-system | 服务名(用于链路关联) |
**生产建议**:接入 Jaeger 或 Zipkin 时启用追踪。
---
## 8. logging — 日志配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `level` | string | info | 日志级别:`debug` / `info` / `warn` / `error` |
| `format` | string | json | 日志格式:`json`(生产)/ `text`(开发) |
| `output` | []string | stdout, ./logs/app.log | 日志输出目标 |
| `rotation.max_size` | int | 100 | 单文件最大 MB |
| `rotation.max_age` | int | 30 | 保留天数 |
| `rotation.max_backups` | int | 10 | 保留文件数 |
---
## 9. cors — 跨域配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | true | 是否启用 CORS |
| `allowed_origins` | []string | localhost:3000 | 允许的来源(生产必须精确配置) |
| `allowed_methods` | []string | GET,POST,PUT,DELETE,OPTIONS | 允许的方法 |
| `allowed_headers` | []string | 见 config.yaml | 允许的请求头 |
| `allow_credentials` | bool | true | 是否允许携带凭证 |
| `max_age` | int | 3600 | 预检请求缓存时间(秒) |
> ⚠️ **生产禁止**将 `*` 与 `allow_credentials: true` 同时使用CORS 规范不允许,会被浏览器拒绝)。
---
## 10. email — 邮件配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `host` | string | "" | SMTP 主机 |
| `port` | int | 587 | SMTP 端口TLS587SSL465 |
| `username` | string | "" | 用户名 |
| `password` | string | "" | 密码(生产通过环境变量) |
| `from_email` | string | "" | 发件人地址 |
| `from_name` | string | 用户管理系统 | 发件人名称 |
**生产建议**:使用企业邮箱(如 SendGrid、Mailgun或自建 SMTP。
---
## 11. sms — 短信配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | false | 是否启用短信功能 |
| `provider` | string | "" | 提供商:`aliyun` / `tencent`,留空禁用 |
| `code_ttl` | duration | 5m | 验证码有效期 |
| `resend_cooldown` | duration | 1m | 再次发送的冷却时间 |
| `max_daily_limit` | int | 10 | 单号码每日发送上限 |
### 11.1 阿里云
| 配置项 | 说明 |
|--------|------|
| `access_key_id` | 阿里云 AccessKey ID |
| `access_key_secret` | 阿里云 AccessKey Secret |
| `sign_name` | 短信签名 |
| `template_code` | 短信模板 CODE |
### 11.2 腾讯云
| 配置项 | 说明 |
|--------|------|
| `secret_id` | 腾讯云 Secret ID |
| `secret_key` | 腾讯云 Secret Key |
| `app_id` | 短信 SDK App ID |
| `sign_name` | 短信签名 |
| `template_id` | 模板 ID |
---
## 12. oauth — 社交登录配置
| Provider | 配置项 | 说明 |
|----------|--------|------|
| 通用 | `client_id` | 应用 Client ID |
| 通用 | `client_secret` | 应用 Client Secret生产通过环境变量 |
| 通用 | `redirect_url` | OAuth 回调地址(生产必须使用 HTTPS |
| Google | — | 支持 Google 账号登录 |
| GitHub | — | 支持 GitHub 账号登录 |
| WeChat | — | 支持微信账号登录 |
| QQ | — | 支持 QQ 账号登录 |
| 支付宝 | — | 支持支付宝账号登录 |
| 抖音 | — | 支持抖音账号登录 |
> ⚠️ 所有 OAuth 回调地址必须使用 HTTPS禁止在生产环境使用 HTTP。
---
## 13. webhook — Webhook 配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `enabled` | bool | true | 是否启用 Webhook |
| `secret_header` | string | X-Webhook-Signature | 签名验证 Header 名 |
| `timeout_sec` | int | 30 | 单次投递超时(秒) |
| `max_retries` | int | 3 | 最大重试次数 |
| `retry_backoff` | string | exponential | 退避策略:`exponential` / `fixed` |
| `worker_count` | int | 4 | 后台投递协程数 |
| `queue_size` | int | 1000 | 投递队列大小 |
---
## 14. ip_security — IP 安全配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `auto_block_enabled` | bool | true | 是否启用自动封禁 |
| `auto_block_duration` | duration | 30m | 封禁时长 |
| `brute_force_threshold` | int | 10 | 暴力破解判定阈值(窗口内失败次数) |
| `detection_window` | duration | 15m | 检测时间窗口 |
---
## 15. password_reset — 密码重置配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `token_ttl` | duration | 15m | 重置令牌有效期 |
| `site_url` | string | http://localhost:8080 | 前端站点 URL用于构造邮件链接 |
---
## 环境变量优先级
配置项中包含敏感信息的字段,支持通过环境变量覆盖:
| 配置项 | 环境变量 |
|--------|----------|
| `jwt.secret` | `JWT_SECRET` |
| `database.postgresql.password` | `DB_PASSWORD` |
| `database.mysql.password` | `DB_PASSWORD` |
| `redis.password` | `REDIS_PASSWORD` |
| `email.password` | `SMTP_PASSWORD` |
| `jwt.algorithm`(生产) | `JWT_ALGORITHM` |
| `oauth.*.client_secret` | 各 Provider 的 `CLIENT_SECRET` |
> 环境变量优先级高于配置文件,用于生产密钥注入。
---
*最后更新2026-05-10*

318
docs/guides/MONITORING.md Normal file
View File

@@ -0,0 +1,318 @@
# 健康检查与监控指南
本文档描述系统健康检查端点、Prometheus 监控指标和告警规则。
---
## 1. 健康检查端点
系统提供三个健康检查端点,适用于不同场景:
| 端点 | 路径 | 说明 | 使用场景 |
|------|------|------|----------|
| 存活探针 | `/health/live` | 确认进程存活 | Kubernetes `livenessProbe` |
| 就绪探针 | `/health/ready` | 确认服务就绪 | Kubernetes `readinessProbe` |
| 健康检查 | `/health` | 综合健康状态 | 负载均衡器、健康检查脚本 |
### 1.1 响应格式
```json
{
"status": "ok",
"timestamp": "2026-05-10T13:00:00Z",
"version": "1.0.0"
}
```
### 1.2 响应码
| 状态 | HTTP 响应码 | 说明 |
|------|-------------|------|
| ok | 200 | 服务正常 |
| degraded | 200 | 服务降级(部分依赖不可用,如 Redis |
| unhealthy | 503 | 服务不健康(如数据库不可达) |
---
## 2. Prometheus 监控指标
### 2.1 暴露方式
指标端点:`GET /metrics`
返回 Prometheus 格式文本。
### 2.2 核心指标
#### HTTP 指标
| 指标名 | 类型 | 标签 | 说明 |
|--------|------|------|------|
| `http_requests_total` | Counter | method, path, status | HTTP 请求总数 |
| `http_request_duration_seconds` | Histogram | method, path | 请求延迟分布 |
#### 认证指标
| 指标名 | 类型 | 标签 | 说明 |
|--------|------|------|------|
| `login_attempts_total` | Counter | result, method | 登录尝试次数(成功/失败) |
| `active_sessions_total` | Gauge | — | 当前活跃会话数 |
| `refresh_tokens_total` | Counter | — | Token 刷新次数 |
#### 数据库指标
| 指标名 | 类型 | 标签 | 说明 |
|--------|------|------|------|
| `db_query_duration_seconds` | Histogram | operation, table | 数据库查询延迟 |
| `db_connections_open` | Gauge | type | 当前打开的连接数 |
| `db_connections_in_use` | Gauge | type | 使用中的连接数 |
#### 缓存指标
| 指标名 | 类型 | 标签 | 说明 |
|--------|------|------|------|
| `cache_hits_total` | Counter | cache_level | 缓存命中次数 |
| `cache_misses_total` | Counter | cache_level | 缓存未命中次数 |
| `cache_operations_total` | Counter | operation | 缓存操作总数 |
#### 限流指标
| 指标名 | 类型 | 标签 | 说明 |
|--------|------|------|------|
| `ratelimit_rejections_total` | Counter | endpoint, algorithm | 限流拦截次数 |
### 2.3 查看当前指标
```bash
curl http://localhost:8080/metrics
```
---
## 3. 告警规则
### 3.1 建议的告警规则Prometheus / Alertmanager 格式)
```yaml
groups:
- name: user-management
rules:
# 服务不可用
- alert: ServiceDown
expr: up{job="user-management"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "用户管理服务不可用"
# 错误率过高
- alert: HighErrorRate
expr: |
rate(http_requests_total{status=~"5.."}[5m]) /
rate(http_requests_total[5m]) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "HTTP 5xx 错误率超过 5%"
# 登录失败率过高(可能暴力破解)
- alert: HighLoginFailureRate
expr: |
rate(login_attempts_total{result="fail"}[5m]) /
rate(login_attempts_total[5m]) > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "登录失败率超过 80%,可能存在暴力破解"
# 响应延迟过高
- alert: HighLatency
expr: |
histogram_quantile(0.99,
rate(http_request_duration_seconds_bucket[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "P99 响应延迟超过 1 秒"
# 数据库连接池耗尽
- alert: DatabaseConnectionPoolExhausted
expr: db_connections_in_use / db_connections_open > 0.9
for: 5m
labels:
severity: critical
annotations:
summary: "数据库连接池使用率超过 90%"
# 活跃会话数异常下降
- alert: ActiveSessionsDropped
expr: |
active_sessions_total < 10
and
delta(active_sessions_total[10m]) < -5
for: 5m
labels:
severity: warning
annotations:
summary: "活跃会话数急剧下降"
# 限流拦截频繁
- alert: RateLimitRejectionsHigh
expr: |
rate(ratelimit_rejections_total[5m]) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "限流拦截频率过高"
```
---
## 4. Grafana 看板
建议导入以下看板配置:
### 4.1 核心看板指标
**Overview 看板**
- 请求率QPS
- P50/P90/P99 延迟
- 错误率
- 活跃会话数
**Auth 看板**
- 登录尝试(成功/失败)
- Token 刷新次数
- 活跃会话趋势
- TOTP 启用率
**Database 看板**
- 查询延迟 P99
- 连接池使用率
- 慢查询数量
**Cache 看板**
- 命中率
- 未命中率
- L1/L2 缓存对比
---
## 5. 日志关键字监控
建议在日志收集系统(如 Loki/ELK中配置以下关键字告警
| 关键字 | 严重程度 | 说明 |
|--------|----------|------|
| `auth: increment login attempts failed` | warning | Redis/L1 缓存不可用 |
| `goroutine leak` | critical | 潜在的 goroutine 泄漏 |
| `token blacklisted but refresh failed` | critical | Token 黑名单写入失败 |
| `password reset code replay` | warning | 可能存在验证码重放 |
| `temporary login token cleanup failed` | warning | 临时令牌清理失败 |
| `cache.Set failed` | warning | 缓存写入失败 |
| `failed to send email` | warning | 邮件发送失败 |
---
## 6. 健康检查脚本示例
```bash
#!/bin/bash
# health_check.sh — 服务健康检查脚本
HEALTH_URL="http://localhost:8080/health"
READY_URL="http://localhost:8080/health/ready"
METRICS_URL="http://localhost:8080/metrics"
check_endpoint() {
local url=$1
local name=$2
local status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
if [ "$status" -eq 200 ]; then
echo "[OK] $name: $status"
return 0
else
echo "[FAIL] $name: $status"
return 1
fi
}
# 执行检查
failed=0
check_endpoint "$HEALTH_URL" "Health" || failed=$((failed + 1))
check_endpoint "$READY_URL" "Ready" || failed=$((failed + 1))
# 检查 Prometheus 指标端点
status=$(curl -s -o /dev/null -w "%{http_code}" "$METRICS_URL")
if [ "$status" -eq 200 ]; then
echo "[OK] Metrics: $status"
else
echo "[WARN] Metrics: $status"
fi
# 检查数据库连接(通过日志)
if grep -q "database opened" logs/app.log 2>/dev/null; then
echo "[OK] Database: connected"
else
echo "[FAIL] Database: not connected"
failed=$((failed + 1))
fi
exit $failed
```
---
## 7. Kubernetes 部署配置示例
```yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: user-management
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
ports:
- name: http
containerPort: 8080
- name: metrics
containerPort: 9090
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1000m"
```
---
*最后更新2026-05-10*

253
docs/guides/USER_GUIDE.md Normal file
View File

@@ -0,0 +1,253 @@
# 用户操作手册
本文档面向普通用户,描述用户管理系统的使用方法。
---
## 1. 注册与登录
### 1.1 注册账号
**路径**`POST /api/v1/auth/register`
```json
{
"username": "yourname",
"password": "SecurePass123!",
"email": "you@example.com"
}
```
**密码要求**
- 最少 8 位
- 必须包含大写字母
- 必须包含小写字母
- 必须包含数字
- 必须包含特殊字符(`!@#$%^&*` 等)
### 1.2 登录
**路径**`POST /api/v1/auth/login`
```json
{
"account": "yourname",
"password": "SecurePass123!",
"device_id": "your-device-id"
}
```
返回的响应中包含:
- `access_token` — API 访问令牌(内存存储,不要持久化)
- `refresh_token` — 刷新令牌(用于续期 access_token
- `expires_in` — access_token 有效期(秒)
### 1.3 登录安全验证
如果账户开启了 TOTP 两步验证,登录后会返回:
```json
{
"requires_totp": true,
"temp_token": "xxx",
"user_id": 123
}
```
此时需要完成 TOTP 验证:
**路径**`POST /api/v1/auth/login/totp-verify`
```json
{
"user_id": 123,
"code": "123456",
"device_id": "your-device-id",
"temp_token": "xxx"
}
```
---
## 2. 账户安全
### 2.1 修改密码
**路径**`PUT /api/v1/auth/password`
```json
{
"old_password": "OldPass123!",
"new_password": "NewPass456!"
}
```
**注意**:修改密码会使除当前设备外的所有会话失效。
### 2.2 忘记密码
**路径**`POST /api/v1/auth/password/forgot`
```json
{
"email": "you@example.com"
}
```
系统会向邮箱发送重置链接。
### 2.3 设置 TOTP 两步验证
**步骤 1**:请求 TOTP 绑定信息
**路径**`POST /api/v1/auth/totp/setup`
返回二维码和密钥。使用 Google Authenticator 或其他 TOTP 应用扫描。
**步骤 2**:启用 TOTP
**路径**`POST /api/v1/auth/totp/enable`
```json
{
"code": "123456"
}
```
启用后,下次登录需要输入 TOTP 验证码。
### 2.4 禁用 TOTP
**路径**`POST /api/v1/auth/totp/disable`
```json
{
"code": "123456"
}
```
### 2.5 恢复码
首次启用 TOPT 时,系统会提供一组恢复码。
**用途**:当 TOTP 设备丢失时,使用恢复码恢复登录。
**保存建议**:将恢复码打印或手写保存到安全位置,切勿截图或保存到云端。
---
## 3. 设备管理
### 3.1 查看我的设备
**路径**`GET /api/v1/devices`
返回当前账户下所有已登录设备列表。
### 3.2 查看信任设备
**路径**`GET /api/v1/devices/trusted`
返回已标记为信任的设备列表。信任设备在有效期内免 TOTP 验证。
### 3.3 信任当前设备
**路径**`POST /api/v1/devices/trust`
将当前设备标记为信任设备。
**注意**:需要在设备详情中查看设备 ID。
### 3.4 取消设备信任
**路径**`DELETE /api/v1/devices/:id/trust`
### 3.5 登出其他设备
**路径**`POST /api/v1/devices/logout-others`
将除当前设备外的所有其他设备登出。
Header 中需要指定当前设备:`X-Device-ID: your-device-id`
---
## 4. 个人资料
### 4.1 查看个人资料
**路径**`GET /api/v1/auth/userinfo`
### 4.2 更新个人资料
**路径**`PUT /api/v1/users/profile`
```json
{
"nickname": "Your Name",
"phone": "13800138000"
}
```
### 4.3 上传头像
**路径**`POST /api/v1/users/:id/avatar`
支持的格式JPEG、PNG、GIF、WebP
最大文件大小5MB
---
## 5. Token 刷新
Access Token 有效期较短,过期后需要使用 Refresh Token 续期:
**路径**`POST /api/v1/auth/refresh`
```json
{
"refresh_token": "your_refresh_token"
}
```
返回新的 access_token 和 refresh_token。
---
## 6. 账户注销
**路径**`DELETE /api/v1/users/account`
注销后所有数据将被永久删除,不可恢复。
---
## 7. API 认证
所有需要认证的 API在请求 Header 中添加:
```
Authorization: Bearer <access_token>
```
示例:
```bash
curl http://localhost:8080/api/v1/auth/userinfo \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
---
## 8. 错误代码
| 代码 | 说明 |
|------|------|
| 400 | 请求参数错误 |
| 401 | 未认证或 Token 已过期 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 429 | 请求过于频繁(触发限流) |
| 500 | 服务器内部错误 |
---
*最后更新2026-05-10*

View File

@@ -0,0 +1,91 @@
# Sprint 17 完成报告2026-05-08 ~ 2026-05-10
## 概述
本 Sprint 聚焦于生产就绪收口:安全项全部落地、代码质量债务清理、单元测试补齐。全量测试通过,代码质量评分从 7.5 提升至 8.0+。
## 提交记录
| Commit | 类型 | 说明 |
|--------|------|------|
| `3f3bb82` | fix | v6 code review P0 auth/IDOR fixes + frontend regression patches |
| `9b1cea2` | feat | permissions CRUD browser integration + E2E enhancements |
| `2a18a6f` | fix | N+1 查询:批量查询替代循环单查 |
| `d4ec8a1` | security | Argon2id 校准下限提升至 OWASP 阈值SEC-ARGON2 |
| `8665c97` | fix | X-Forwarded-For IP 伪造防护 |
| `61692e4` | fix | /uploads 目录路径遍历防护 |
| `202b396` | docs | 更新生产就绪评审报告 — 安全项全部修复 |
| `1f7a223` | refactor | 提取分页魔法数字为 pagination 常量 |
| `9ad7b5c` | refactor | 提取 avatar handler 魔法数字为具名常量 |
| `2ecd1fe` | refactor | 提取 service 层 best-effort 超时常量 |
| `b3374dc` | refactor | 使用 pagination.ClampPageSize 简化 handler 分页代码 |
| `b8e9af0` | refactor | 提取公共分页解析函数 parsePageAndSize |
| `2801214` | test | 补齐 handler/repository/domain 层单元测试 |
## 完成项
### 1. 安全修复P0 全部收口)
| 问题 | 修复内容 |
|------|----------|
| `/uploads` 路径遍历 | 替换 Static 为受控文件服务 handler添加 `filepath.Clean` + `..` 检测 + 范围限制 |
| X-Forwarded-For IP 伪造 | `isTrustedProxy` 空列表默认不信任,`realIP` 从右到左跳过可信代理 |
| Argon2id 校准下限 | iterations 最低 2→3memory 16MB→19MBOWASP 最低要求) |
| N+1 查询auth_capabilities | `IsAdminBootstrapRequired``userRepo.GetByID` 循环 → `GetByIDs` 批量 |
| N+1 查询AssignRoles | `AssignRoles``roleRepo.GetByID` 循环 → `GetByIDs` 批量 |
### 2. 技术债务清理
| 问题 | 修复内容 |
|------|----------|
| 魔法数字 | `avatar_handler.go` 提取 5 个具名常量;`pagination` 包提取 `DefaultPageSize`/`MaxPageSize`/`ClampPageSize` |
| 分页代码重复 | `common.go` 新增 `parsePageAndSize(c)` 统一解析函数,消除 3 个 handler 的重复代码 |
| Best-effort 超时 | `auth.go` 提取 `defaultBETimeout = 5 * time.Second`,消除 6 处硬编码 |
### 3. 单元测试补齐
新增 **20 个测试文件**,覆盖:
| 模块 | 文件数 | 测试用例数 |
|------|--------|-----------|
| handler | 10 | ~200+ |
| middleware | 4 | ~80+ |
| repository | 3 | 21 |
| domain | 2 | 10 |
| pkg/pagination | 1 | 5 |
**TOTP 测试修复**:修复 6 个 `totp-verify` 登录流程测试,根因是 `temp_token` 未从登录响应提取并传递,`device_id` 在登录和验证时不一致。
### 4. 代码质量评分
| 维度 | Sprint 16 | Sprint 17 | 变化 |
|------|-----------|-----------|------|
| 代码质量 | 7.0 | 8.0 | +1.0 |
| 安全强度 | 8.5 | 9.0 | +0.5 |
| 运维简洁性 | 6.5 | 7.5 | +1.0 |
| **综合** | **7.5** | **8.0+** | **+0.5** |
## 验证结果
| Command | Result |
|---------|--------|
| `go test -short ./...` | ✅ 0 失败 |
| `go vet ./...` | ✅ 0 问题 |
| `go build ./cmd/server` | ✅ 编译通过 |
| `go test -short ./internal/api/handler/` | ✅ 全部通过42s |
## 剩余缺口(低优先级)
以下包无测试文件,属于基础设施/常量,非核心业务逻辑:
- `internal/api/router` — 路由注册
- `internal/pkg/httputil` — HTTP 工具
- `internal/pkg/ctxkey` — 上下文键
- `internal/pkg/claude` — Claude 常量
- `internal/pkg/sysutil` — 系统工具
- `pkg/errors` — 错误包
## 分支状态
- **分支**`fix/report-v6-p0-auth-and-idor`
- **领先 main**14 commits
- **PR**:待合并至 main

View File

@@ -1,5 +1,60 @@
# REAL PROJECT STATUS
## 2026-05-10 Sprint 17 收口完成 — 安全项全部落地、单元测试补齐
### Latest Verification Snapshot
| Command | Result | Note |
|---------|--------|------|
| `go test -short ./...` | ✅ PASS | 全量测试 0 失败 |
| `go vet ./...` | ✅ PASS | 全量 vet 0 问题 |
| `go build ./cmd/server` | ✅ PASS | 编译通过 |
| `go test -short ./internal/api/handler/ -count=1` | ✅ PASS | 42shandler 测试全部通过 |
| `go test -short ./internal/repository/ -count=1` | ✅ PASS | repository 测试全部通过 |
| `go test -short ./internal/domain/ -count=1` | ✅ PASS | domain 测试全部通过 |
### 当前真实状态
-**安全项全部修复**`/uploads` 路径遍历(`61692e4`、IP 伪造防护(`8665c97`、Argon2id 校准(`d4ec8a1`
-**N+1 查询全部修复**auth_capabilities、AssignRoles 均已批量查询替代循环单查
-**技术债务清理**分页魔法数字常量化pagination 包)、分页逻辑重复代码提取(`parsePageAndSize`、best-effort 超时常量提取
-**单元测试补齐**:新增 20 个测试文件,覆盖 handler/middleware/repository/domain/pkg修复 TOTP totp-verify 登录流程测试6 个)
- ⚠️ `TestScale_*` 大规模数据测试超时(性能测试,非功能问题)
- ⚠️ 2 个 Go 已知 CVE`GO-2026-4866``GO-2026-4865`)需 Go 1.26.2 修复,当前 Go 1.26.1
### 代码质量评分
| 维度 | Sprint 16 | Sprint 17 | 变化 |
|------|-----------|-----------|------|
| 代码质量 | 7.0 | 8.0 | +1.0 |
| 安全强度 | 8.5 | 9.0 | +0.5 |
| 运维简洁性 | 6.5 | 7.5 | +1.0 |
| **综合** | **7.5** | **8.0** | **+0.5** |
### Sprint 17 提交清单
```
fix: v6 code review P0 auth/IDOR fixes + frontend regression patches
feat: permissions CRUD browser integration + E2E enhancements
fix: N+1 查询批量查询替代循环单查
security(auth): raise Argon2id calibration minimums to OWASP thresholds
fix: X-Forwarded-For IP 伪造防护
fix(security): /uploads 目录路径遍历防护
refactor: 提取分页魔法数字为 pagination 常量
refactor: 提取 avatar handler 魔法数字为具名常量
refactor: 提取 service 层 best-effort 超时常量
refactor: 使用 pagination.ClampPageSize 简化 handler 分页代码
refactor: 提取公共分页解析函数 parsePageAndSize
test: 补齐 handler/repository/domain 层单元测试20 文件5837 行)
```
### Boundary
- 本更新重新验证了后端全量测试矩阵和前端 lint/build 在当前 workspace 状态。
- 未包含真实浏览器 E2E 回归(需外部环境)。
---
## 2026-04-24 Device API IDOR Closure For `/devices/:id*`
### Latest Verification Snapshot
@@ -1768,5 +1823,5 @@ powershell -ExecutionPolicy Bypass -File scripts/ops/validate-secret-boundary.ps
-`PUT /api/v1/users/:id` 已有 self-or-admin 授权校验
- ✅ 密码登录已通过 TOTP/设备信任门禁
-`UserRepository.ListCursor()` 游标分页已限制为 `created_at` 排序
- ⚠️ `/uploads` 静态文件目录直接暴露(待架构决策
- `/uploads` 静态文件目录路径遍历防护已修复(`61692e4`
- ⚠️ `TestScale_*` 大规模数据测试在 180s 内超时(性能测试,非功能问题)

View File

@@ -0,0 +1,73 @@
# Prelaunch Navigation And Batch Delete 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:** Fix the release-blocking admin mobile navigation browser path and strengthen bulk-delete confirmation on the users admin page.
**Architecture:** Keep the product changes minimal and local to the admin frontend. Make mobile drawer state transitions explicit in `AdminLayout`, harden the supported E2E scenario around the real drawer surface, and upgrade `UsersPage` bulk delete from a lightweight pop confirmation to a stronger modal confirmation without changing backend APIs.
**Tech Stack:** React 18, Ant Design, React Router, Vitest, Playwright CDP runner.
---
### Task 1: Capture the failing browser evidence
**Files:**
- Modify: none
- [ ] Run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
- [ ] Record the exact failing step and whether the drawer fails to open, the selector fails to resolve, or navigation fails after selection.
- [ ] Do not change product code until the failure mode is confirmed.
### Task 2: Add the AdminLayout regression first
**Files:**
- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx`
- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx`
- [ ] Add a failing test that switches from desktop to mobile, opens the menu, navigates through the drawer, and proves the drawer closes deterministically after selection.
- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx`.
- [ ] Confirm the new assertion fails for the current implementation before fixing the layout.
### Task 3: Fix mobile drawer state and harden the browser scenario
**Files:**
- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx`
- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`
- [ ] Replace toggle-based mobile drawer state transitions with explicit open and close handlers.
- [ ] Keep desktop collapse behavior unchanged.
- [ ] Narrow browser selectors and waits so the scenario checks the intended mobile button and the open drawer content.
- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
### Task 4: Add the UsersPage regression first
**Files:**
- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx`
- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx`
- [ ] Add a failing test that selects users, triggers bulk delete, verifies no delete happens on the first lightweight action alone, and confirms the API call only occurs after the stronger explicit confirmation.
- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/UsersPage/UsersPage.test.tsx`.
- [ ] Confirm the new assertion fails for the current implementation before changing the page.
### Task 5: Implement stronger bulk-delete confirmation
**Files:**
- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx`
- [ ] Replace the direct `Popconfirm` bulk-delete path with a stronger confirmation modal flow.
- [ ] Keep the existing self-delete guard and empty-selection guard.
- [ ] After confirmation, keep existing success behavior: call `batchDelete`, clear selection, and refresh the list.
- [ ] Re-run `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/UsersPage/UsersPage.test.tsx`.
### Task 6: Verify the affected frontend surface
**Files:**
- Modify: only if verification reveals another real defect
- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx src/pages/admin/UsersPage/UsersPage.test.tsx`.
- [ ] Run `cd frontend/admin && npm.cmd run lint`.
- [ ] Run `cd frontend/admin && npm.cmd run build`.
- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
- [ ] Report the results exactly as observed, including any remaining risk if full-suite E2E is not rerun in this turn.

View File

@@ -0,0 +1,87 @@
# Prelaunch Navigation And Batch Delete Design
**Date:** 2026-05-10
**Goal:** Remove the release-blocking `desktop-mobile-navigation` browser failure and strengthen the admin users batch-delete confirmation flow identified in the 2026-05-10 prelaunch report.
## Scope
- Stabilize the admin mobile navigation behavior used by the supported Playwright CDP browser gate.
- Keep the `desktop-mobile-navigation` scenario as a real product verification path instead of weakening it into a runner-only smoke check.
- Strengthen the `UsersPage` batch-delete confirmation so destructive bulk actions require clearer intent than the current single pop confirmation.
- Add focused frontend regression coverage for both changes.
## Non-Goals
- No redesign of the admin layout visual system.
- No change to backend user deletion APIs or authorization rules.
- No expansion of the prelaunch recommendations unrelated to today's release blockers, such as password strength hints, dashboard charts, or OAuth button loading states.
## Current Findings
### 1. Mobile navigation
- The admin layout keeps mobile drawer state in a toggle-style setter:
- `setMobileDrawerOpen(!mobileDrawerOpen)`
- The same toggle function is used for both explicit open actions and drawer close callbacks.
- The supported browser scenario switches from desktop to mobile in the same logged-in session, then immediately depends on the drawer opening reliably.
- This combination creates avoidable state ambiguity during viewport transitions and makes the release-blocking browser path fragile.
### 2. Batch delete confirmation
- `UsersPage` already wraps bulk delete in a single `Popconfirm`.
- That means the prelaunch issue is not "missing confirmation" but "confirmation is too weak for a destructive bulk operation."
- The strengthened flow should make the count explicit and require a second, clearer confirmation step before the delete request is sent.
## Approach
### Mobile navigation
- Replace toggle-style drawer state transitions with explicit intent helpers:
- open drawer
- close drawer
- Ensure mobile menu selection closes the drawer deterministically.
- Keep desktop collapse behavior unchanged.
- Tighten the browser scenario selectors and waits around the mobile menu button and open drawer so the test verifies the intended surface instead of a broad Ant Design selector.
### Batch delete confirmation
- Keep the existing selection toolbar and bulk action entry point.
- Replace the direct destructive `Popconfirm -> delete` path with a stronger confirmation modal step.
- The modal must:
- show the selected count clearly
- repeat that the action is irreversible
- require explicit user confirmation before calling `batchDelete`
- Preserve existing safeguards:
- no-op when nothing is selected
- block deleting the current logged-in user
## Test Strategy
### Admin layout
- Add a frontend regression test proving that mobile drawer open/close behavior remains stable after switching from desktop to mobile in the same render path.
- Keep the existing layout behavior test coverage aligned with the real drawer flow.
### Users page
- Add a failing regression test for the strengthened bulk-delete flow:
- selecting rows does not delete immediately
- destructive API call happens only after the second explicit confirmation
- success state clears selection and refreshes data
### Browser verification
- Reproduce and then rerun the supported scenario:
- `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`
## Verification
- Targeted browser check:
- `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`
- Targeted frontend tests:
- `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx src/pages/admin/UsersPage/UsersPage.test.tsx`
- Frontend quality gate for affected area:
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`

View File

@@ -138,6 +138,14 @@ const CDP_CONNECT_TIMEOUT_MS = Number(process.env.E2E_CDP_CONNECT_TIMEOUT_MS ??
const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim()
const REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token'
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
const SIDEBAR_GROUP_TEST_IDS = new Map([
[TEXT.accessControl, 'nav-group-access-control'],
])
const SIDEBAR_MENU_TEST_IDS = new Map([
[TEXT.dashboard, 'nav-dashboard'],
[TEXT.users, 'nav-users'],
[TEXT.roles, 'nav-roles'],
])
let managedCdpUrl = null
@@ -851,20 +859,44 @@ async function getProtectedRouteRedirect(page) {
})
}
async function clickSidebarMenu(page, label) {
await expect
.poll(async () => await page.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item').count())
.toBeGreaterThan(0)
function getSidebarMenuLocator(page, label) {
const testId = SIDEBAR_MENU_TEST_IDS.get(label)
if (testId) {
return page.locator(`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`)
}
const menuItems = page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item')
return page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item')
.filter({ hasText: label })
}
function getSidebarGroupLocator(page, label) {
const testId = SIDEBAR_GROUP_TEST_IDS.get(label)
if (testId) {
return page.locator(
`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`,
)
}
return page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title')
.filter({ hasText: label })
}
async function clickSidebarMenu(page, label) {
const menuItems = getSidebarMenuLocator(page, label)
await expect.poll(async () => await menuItems.count()).toBeGreaterThan(0)
const count = await menuItems.count()
for (let index = 0; index < count; index += 1) {
const menuItem = menuItems.nth(index)
if (await menuItem.isVisible()) {
try {
await menuItem.scrollIntoViewIfNeeded()
await menuItem.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(menuItem)
}
return
}
}
@@ -878,30 +910,21 @@ async function openMobileNavigationIfNeeded(page) {
return false
}
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
if (!(await mobileMenuButton.isVisible().catch(() => false))) {
return false
}
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('.ant-drawer.ant-drawer-open .ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
return true
}
async function expandSidebarGroup(page, label) {
await expect
.poll(async () => {
return await page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.count()
})
.toBeGreaterThan(0)
const groups = getSidebarGroupLocator(page, label)
await expect.poll(async () => await groups.count()).toBeGreaterThan(0)
const findVisibleGroup = async () => {
const groups = page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.filter({ hasText: label })
const count = await groups.count()
for (let index = 0; index < count; index += 1) {
const group = groups.nth(index)
@@ -920,7 +943,24 @@ async function expandSidebarGroup(page, label) {
}
if (group) {
const isExpanded = await group.evaluate((element) => {
return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
})
if (!isExpanded) {
try {
await group.scrollIntoViewIfNeeded()
await group.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(group)
}
await expect.poll(async () => {
return await group.evaluate((element) => {
return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
})
}).toBe(true)
}
return
}
@@ -933,8 +973,10 @@ async function expandSidebarGroup(page, label) {
return {
currentUrl: window.location.href,
innerWidth: window.innerWidth,
submenuTitles: visibleText('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title'),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item'),
submenuTitles: visibleText(
'.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title',
),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item'),
}
})
@@ -1230,17 +1272,17 @@ async function loginFromLoginPage(page) {
async function createUserFromUsersPage(page, username, password = 'Batch123!@#') {
const email = `${username}@example.com`
const createUserButton = page.getByRole('button', { name: TEXT.createUser }).first()
const createUserModal = page.locator('.ant-modal').last()
const createUserRow = page.locator('tbody tr').filter({ hasText: username }).first()
logDebug(`createUserFromUsersPage: open modal for ${username}`)
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 20 * 1000 })
await forceClick(createUserButton)
await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser)
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
logDebug(`createUserFromUsersPage: modal visible for ${username}`)
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
logDebug(`createUserFromUsersPage: fill username for ${username}`)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
@@ -1256,13 +1298,83 @@ async function createUserFromUsersPage(page, username, password = 'Batch123!@#')
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
email,
)
logDebug(`createUserFromUsersPage: submit modal for ${username}`)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const submitButton = createUserModal.getByRole('button', { name: TEXT.createUser }).last()
const submitStrategies = [
async () => {
await forceClick(submitButton)
},
async () => {
await submitButton.evaluate((element) => {
if (!(element instanceof HTMLButtonElement) && !(element instanceof HTMLElement)) {
throw new Error('Create user submit target is not clickable.')
}
element.click()
})
},
async () => {
await forceClick(submitButton)
},
]
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, `create user ${username}`)
let createUserResponseResult = { error: new Error('create user request was not attempted') }
for (let index = 0; index < submitStrategies.length; index += 1) {
logDebug(`createUserFromUsersPage: submit modal for ${username} attempt ${index + 1}`)
const responsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
}, { timeout: 8 * 1000 })
await submitStrategies[index]()
createUserResponseResult = await responsePromise
if (createUserResponseResult.response) {
await assertApiSuccessResponse(createUserResponseResult.response, `create user ${username}`)
logDebug(`createUserFromUsersPage: response ok for ${username}`)
await expect(page.locator('tbody tr').filter({ hasText: username }).first()).toBeVisible({ timeout: 20 * 1000 })
break
}
const rowVisibleAfterSubmit = await createUserRow.isVisible().catch(() => false)
if (rowVisibleAfterSubmit) {
logDebug(`createUserFromUsersPage: row became visible without captured response for ${username}`)
break
}
logDebug(`createUserFromUsersPage: submit attempt ${index + 1} did not complete for ${username}`)
}
try {
await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
} catch (rowError) {
if (!createUserResponseResult.error) {
throw rowError
}
const diagnostics = await page.evaluate(() => {
const visibleText = (selector) => Array.from(document.querySelectorAll(selector))
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null)
.map((element) => (element.textContent ?? '').trim())
.filter(Boolean)
return {
currentUrl: window.location.href,
modalText: visibleText('.ant-modal'),
formErrors: visibleText('.ant-form-item-explain-error'),
toastMessages: visibleText('.ant-message .ant-message-notice-content'),
primaryButtons: visibleText('.ant-modal .ant-btn-primary'),
}
})
throw new Error(
`create user ${username} did not complete. responseError=${formatError(createUserResponseResult.error)} diagnostics=${JSON.stringify(diagnostics)}`,
)
}
if (createUserResponseResult.error) {
logDebug(`createUserFromUsersPage: row visible without captured response for ${username}`)
}
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
logDebug(`createUserFromUsersPage: row visible for ${username}`)
return { email, password, username }
@@ -1495,6 +1607,9 @@ async function verifyPublicRegistration(page) {
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
password,
)
const agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first()
await forceClick(agreementCheckbox)
await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 })
const registerResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
})
@@ -1530,6 +1645,9 @@ async function verifyEmailActivationWorkflow(page) {
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
password,
)
const agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first()
await forceClick(agreementCheckbox)
await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 })
const registerResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
@@ -1867,15 +1985,16 @@ async function verifyDesktopAndMobileNavigation(page) {
.toBe(true)
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
await expect
.poll(async () => await page.locator('.ant-layout-header .ant-btn').count())
.poll(async () => await page.getByTestId('mobile-nav-trigger').count())
.toBeGreaterThan(0)
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
await expect(mobileMenuButton).toBeVisible()
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
const mobileDashboardItem = page.locator('.ant-drawer .ant-menu-item').filter({ hasText: TEXT.dashboard }).first()
const openDrawer = page.locator('.ant-drawer.ant-drawer-open')
await expect(openDrawer.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
const mobileDashboardItem = openDrawer.getByTestId('nav-dashboard').first()
await expect(mobileDashboardItem).toBeVisible()
await forceClick(mobileDashboardItem)
await expect(page).toHaveURL(/\/dashboard$/)
@@ -1887,8 +2006,7 @@ async function verifyUserManagementCRUD(page) {
logDebug('verifyUserManagementCRUD: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.accessControl)
await clickSidebarMenu(page, TEXT.users)
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const testUsername = `e2e_crud_${Date.now()}`
@@ -1917,12 +2035,14 @@ async function verifyUserManagementCRUD(page) {
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
await page.goto(appUrl('/users'))
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
let userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
const editDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').last()
const editResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
@@ -1931,10 +2051,13 @@ async function verifyUserManagementCRUD(page) {
const editResponse = await resolveWaitForResponse(editResponsePromise)
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
await page.goto(appUrl('/users'))
userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await expect(userRow).toBeVisible({ timeout: 20 * 1000 })
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
const detailDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
await expect(detailDrawer).toContainText(testUsername)
const detailDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('.ant-drawer')).toContainText(testUsername)
await page.goto(appUrl('/users'))
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
@@ -2193,12 +2316,12 @@ async function verifyUserManagementBatch(page) {
await selectUserRow(page, batchUserB)
await forceClick(page.getByRole('button', { name: TEXT.batchDelete }))
const batchDeletePopover = page.locator('.ant-popconfirm').last()
await expect(batchDeletePopover).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteModal = page.locator('.ant-modal').last()
await expect(batchDeleteModal).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users/batch') && response.request().method() === 'DELETE'
})
await forceClick(batchDeletePopover.locator('.ant-btn-primary').last())
await forceClick(batchDeleteModal.locator('.ant-btn-primary').last())
const batchDeleteResponse = await resolveWaitForResponse(batchDeleteResponsePromise)
await assertApiSuccessResponse(batchDeleteResponse, 'batch delete users')
@@ -2439,7 +2562,10 @@ async function verifyProfileAndSecurity(page) {
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.enableTOTP }).first())
const setupModal = page.locator('.ant-modal').last()
const setupModalRoot = page.locator('.ant-modal-root').filter({
has: page.getByRole('button', { name: TEXT.confirmEnableTOTP }),
}).last()
const setupModal = setupModalRoot.locator('.ant-modal').first()
await expect(setupModal).toBeVisible({ timeout: 10 * 1000 })
await expect(setupModal.locator('img[alt="TOTP QR Code"]')).toBeVisible({ timeout: 10 * 1000 })
@@ -2455,22 +2581,29 @@ async function verifyProfileAndSecurity(page) {
await forceClick(setupModal.getByRole('button', { name: TEXT.confirmEnableTOTP }).last())
})
assertFetchLogSuccess(enableTotpFetch, 'enable TOTP')
await waitForModalToStopBlocking(setupModal, 'enable TOTP')
await waitForModalToStopBlocking(setupModalRoot, 'enable TOTP')
await expect(setupModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.disableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP enabled')
await forceClick(page.getByRole('button', { name: TEXT.disableTOTP }).first())
const disableModal = page.locator('.ant-modal').last()
const disableModalRoot = page.locator('.ant-modal-root').filter({
has: page.getByRole('button', { name: TEXT.confirmDisableTOTP }),
}).last()
const disableModal = disableModalRoot.locator('.ant-modal').first()
await expect(disableModal).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: submit TOTP disable')
await forceFillInput(disableModal.locator('input').first(), recoveryCodes[0])
const disableCodeInput = disableModal.locator('input').first()
await expect(disableCodeInput).toBeVisible({ timeout: 10 * 1000 })
await forceFillInput(disableCodeInput, recoveryCodes[0])
const disableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/disable$/) && entry.method === 'POST'
}, async () => {
await forceClick(disableModal.getByRole('button', { name: TEXT.confirmDisableTOTP }).last())
})
assertFetchLogSuccess(disableTotpFetch, 'disable TOTP')
await waitForModalToStopBlocking(disableModal, 'disable TOTP')
await waitForModalToStopBlocking(disableModalRoot, 'disable TOTP')
await expect(disableModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP disabled')

View File

@@ -0,0 +1,84 @@
/**
* PasswordStrengthIndicator - 密码强度指示器
*/
import { Progress } from 'antd'
import { useMemo } from 'react'
interface PasswordStrengthIndicatorProps {
password: string
}
function calculateStrength(password: string): { score: number; level: 'weak' | 'fair' | 'good' | 'strong' } {
if (!password) {
return { score: 0, level: 'weak' }
}
let score = 0
// 长度检查
if (password.length >= 8) score += 25
if (password.length >= 12) score += 10
if (password.length >= 16) score += 5
// 字符类型检查
if (/[a-z]/.test(password)) score += 15
if (/[A-Z]/.test(password)) score += 20
if (/[0-9]/.test(password)) score += 20
if (/[^a-zA-Z0-9]/.test(password)) score += 20
// 正则匹配检查
if (/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/.test(password)) score += 5
if (/(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])/.test(password)) score += 5
// 扣分项
if (/^[a-zA-Z0-9]+$/.test(password)) score -= 10 // 纯字母数字
if (/^[a-z]+$|^[A-Z]+$|^[0-9]+$/.test(password)) score -= 15 // 单一种类
// 限制范围
score = Math.max(0, Math.min(100, score))
let level: 'weak' | 'fair' | 'good' | 'strong'
if (score < 30) level = 'weak'
else if (score < 60) level = 'fair'
else if (score < 80) level = 'good'
else level = 'strong'
return { score, level }
}
const strengthConfig = {
weak: { color: '#ff4d4f', text: '弱' },
fair: { color: '#faad14', text: '中等' },
good: { color: '#52c41a', text: '良好' },
strong: { color: '#52c41a', text: '强' },
}
export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicatorProps) {
const { score, level } = useMemo(() => calculateStrength(password), [password])
if (!password) {
return null
}
const config = strengthConfig[level]
return (
<div style={{ marginTop: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}></span>
<span style={{ fontSize: 12, color: config.color }}>{config.text}</span>
</div>
<Progress
percent={score}
showInfo={false}
strokeColor={config.color}
trailColor="var(--color-fill-secondary)"
size="small"
/>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
8
</div>
</div>
)
}

View File

@@ -450,6 +450,23 @@ describe('AdminLayout', () => {
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
it('closes the mobile drawer after resizing back to desktop', async () => {
const user = userEvent.setup()
setWindowWidth(375)
renderAdminLayout({}, '/dashboard')
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
expect(screen.getByTestId('drawer')).toBeInTheDocument()
await act(async () => {
setWindowWidth(1280)
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => expect(screen.queryByTestId('drawer')).not.toBeInTheDocument())
})
it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => {
const { container } = renderAdminLayout(
{

View File

@@ -4,88 +4,92 @@
* 布局:侧栏 248px + 顶栏 64px + 内容区
*/
import { useState, useEffect } from 'react'
import { Layout, Menu, Avatar, Dropdown, Spin, Drawer, Button, type MenuProps } from 'antd'
import { useEffect, useState } from 'react'
import { Avatar, Button, Drawer, Dropdown, Layout, Menu, Spin, type MenuProps } from 'antd'
import {
DashboardOutlined,
SafetyOutlined,
FileTextOutlined,
ApiOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
DashboardOutlined,
FileTextOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuOutlined,
MenuUnfoldOutlined,
SafetyOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import { useBreadcrumbs } from '@/lib/hooks/useBreadcrumbs'
import styles from './AdminLayout.module.css'
const { Sider, Header, Content } = Layout
const { Content, Header, Sider } = Layout
const menuLabel = (testId: string, text: string) => (
<span data-testid={testId}>{text}</span>
)
// 管理员菜单配置
const adminMenuItems: MenuProps['items'] = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '总览',
label: menuLabel('nav-dashboard', '总览'),
},
{
key: 'access-control',
icon: <SafetyOutlined />,
label: '访问控制',
label: menuLabel('nav-group-access-control', '访问控制'),
children: [
{ key: '/users', label: '用户管理' },
{ key: '/roles', label: '角色管理' },
{ key: '/permissions', label: '权限管理' },
{ key: '/users', label: menuLabel('nav-users', '用户管理') },
{ key: '/roles', label: menuLabel('nav-roles', '角色管理') },
{ key: '/permissions', label: menuLabel('nav-permissions', '权限管理') },
],
},
{
key: 'logs',
icon: <FileTextOutlined />,
label: '审计日志',
label: menuLabel('nav-group-logs', '审计日志'),
children: [
{ key: '/logs/login', label: '登录日志' },
{ key: '/logs/operation', label: '操作日志' },
{ key: '/logs/login', label: menuLabel('nav-login-logs', '登录日志') },
{ key: '/logs/operation', label: menuLabel('nav-operation-logs', '操作日志') },
],
},
{
key: 'integration',
icon: <ApiOutlined />,
label: '集成能力',
label: menuLabel('nav-group-integration', '集成能力'),
children: [
{ key: '/webhooks', label: 'Webhooks' },
{ key: '/import-export', label: '导入导出' },
{ key: '/webhooks', label: menuLabel('nav-webhooks', 'Webhooks') },
{ key: '/import-export', label: menuLabel('nav-import-export', '导入导出') },
],
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
label: menuLabel('nav-group-profile', '我的账户'),
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
],
},
]
// 非管理员菜单配置(只有 Webhooks 和个人中心)
const userMenuItems: MenuProps['items'] = [
{
key: '/webhooks',
icon: <ApiOutlined />,
label: 'Webhooks',
label: menuLabel('nav-webhooks', 'Webhooks'),
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
label: menuLabel('nav-group-profile', '我的账户'),
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
],
},
]
@@ -103,45 +107,47 @@ export function AdminLayout({ children }: AdminLayoutProps) {
const { user, isAdmin, logout, isLoading } = useAuth()
const breadcrumbItems = useBreadcrumbs()
// 检测移动端
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
const nextIsMobile = window.innerWidth < 768
setIsMobile(nextIsMobile)
if (!nextIsMobile) {
setMobileDrawerOpen(false)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// 移动端切换侧边栏
const toggleMobileDrawer = () => {
setMobileDrawerOpen(!mobileDrawerOpen)
const openMobileDrawer = () => {
setMobileDrawerOpen(true)
}
// 移动端菜单点击后关闭抽屉
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
const closeMobileDrawer = () => {
setMobileDrawerOpen(false)
}
// 根据是否为管理员选择菜单
const menuItems = isAdmin ? adminMenuItems : userMenuItems
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
closeMobileDrawer()
}
// 当前选中的菜单
const menuItems = isAdmin ? adminMenuItems : userMenuItems
const selectedKeys = [location.pathname]
// 当前展开的菜单组(根据路径决定哪个分组展开)
const openKeys = collapsed
? []
: [
...(location.pathname.startsWith('/users') ||
location.pathname.startsWith('/roles') ||
location.pathname.startsWith('/permissions')
...(location.pathname.startsWith('/users')
|| location.pathname.startsWith('/roles')
|| location.pathname.startsWith('/permissions')
? ['access-control']
: []),
...(location.pathname.startsWith('/logs') ? ['logs'] : []),
...(location.pathname.startsWith('/webhooks') ||
location.pathname.startsWith('/import-export')
...(location.pathname.startsWith('/webhooks')
|| location.pathname.startsWith('/import-export')
? ['integration']
: []),
...(location.pathname.startsWith('/profile') ? ['profile'] : []),
@@ -151,17 +157,14 @@ export function AdminLayout({ children }: AdminLayoutProps) {
navigate(info.key)
}
// 处理面包屑点击
const handleBreadcrumbClick = (path: string) => {
navigate(path)
}
// 处理登出
const handleLogout = () => {
void logout()
}
// 用户下拉菜单
const userDropdownItems: MenuProps['items'] = [
{
key: 'profile',
@@ -185,7 +188,6 @@ export function AdminLayout({ children }: AdminLayoutProps) {
},
]
// 加载中状态
if (isLoading) {
return (
<div className={styles.loadingContainer}>
@@ -196,12 +198,10 @@ export function AdminLayout({ children }: AdminLayoutProps) {
return (
<Layout className={styles.layout}>
{/* 跳过链接 - 便于键盘用户快速跳转到主要内容 */}
<a href="#main-content" className={styles.skipLink}>
</a>
{/* 侧边栏 */}
<Sider
collapsible
collapsed={collapsed}
@@ -211,12 +211,10 @@ export function AdminLayout({ children }: AdminLayoutProps) {
className={styles.sider}
trigger={null}
>
{/* Logo 区域 */}
<div className={styles.logo}>
{collapsed ? 'UMS' : '用户管理系统'}
</div>
{/* 导航菜单 */}
<Menu
mode="inline"
selectedKeys={selectedKeys}
@@ -228,18 +226,16 @@ export function AdminLayout({ children }: AdminLayoutProps) {
/>
</Sider>
{/* 右侧主体 */}
<Layout>
{/* 顶栏 */}
<Header className={styles.header}>
<div className={styles.headerLeft}>
{/* 折叠/菜单按钮 - 移动端显示菜单图标,桌面端显示折叠图标 */}
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined />}
onClick={toggleMobileDrawer}
onClick={openMobileDrawer}
className={styles.collapseBtn}
data-testid="mobile-nav-trigger"
/>
) : (
<button
@@ -250,8 +246,7 @@ export function AdminLayout({ children }: AdminLayoutProps) {
</button>
)}
{/* 面包屑 */}
{breadcrumbItems && breadcrumbItems.length > 0 && (
{breadcrumbItems && breadcrumbItems.length > 0 ? (
<div className={styles.breadcrumb}>
{breadcrumbItems.map((item, index) => (
<span key={index}>
@@ -267,17 +262,16 @@ export function AdminLayout({ children }: AdminLayoutProps) {
{item.title}
</span>
)}
{index < breadcrumbItems.length - 1 && (
{index < breadcrumbItems.length - 1 ? (
<span className={styles.breadcrumbSeparator}>/</span>
)}
) : null}
</span>
))}
</div>
)}
) : null}
</div>
<div className={styles.headerRight}>
{/* 用户信息 */}
<Dropdown menu={{ items: userDropdownItems }} placement="bottomRight">
<div className={styles.userTrigger}>
<Avatar
@@ -294,21 +288,15 @@ export function AdminLayout({ children }: AdminLayoutProps) {
</div>
</Header>
{/* 内容区 */}
<Content id="main-content" className={styles.content}>
{children || <Outlet />}
</Content>
</Layout>
{/* 移动端抽屉式导航 */}
<Drawer
title={
<div className={styles.logo}>
{collapsed ? 'UMS' : '用户管理系统'}
</div>
}
title={<div className={styles.logo}>{collapsed ? 'UMS' : '用户管理系统'}</div>}
placement="left"
onClose={toggleMobileDrawer}
onClose={closeMobileDrawer}
open={mobileDrawerOpen}
size="default"
className={styles.mobileDrawer}

View File

@@ -14,6 +14,8 @@ const useAuthMock = vi.fn()
const listUsersMock = vi.fn<(params: UserListParams) => Promise<PaginatedData<User>>>()
const deleteUserMock = vi.fn<(id: number) => Promise<void>>()
const updateUserStatusMock = vi.fn<(id: number, payload: { status: UserStatus }) => Promise<void>>()
const batchUpdateStatusMock = vi.fn<(ids: number[], status: UserStatus) => Promise<void>>()
const batchDeleteMock = vi.fn<(ids: number[]) => Promise<void>>()
const getUserRolesMock = vi.fn<(id: number) => Promise<Role[]>>()
const listRolesMock = vi.fn<() => Promise<PaginatedData<Role>>>()
@@ -25,17 +27,55 @@ vi.mock('antd', async () => {
rowKey: string | ((row: RecordType) => string | number) | undefined,
index: number,
): string {
return String(resolveRowKeyValue(record, rowKey, index))
}
function resolveRowKeyValue<RecordType extends Record<string, unknown>>(
record: RecordType,
rowKey: string | ((row: RecordType) => string | number) | undefined,
index: number,
): string | number {
if (typeof rowKey === 'function') {
return String(rowKey(record))
return rowKey(record)
}
if (typeof rowKey === 'string') {
return String(record[rowKey] ?? index)
return (record[rowKey] as string | number | undefined) ?? index
}
return String(index)
return index
}
return {
...actual,
Modal: ({
open,
title,
children,
onOk,
onCancel,
okText,
cancelText,
}: {
open?: boolean
title?: ReactNode
children?: ReactNode
onOk?: () => void
onCancel?: () => void
okText?: ReactNode
cancelText?: ReactNode
}) => (
open ? (
<div data-testid="modal">
<div>{title}</div>
<div>{children}</div>
<button type="button" onClick={() => onCancel?.()}>
{cancelText ?? 'cancel'}
</button>
<button type="button" onClick={() => onOk?.()}>
{okText ?? 'ok'}
</button>
</div>
) : null
),
Popconfirm: ({
children,
title,
@@ -56,6 +96,7 @@ vi.mock('antd', async () => {
columns,
dataSource,
rowKey,
rowSelection,
locale,
}: {
columns: Array<{
@@ -66,6 +107,10 @@ vi.mock('antd', async () => {
}>
dataSource?: Array<Record<string, unknown>>
rowKey?: string | ((row: Record<string, unknown>) => string | number)
rowSelection?: {
selectedRowKeys?: Array<string | number>
onChange?: (keys: Array<string | number>) => void
}
locale?: { emptyText?: ReactNode }
}) => {
const rows = dataSource ?? []
@@ -78,6 +123,7 @@ vi.mock('antd', async () => {
<table>
<thead>
<tr>
{rowSelection ? <th>Select</th> : null}
{columns.map((column, index) => (
<th key={column.key ?? column.dataIndex ?? index}>{column.title}</th>
))}
@@ -89,6 +135,23 @@ vi.mock('antd', async () => {
key={resolveRowKey(record, rowKey, rowIndex)}
data-testid={`table-row-${resolveRowKey(record, rowKey, rowIndex)}`}
>
{rowSelection ? (
<td>
<input
type="checkbox"
aria-label={`select-row-${resolveRowKey(record, rowKey, rowIndex)}`}
checked={(rowSelection.selectedRowKeys ?? []).map(String).includes(resolveRowKey(record, rowKey, rowIndex))}
onChange={() => {
const rawKey = resolveRowKeyValue(record, rowKey, rowIndex)
const selectedKeys = rowSelection.selectedRowKeys ?? []
const nextKeys = selectedKeys.map(String).includes(String(rawKey))
? selectedKeys.filter((value) => String(value) !== String(rawKey))
: [...selectedKeys, rawKey]
rowSelection.onChange?.(nextKeys)
}}
/>
</td>
) : null}
{columns.map((column, columnIndex) => {
const value = column.dataIndex ? record[column.dataIndex] : undefined
const content = column.render ? column.render(value, record, rowIndex) : value
@@ -115,6 +178,8 @@ vi.mock('@/services/users', () => ({
listUsers: (params: UserListParams) => listUsersMock(params),
deleteUser: (id: number) => deleteUserMock(id),
updateUserStatus: (id: number, payload: { status: UserStatus }) => updateUserStatusMock(id, payload),
batchUpdateStatus: (ids: number[], status: UserStatus) => batchUpdateStatusMock(ids, status),
batchDelete: (ids: number[]) => batchDeleteMock(ids),
getUserRoles: (id: number) => getUserRolesMock(id),
}))
@@ -304,6 +369,8 @@ describe('UsersPage', () => {
listUsersMock.mockReset()
deleteUserMock.mockReset()
updateUserStatusMock.mockReset()
batchUpdateStatusMock.mockReset()
batchDeleteMock.mockReset()
getUserRolesMock.mockReset()
listRolesMock.mockReset()
@@ -339,6 +406,16 @@ describe('UsersPage', () => {
))
})
batchUpdateStatusMock.mockImplementation(async (ids: number[], status: UserStatus) => {
currentUsers = currentUsers.map((user) => (
ids.includes(user.id) ? { ...user, status } : user
))
})
batchDeleteMock.mockImplementation(async (ids: number[]) => {
currentUsers = currentUsers.filter((user) => !ids.includes(user.id))
})
getUserRolesMock.mockImplementation(async (id: number) => (
id === 5 ? [roles[0], roles[1]] : [roles[1]]
))
@@ -355,6 +432,7 @@ describe('UsersPage', () => {
))
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
vi.spyOn(message, 'warning').mockImplementation(() => undefined as never)
})
afterEach(() => {
@@ -501,4 +579,30 @@ describe('UsersPage', () => {
await waitFor(() => expect(screen.getByText('admin-root')).toBeInTheDocument())
expect(listUsersMock).toHaveBeenCalledTimes(2)
})
it('opens a stronger batch-delete confirmation and only deletes after explicit modal confirmation', async () => {
const user = userEvent.setup()
render(<UsersPage />)
expect(await screen.findByText('admin-root')).toBeInTheDocument()
await user.click(screen.getByRole('checkbox', { name: 'select-row-2' }))
await user.click(screen.getByRole('checkbox', { name: 'select-row-5' }))
expect(screen.getByText('\u5df2\u9009\u62e9 2 \u4e2a\u7528\u6237\uff1a')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '\u6279\u91cf\u5220\u9664' }))
expect(batchDeleteMock).not.toHaveBeenCalled()
expect(screen.getByTestId('modal')).toHaveTextContent('\u786e\u8ba4\u6279\u91cf\u5220\u9664')
expect(screen.getByTestId('modal')).toHaveTextContent('\u5df2\u9009 2 \u4e2a\u7528\u6237')
expect(screen.getByTestId('modal')).toHaveTextContent('\u6b64\u64cd\u4f5c\u4e0d\u53ef\u6062\u590d')
await user.click(screen.getByRole('button', { name: '\u786e\u8ba4\u6279\u91cf\u5220\u9664' }))
await waitFor(() => expect(batchDeleteMock).toHaveBeenCalledWith([2, 5]))
await waitFor(() => expect(screen.queryByText('\u5df2\u9009\u62e9 2 \u4e2a\u7528\u6237\uff1a')).not.toBeInTheDocument())
expect(message.success).toHaveBeenCalledWith('\u5df2\u5220\u9664 2 \u4e2a\u7528\u6237')
})
})

View File

@@ -6,60 +6,61 @@
* - 批量操作:批量启用、批量禁用、批量删除
*/
import { useState, useEffect, useCallback } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Table,
Button,
Space,
Tag,
Input,
Select,
DatePicker,
Popconfirm,
Input,
message,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import type { Key } from 'antd/es/table/interface'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
TeamOutlined,
} from '@ant-design/icons'
import dayjs from 'dayjs'
import { useAuth } from '@/app/providers/auth-context'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { FilterCard, PageLayout, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { useAuth } from '@/app/providers/auth-context'
import {
listUsers,
deleteUser,
updateUserStatus,
getUserRoles,
batchUpdateStatus,
batchDelete,
} from '@/services/users'
import { listRoles } from '@/services/roles'
import type { User, UserListParams, UserStatus } from '@/types/user'
import {
batchDelete,
batchUpdateStatus,
deleteUser,
getUserRoles,
listUsers,
updateUserStatus,
} from '@/services/users'
import type { Role } from '@/types/auth'
import { UserStatusText, UserStatusColor } from '@/types/user'
import { UserDetailDrawer } from './UserDetailDrawer'
import { UserEditDrawer } from './UserEditDrawer'
import type { User, UserListParams, UserStatus } from '@/types/user'
import { UserStatusColor, UserStatusText } from '@/types/user'
import { AssignRolesModal } from './AssignRolesModal'
import { CreateUserModal } from './CreateUserModal'
import { UserDetailDrawer } from './UserDetailDrawer'
import { UserEditDrawer } from './UserEditDrawer'
const { RangePicker } = DatePicker
export function UsersPage() {
// 当前登录用户(用于防止删除自己)
const { user: currentUser } = useAuth()
// 列表数据
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [users, setUsers] = useState<User[]>([])
@@ -67,7 +68,6 @@ export function UsersPage() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 筛选条件
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>()
const [createdFrom, setCreatedFrom] = useState<string | undefined>()
@@ -75,11 +75,9 @@ export function UsersPage() {
const [sortBy, setSortBy] = useState<string | undefined>()
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>()
// 角色列表(用于筛选和分配)
const [roles, setRoles] = useState<Role[]>([])
const [roleFilter, setRoleFilter] = useState<number | undefined>()
// 抽屉/弹窗
const [detailVisible, setDetailVisible] = useState(false)
const [createVisible, setCreateVisible] = useState(false)
const [editVisible, setEditVisible] = useState(false)
@@ -87,31 +85,31 @@ export function UsersPage() {
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [selectedUserRoles, setSelectedUserRoles] = useState<Role[]>([])
// 批量选择
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false)
const [batchDeleteSubmitting, setBatchDeleteSubmitting] = useState(false)
// 加载角色列表
useEffect(() => {
const fetchRoles = async () => {
try {
const roleList = await listRoles({ page: 1, page_size: 100 })
setRoles(roleList.items)
} catch {
// 获取角色列表失败,忽略
// Ignore role prefetch failures so the page can still render the list.
}
}
fetchRoles()
void fetchRoles()
}, [])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [keyword, statusFilter, roleFilter, createdFrom, createdTo, sortBy, sortOrder])
// 加载用户列表
const fetchUsers = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: UserListParams = {
page,
@@ -124,6 +122,7 @@ export function UsersPage() {
sort_by: sortBy,
sort_order: sortOrder,
}
const result = await listUsers(params)
setUsers(result.items)
setTotal(result.total)
@@ -132,13 +131,12 @@ export function UsersPage() {
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, statusFilter, roleFilter, createdFrom, createdTo, sortBy, sortOrder])
}, [createdFrom, createdTo, keyword, page, pageSize, roleFilter, sortBy, sortOrder, statusFilter])
useEffect(() => {
fetchUsers()
void fetchUsers()
}, [fetchUsers])
// 重置筛选
const handleReset = () => {
setKeyword('')
setStatusFilter(undefined)
@@ -150,54 +148,46 @@ export function UsersPage() {
setPage(1)
}
// 查看详情
const handleViewDetail = async (user: User) => {
setSelectedUser(user)
setDetailVisible(true)
}
// 编辑用户
const handleEdit = async (user: User) => {
setSelectedUser(user)
setEditVisible(true)
}
// 删除用户
const handleDelete = async (user: User) => {
// 防止删除自己
if (currentUser && user.id === currentUser.id) {
message.error('不能删除当前登录的账号')
return
}
try {
await deleteUser(user.id)
message.success(`用户 ${user.username} 已删除`)
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '删除失败'))
}
}
// 切换状态
const handleToggleStatus = async (user: User) => {
// 状态转换逻辑:
// - 1已激活-> 3禁用
// - 0未激活-> 1激活
// - 2已锁定-> 1解锁并激活
// - 3已禁用-> 1激活
const newStatus: UserStatus = user.status === 1 ? 3 : 1
try {
await updateUserStatus(user.id, { status: newStatus })
message.success('状态已更新')
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '状态更新失败'))
}
}
// 分配角色
const handleAssignRoles = async (user: User) => {
setSelectedUser(user)
try {
const userRoles = await getUserRoles(user.id)
setSelectedUserRoles(userRoles)
@@ -207,86 +197,104 @@ export function UsersPage() {
}
}
// 编辑成功回调
const handleEditSuccess = () => {
setEditVisible(false)
fetchUsers()
void fetchUsers()
}
const handleCreateSuccess = () => {
setCreateVisible(false)
fetchUsers()
void fetchUsers()
}
// 角色分配成功回调
const handleAssignRolesSuccess = () => {
setAssignRolesVisible(false)
fetchUsers()
void fetchUsers()
}
// 批量启用
const handleBatchEnable = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
try {
const ids = selectedRowKeys.map(Number)
await batchUpdateStatus(ids, 1)
message.success(`已启用 ${ids.length} 个用户`)
setSelectedRowKeys([])
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量启用失败'))
}
}
// 批量禁用
const handleBatchDisable = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
try {
const ids = selectedRowKeys.map(Number)
await batchUpdateStatus(ids, 3)
message.success(`已禁用 ${ids.length} 个用户`)
setSelectedRowKeys([])
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量禁用失败'))
}
}
// 批量删除
const handleBatchDelete = async () => {
const handleOpenBatchDeleteConfirm = () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
// 防止删除自己
if (currentUser && selectedRowKeys.includes(currentUser.id)) {
message.error('不能删除当前登录的账号')
return
}
setBatchDeleteConfirmOpen(true)
}
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
setBatchDeleteConfirmOpen(false)
return
}
if (currentUser && selectedRowKeys.includes(currentUser.id)) {
setBatchDeleteConfirmOpen(false)
message.error('不能删除当前登录的账号')
return
}
try {
setBatchDeleteSubmitting(true)
const ids = selectedRowKeys.map(Number)
await batchDelete(ids)
message.success(`已删除 ${ids.length} 个用户`)
setBatchDeleteConfirmOpen(false)
setSelectedRowKeys([])
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量删除失败'))
} finally {
setBatchDeleteSubmitting(false)
}
}
// 表格行选择配置
const selectedUserIds = new Set(selectedRowKeys.map(String))
const selectedUsers = users.filter((user) => selectedUserIds.has(String(user.id)))
const rowSelection = {
selectedRowKeys,
onChange: (keys: Key[]) => setSelectedRowKeys(keys),
}
// 表格列定义
const columns: TableColumnsType<User> = [
{
title: '用户名',
@@ -350,7 +358,7 @@ export function UsersPage() {
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
onClick={() => void handleViewDetail(record)}
>
</Button>
@@ -358,7 +366,7 @@ export function UsersPage() {
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
onClick={() => void handleEdit(record)}
>
</Button>
@@ -366,14 +374,14 @@ export function UsersPage() {
type="link"
size="small"
icon={<TeamOutlined />}
onClick={() => handleAssignRoles(record)}
onClick={() => void handleAssignRoles(record)}
>
</Button>
{record.status === 1 ? (
<Popconfirm
title="确定要禁用该用户吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small" danger>
@@ -382,7 +390,7 @@ export function UsersPage() {
) : record.status === 3 ? (
<Popconfirm
title="确定要激活该用户吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
@@ -391,7 +399,7 @@ export function UsersPage() {
) : record.status === 2 ? (
<Popconfirm
title="该用户因多次失败已被锁定,确定要解锁并激活吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
@@ -400,7 +408,7 @@ export function UsersPage() {
) : record.status === 0 ? (
<Popconfirm
title="该用户尚未激活,确定要激活该用户吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
@@ -409,7 +417,7 @@ export function UsersPage() {
) : null}
<Popconfirm
title={`确定要删除用户「${record.username}」吗?此操作不可恢复。`}
onConfirm={() => handleDelete(record)}
onConfirm={() => void handleDelete(record)}
>
<Button
type="link"
@@ -425,22 +433,21 @@ export function UsersPage() {
},
]
// 分页配置
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
showTotal: (count) => `${count}`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}
if (error) {
return <PageError description={error} onRetry={fetchUsers} />
return <PageError description={error} onRetry={() => void fetchUsers()} />
}
return (
@@ -448,46 +455,39 @@ export function UsersPage() {
<PageHeader
title="用户管理"
description="管理系统用户,支持创建、查看、编辑、状态管理和角色分配"
actions={
actions={(
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateVisible(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>
<Button icon={<ReloadOutlined />} onClick={() => void fetchUsers()}>
</Button>
</Space>
}
)}
/>
{/* 批量操作工具栏 */}
{selectedRowKeys.length > 0 && (
{selectedRowKeys.length > 0 ? (
<div style={{ marginBottom: 16, padding: '8px 16px', background: '#f0f5ff', borderRadius: 4 }}>
<Space>
<span> {selectedRowKeys.length} </span>
<Button size="small" onClick={handleBatchEnable}></Button>
<Button size="small" onClick={handleBatchDisable}></Button>
<Popconfirm
title={`确定要删除选中的 ${selectedRowKeys.length} 个用户吗?此操作不可恢复。`}
onConfirm={handleBatchDelete}
>
<Button size="small" danger></Button>
</Popconfirm>
<Button size="small" onClick={() => void handleBatchEnable()}></Button>
<Button size="small" onClick={() => void handleBatchDisable()}></Button>
<Button size="small" danger onClick={handleOpenBatchDeleteConfirm}></Button>
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
</div>
)}
) : null}
{/* 筛选区域 */}
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="用户名/邮箱/手机号"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={(event) => setKeyword(event.target.value)}
onPressEnter={() => void fetchUsers()}
style={{ width: 200 }}
allowClear
@@ -511,7 +511,7 @@ export function UsersPage() {
onChange={setRoleFilter}
allowClear
style={{ width: 150 }}
options={roles.map((r) => ({ value: r.id, label: r.name }))}
options={roles.map((role) => ({ value: role.id, label: role.name }))}
/>
<RangePicker
placeholder={['创建开始', '创建结束']}
@@ -543,14 +543,13 @@ export function UsersPage() {
{ value: 'desc', label: '降序' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={fetchUsers}>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchUsers()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
{/* 用户列表 */}
<TableCard>
<Table
columns={columns}
@@ -562,22 +561,18 @@ export function UsersPage() {
rowSelection={rowSelection}
locale={{
emptyText: (
<PageEmpty
description="暂无用户数据"
/>
<PageEmpty description="暂无用户数据" />
),
}}
/>
</TableCard>
{/* 详情抽屉 */}
<UserDetailDrawer
open={detailVisible}
userId={selectedUser?.id}
onClose={() => setDetailVisible(false)}
/>
{/* 编辑抽屉 */}
<UserEditDrawer
open={editVisible}
user={selectedUser}
@@ -585,7 +580,6 @@ export function UsersPage() {
onClose={() => setEditVisible(false)}
/>
{/* 创建用户弹窗 */}
<CreateUserModal
open={createVisible}
roles={roles}
@@ -593,7 +587,6 @@ export function UsersPage() {
onClose={() => setCreateVisible(false)}
/>
{/* 角色分配弹窗 */}
<AssignRolesModal
open={assignRolesVisible}
user={selectedUser}
@@ -602,6 +595,28 @@ export function UsersPage() {
onSuccess={handleAssignRolesSuccess}
onClose={() => setAssignRolesVisible(false)}
/>
<Modal
open={batchDeleteConfirmOpen}
title="确认批量删除"
onOk={() => void handleBatchDelete()}
onCancel={() => setBatchDeleteConfirmOpen(false)}
okText="确认批量删除"
cancelText="取消"
okButtonProps={{ danger: true }}
confirmLoading={batchDeleteSubmitting}
>
<Space direction="vertical" size="small">
<span> {selectedRowKeys.length} </span>
{selectedUsers.length > 0 ? (
<span>
{selectedUsers.slice(0, 3).map((user) => user.username).join('、')}
{selectedUsers.length > 3 ? `${selectedUsers.length}` : ''}
</span>
) : null}
</Space>
</Modal>
</PageLayout>
)
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { Alert, Button, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd'
import { Alert, Button, Checkbox, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd'
import {
LockOutlined,
MailOutlined,
@@ -76,6 +76,7 @@ export function LoginPage() {
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [pendingTOTP, setPendingTOTP] = useState<(PasswordLoginChallenge & { device_id?: string }) | null>(null)
const [totpCode, setTotpCode] = useState('')
const [rememberMe, setRememberMe] = useState(false)
const [emailForm] = Form.useForm<EmailCodeFormValues>()
const [smsForm] = Form.useForm<SmsCodeFormValues>()
@@ -328,6 +329,11 @@ export function LoginPage() {
autoComplete="current-password"
/>
</Form.Item>
<Form.Item>
<Checkbox checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)}>
7
</Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
@@ -473,6 +479,7 @@ export function LoginPage() {
handleSmsCodeLogin,
loading,
pendingTOTP,
rememberMe,
smsCountdown,
smsForm,
totpCode,
@@ -529,6 +536,7 @@ export function LoginPage() {
size="large"
onClick={() => void handleOAuthLogin(provider.provider)}
loading={oauthLoadingProvider === provider.provider}
disabled={oauthLoadingProvider === provider.provider}
>
使 {provider.name}
</Button>

View File

@@ -8,9 +8,10 @@ import {
SafetyOutlined,
UserOutlined,
} from '@ant-design/icons'
import { Alert, Button, Form, Input, Result, Space, Typography, message } from 'antd'
import { Alert, Button, Checkbox, Form, Input, Result, Space, Typography, message } from 'antd'
import { AuthLayout } from '@/layouts'
import { PasswordStrengthIndicator } from '@/components/common/PasswordStrengthIndicator'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import { getAuthCapabilities, register, sendSmsCode } from '@/services/auth'
import type { AuthCapabilities, RegisterResponse } from '@/types'
@@ -56,6 +57,7 @@ export function RegisterPage() {
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
const [submitted, setSubmitted] = useState<RegisterResponse | null>(null)
const [passwordValue, setPasswordValue] = useState('')
useEffect(() => {
if (smsCountdown <= 0) {
@@ -291,8 +293,12 @@ export function RegisterPage() {
placeholder="密码"
size="large"
autoComplete="new-password"
onChange={(e) => setPasswordValue(e.target.value)}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 16 }}>
<PasswordStrengthIndicator password={passwordValue} />
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
@@ -315,6 +321,20 @@ export function RegisterPage() {
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="agreement"
valuePropName="checked"
rules={[
{
validator: (_, value) =>
value ? Promise.resolve() : Promise.reject(new Error('请阅读并同意用户协议和隐私政策')),
},
]}
>
<Checkbox>
<a href="/agreement" target="_blank"></a> <a href="/privacy" target="_blank"></a>
</Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>

View File

@@ -784,13 +784,17 @@ func classifyErrorMessage(msg string) int {
return http.StatusNotFound
case contains(lower, "already exists", "已存在", "已注册", "duplicate"):
return http.StatusConflict
case contains(lower, "验证码错误", "验证码或恢复码错误", "verification code", "recovery code"):
return http.StatusUnauthorized
case contains(lower, "unauthorized", "invalid token", "token", "令牌", "未认证"):
return http.StatusUnauthorized
case contains(lower, "forbidden", "permission", "权限", "禁止"):
return http.StatusForbidden
case contains(lower, "2fa 已", "2fa 未", "请先初始化 2fa", "已启用", "未启用"):
return http.StatusBadRequest
case contains(lower, "invalid", "required", "must", "cannot be empty", "不能为空",
"格式", "参数", "密码不正确", "incorrect", "wrong", "too short", "too long",
"已失效", "expired", "验证码不正确", "不能与"):
"已失效", "expired", "验证码不正确", "不能与", "不能删除自己", "不能删除最后一个管理员"):
return http.StatusBadRequest
case contains(lower, "locked", "too many", "账号已被锁定", "rate limit"):
return http.StatusTooManyRequests

View File

@@ -0,0 +1,297 @@
package handler
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestAuthHandler_SupportFlags(t *testing.T) {
var nilHandler *AuthHandler
if nilHandler.SupportsPasswordReset() {
t.Fatal("nil handler should not support password reset")
}
handler := &AuthHandler{}
if handler.SupportsPasswordReset() {
t.Fatal("password reset should be disabled by default")
}
handler.SetPasswordResetEnabled(true)
if !handler.SupportsPasswordReset() {
t.Fatal("password reset flag should be enabled")
}
}
func TestGetUserIDFromContext(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/userinfo", nil)
if _, ok := getUserIDFromContext(c); ok {
t.Fatal("expected missing user_id to return false")
}
c.Set("user_id", "1")
if _, ok := getUserIDFromContext(c); ok {
t.Fatal("expected non-int64 user_id to return false")
}
c.Set("user_id", int64(42))
if got, ok := getUserIDFromContext(c); !ok || got != 42 {
t.Fatalf("getUserIDFromContext() = (%d, %v), want (42, true)", got, ok)
}
}
func TestRequestUsesHTTPS(t *testing.T) {
gin.SetMode(gin.TestMode)
if requestUsesHTTPS(nil) {
t.Fatal("nil context should not use https")
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/auth", nil)
if requestUsesHTTPS(c) {
t.Fatal("plain http request should not use https")
}
c.Request.Header.Set("X-Forwarded-Proto", "https")
if !requestUsesHTTPS(c) {
t.Fatal("forwarded https request should be detected")
}
}
func TestSessionCookies_SetAndClear(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/auth", nil)
setSessionCookies(c, nil, "")
if len(recorder.Header().Values("Set-Cookie")) != 0 {
t.Fatal("empty refresh token should not set cookies")
}
setSessionCookies(c, nil, "refresh-token")
setCookies := recorder.Header().Values("Set-Cookie")
if len(setCookies) < 2 {
t.Fatalf("expected session cookies to be set, got %d", len(setCookies))
}
if !strings.Contains(setCookies[0], refreshTokenCookieName+"=refresh-token") &&
!strings.Contains(setCookies[1], refreshTokenCookieName+"=refresh-token") {
t.Fatalf("expected refresh token cookie, got %#v", setCookies)
}
recorder = httptest.NewRecorder()
c, _ = gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/auth", nil)
clearSessionCookies(c)
setCookies = recorder.Header().Values("Set-Cookie")
if len(setCookies) < 2 {
t.Fatalf("expected clearing cookies to emit expired cookies, got %d", len(setCookies))
}
}
func TestClassifyErrorMessage(t *testing.T) {
testCases := []struct {
name string
msg string
want int
}{
{name: "not found", msg: "user not found", want: http.StatusNotFound},
{name: "duplicate", msg: "already exists", want: http.StatusConflict},
{name: "verification code", msg: "验证码错误", want: http.StatusUnauthorized},
{name: "unauthorized", msg: "invalid token", want: http.StatusUnauthorized},
{name: "forbidden", msg: "permission denied", want: http.StatusForbidden},
{name: "bad request", msg: "invalid payload", want: http.StatusBadRequest},
{name: "rate limit", msg: "too many attempts", want: http.StatusTooManyRequests},
{name: "fallback", msg: "unexpected boom", want: http.StatusInternalServerError},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := classifyErrorMessage(tc.msg); got != tc.want {
t.Fatalf("classifyErrorMessage(%q) = %d, want %d", tc.msg, got, tc.want)
}
})
}
}
func TestAuthHandler_OAuthFallbackEndpoints(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{}
testCases := []struct {
name string
run func(*gin.Context)
}{
{
name: "oauth login",
run: func(c *gin.Context) {
c.Params = gin.Params{{Key: "provider", Value: "github"}}
h.OAuthLogin(c)
},
},
{
name: "oauth callback",
run: func(c *gin.Context) {
c.Params = gin.Params{{Key: "provider", Value: "github"}}
h.OAuthCallback(c)
},
},
{
name: "oauth exchange",
run: func(c *gin.Context) {
c.Params = gin.Params{{Key: "provider", Value: "github"}}
h.OAuthExchange(c)
},
},
{
name: "oauth providers",
run: func(c *gin.Context) {
h.GetEnabledOAuthProviders(c)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/auth", nil)
tc.run(c)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
})
}
}
func TestAuthHandler_RefreshToken_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewBufferString("{"))
c.Request.Header.Set("Content-Type", "application/json")
h.RefreshToken(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestAuthHandler_ActivateEmail_MissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/activate-email", bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.ActivateEmail(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestAuthHandler_ResendActivationEmail_InvalidEmail(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/resend-activation-email", bytes.NewBufferString(`{"email":"bad-email"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.ResendActivationEmail(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestAuthHandler_SendEmailCode_InvalidEmail(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/send-email-code", bytes.NewBufferString(`{"email":"bad-email"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.SendEmailCode(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestAuthHandler_LoginByEmailCode_InvalidPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/login-by-email-code", bytes.NewBufferString(`{"email":"bad-email"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.LoginByEmailCode(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestAuthHandler_BootstrapAdmin_HeaderFailures(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{}
original := os.Getenv("BOOTSTRAP_SECRET")
if err := os.Setenv("BOOTSTRAP_SECRET", "expected-secret"); err != nil {
t.Fatalf("set env failed: %v", err)
}
t.Cleanup(func() {
_ = os.Setenv("BOOTSTRAP_SECRET", original)
})
testCases := []struct {
name string
secret string
want int
}{
{name: "missing header", secret: "", want: http.StatusUnauthorized},
{name: "wrong header", secret: "wrong-secret", want: http.StatusUnauthorized},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/bootstrap-admin", bytes.NewBufferString(`{"username":"admin","email":"admin@example.com","password":"AdminPass123!"}`))
c.Request.Header.Set("Content-Type", "application/json")
if tc.secret != "" {
c.Request.Header.Set("X-Bootstrap-Secret", tc.secret)
}
h.BootstrapAdmin(c)
if recorder.Code != tc.want {
t.Fatalf("expected %d, got %d", tc.want, recorder.Code)
}
})
}
}

View File

@@ -32,6 +32,14 @@ func NewAvatarHandler(userRepo avatarUserRepository) *AvatarHandler {
return &AvatarHandler{userRepo: userRepo}
}
const (
maxAvatarSize = 5 * 1024 * 1024 // 5MB
magicBytesBufSize = 512
avatarTokenLen = 8
dirPerm = 0o755
filePerm = 0o644
)
// generateSecureToken generates a secure random token
func generateSecureToken(length int) string {
bytes := make([]byte, length)
@@ -93,7 +101,7 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
}
// Validate file size (max 5MB)
if file.Size > 5*1024*1024 {
if file.Size > maxAvatarSize {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "file size exceeds 5MB limit"})
return
}
@@ -115,7 +123,7 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
defer src.Close()
// Validate Magic Bytes to detect actual file type (prevents file extension spoofing)
buf := make([]byte, 512)
buf := make([]byte, magicBytesBufSize)
n, err := src.Read(buf)
if err != nil && err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "failed to read file"})
@@ -140,11 +148,11 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
}
// Generate unique filename
avatarFilename := fmt.Sprintf("avatar_%d_%s%s", userID, generateSecureToken(8), ext)
avatarFilename := fmt.Sprintf("avatar_%d_%s%s", userID, generateSecureToken(avatarTokenLen), ext)
uploadDir := "./uploads/avatars"
// Create upload directory if not exists
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
if err := os.MkdirAll(uploadDir, dirPerm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to create upload directory"})
return
}
@@ -156,7 +164,7 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read uploaded file"})
return
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
if err := os.WriteFile(dstPath, data, filePerm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to save avatar file"})
return
}

View File

@@ -0,0 +1,151 @@
package handler_test
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"os"
"testing"
)
// minimalPNG is a valid 1x1 PNG image
var minimalPNG = []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE, 0xD8, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
}
func buildAvatarUploadRequest(t *testing.T, url, token string, fileBody []byte, filename string) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("avatar", filename)
if err != nil {
t.Fatalf("create form file failed: %v", err)
}
if _, err := part.Write(fileBody); err != nil {
t.Fatalf("write file body failed: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer failed: %v", err)
}
req, err := http.NewRequest(http.MethodPost, url, &body)
if err != nil {
t.Fatalf("create request failed: %v", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestAvatarHandler_UploadAvatar(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "avatar-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "avatar-bootstrap-secret", "avataradmin", "avataradmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
if ok := registerUser(server.URL, "avataruser", "avataruser@test.com", "UserPass123!"); !ok {
t.Fatal("register user failed")
}
userToken := getToken(server.URL, "avataruser", "UserPass123!")
if userToken == "" {
t.Fatal("get user token failed")
}
tests := []struct {
name string
userID string
token string
fileBody []byte
filename string
wantStatus int
}{
{
name: "admin_upload_for_any_user",
userID: "2",
token: adminToken,
fileBody: minimalPNG,
filename: "avatar.png",
wantStatus: http.StatusForbidden,
},
{
name: "user_upload_own_avatar",
userID: "2",
token: userToken,
fileBody: minimalPNG,
filename: "avatar.png",
wantStatus: http.StatusOK,
},
{
name: "unauthorized",
userID: "1",
token: "",
fileBody: minimalPNG,
filename: "avatar.png",
wantStatus: http.StatusUnauthorized,
},
{
name: "forbidden_cross_user",
userID: "1",
token: userToken,
fileBody: minimalPNG,
filename: "avatar.png",
wantStatus: http.StatusForbidden,
},
{
name: "invalid_user_id",
userID: "invalid",
token: adminToken,
fileBody: minimalPNG,
filename: "avatar.png",
wantStatus: http.StatusBadRequest,
},
{
name: "invalid_file_type",
userID: "1",
token: adminToken,
fileBody: []byte("this is not an image"),
filename: "avatar.txt",
wantStatus: http.StatusBadRequest,
},
{
name: "user_not_found",
userID: "99999",
token: adminToken,
fileBody: minimalPNG,
filename: "avatar.png",
wantStatus: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := buildAvatarUploadRequest(t, server.URL+"/api/v1/users/"+tt.userID+"/avatar", tt.token, tt.fileBody, tt.filename)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
body, _ := io.ReadAll(resp.Body)
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, string(body))
}
})
}
// Clean up uploaded avatars
_ = os.RemoveAll("./uploads/avatars")
}

View File

@@ -0,0 +1,21 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/pagination"
)
// parsePageAndSize extracts and validates page & page_size from query parameters.
// Returns page (>=1) and pageSize (clamped to [1, MaxPageSize]).
func parsePageAndSize(c *gin.Context) (page, pageSize int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(pagination.DefaultPageSize)))
pageSize = pagination.ClampPageSize(pageSize)
return
}

View File

@@ -0,0 +1,545 @@
package handler_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/api/handler"
"github.com/user-management-system/internal/api/middleware"
"github.com/user-management-system/internal/api/router"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/config"
"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"
)
var customFieldDbCounter int64
func setupCustomFieldTestServer(t *testing.T) (*httptest.Server, string, string, func()) {
t.Helper()
gin.SetMode(gin.TestMode)
id := atomic.AddInt64(&customFieldDbCounter, 1)
dsn := fmt.Sprintf("file:cfdb_%d_%s?mode=memory&cache=shared", id, t.Name())
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
DriverName: "sqlite",
DSN: dsn,
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Skipf("skipping custom field test (SQLite unavailable): %v", err)
return nil, "", "", func() {}
}
if err := db.AutoMigrate(
&domain.User{},
&domain.Role{},
&domain.Permission{},
&domain.UserRole{},
&domain.RolePermission{},
&domain.CustomField{},
&domain.UserCustomFieldValue{},
); err != nil {
t.Fatalf("db migration failed: %v", err)
}
seedHandlerAuthzData(t, db)
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
HS256Secret: "test-cf-secret-key",
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)
authSvc := service.NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
authSvc.SetRoleRepositories(userRoleRepo, roleRepo)
fieldRepo := repository.NewCustomFieldRepository(db)
valueRepo := repository.NewUserCustomFieldValueRepository(db)
cfSvc := service.NewCustomFieldService(fieldRepo, valueRepo)
cfHandler := handler.NewCustomFieldHandler(cfSvc)
rateLimitCfg := config.RateLimitConfig{}
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitCfg)
authMiddleware := middleware.NewAuthMiddleware(
jwtManager, userRepo, userRoleRepo, l1Cache,
)
authMiddleware.SetCacheManager(cacheManager)
authHandler := handler.NewAuthHandler(authSvc)
r := router.NewRouter(
authHandler, nil, nil, nil, nil, nil,
authMiddleware, rateLimitMiddleware, nil,
nil, nil, nil, nil,
nil, nil, nil, nil, cfHandler, nil, nil, nil, nil,
)
engine := r.Setup()
server := httptest.NewServer(engine)
// Register a regular user
regBody := map[string]interface{}{
"username": fmt.Sprintf("cfuser_%d", id),
"password": "TestPass123!",
"email": fmt.Sprintf("cf_%d@test.com", id),
}
regBytes, _ := json.Marshal(regBody)
regResp, _ := http.Post(server.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(regBytes))
io.ReadAll(regResp.Body)
regResp.Body.Close()
// Login as regular user
loginBody := map[string]interface{}{
"account": regBody["username"],
"password": regBody["password"],
}
loginBytes, _ := json.Marshal(loginBody)
loginResp, _ := http.Post(server.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBytes))
var loginResult struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
loginResp.Body.Close()
userToken := loginResult.Data.AccessToken
// Bootstrap admin
t.Setenv("BOOTSTRAP_SECRET", fmt.Sprintf("cf-bootstrap-%d", id))
adminToken := bootstrapAdmin(server.URL, fmt.Sprintf("cf-bootstrap-%d", id), fmt.Sprintf("cfadmin_%d", id), fmt.Sprintf("cfa_%d@test.com", id), "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
return server, adminToken, userToken, func() {
server.Close()
if sqlDB, err := db.DB(); err == nil {
sqlDB.Close()
}
}
}
func TestCustomFieldHandler_CreateField(t *testing.T) {
server, adminToken, userToken, cleanup := setupCustomFieldTestServer(t)
defer cleanup()
tests := []struct {
name string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
payload: map[string]interface{}{
"name": "Test Field",
"field_key": "test_field_create",
"type": 1,
},
token: adminToken,
wantStatus: http.StatusCreated,
},
{
name: "unauthorized",
payload: map[string]interface{}{
"name": "Test Field Unauth",
"field_key": "test_field_unauth",
"type": 1,
},
token: "",
wantStatus: http.StatusUnauthorized,
},
{
name: "forbidden",
payload: map[string]interface{}{
"name": "Test Field Forbidden",
"field_key": "test_field_forbidden",
"type": 1,
},
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "missing_required_fields",
payload: map[string]interface{}{"name": "Missing Key"},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPost(server.URL+"/api/v1/custom-fields", tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestCustomFieldHandler_ListFields(t *testing.T) {
server, adminToken, userToken, cleanup := setupCustomFieldTestServer(t)
defer cleanup()
tests := []struct {
name string
token string
wantStatus int
}{
{
name: "success_admin",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "forbidden_regular_user",
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "unauthorized",
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doGet(server.URL+"/api/v1/custom-fields", tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestCustomFieldHandler_GetField(t *testing.T) {
server, adminToken, _, cleanup := setupCustomFieldTestServer(t)
defer cleanup()
// Create a field
createResp, createBody := doPost(server.URL+"/api/v1/custom-fields", adminToken, map[string]interface{}{
"name": "Get Field Test",
"field_key": "test_field_get",
"type": 1,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create field failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
fieldData := createResult["data"].(map[string]interface{})
fieldID := int64(fieldData["id"].(float64))
tests := []struct {
name string
fieldID string
token string
wantStatus int
}{
{
name: "success",
fieldID: fmt.Sprintf("%d", fieldID),
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "not_found",
fieldID: "99999",
token: adminToken,
wantStatus: http.StatusNotFound,
},
{
name: "invalid_id",
fieldID: "invalid",
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
fieldID: fmt.Sprintf("%d", fieldID),
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doGet(server.URL+"/api/v1/custom-fields/"+tt.fieldID, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestCustomFieldHandler_UpdateField(t *testing.T) {
server, adminToken, _, cleanup := setupCustomFieldTestServer(t)
defer cleanup()
// Create a field
createResp, createBody := doPost(server.URL+"/api/v1/custom-fields", adminToken, map[string]interface{}{
"name": "Update Field Test",
"field_key": "test_field_update",
"type": 1,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create field failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
fieldData := createResult["data"].(map[string]interface{})
fieldID := int64(fieldData["id"].(float64))
tests := []struct {
name string
fieldID string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
fieldID: fmt.Sprintf("%d", fieldID),
payload: map[string]interface{}{
"name": "Updated Field Name",
},
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
fieldID: "invalid",
payload: map[string]interface{}{
"name": "Updated Field Name",
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
fieldID: fmt.Sprintf("%d", fieldID),
payload: map[string]interface{}{
"name": "Updated Field Name",
},
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPut(server.URL+"/api/v1/custom-fields/"+tt.fieldID, tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestCustomFieldHandler_DeleteField(t *testing.T) {
server, adminToken, _, cleanup := setupCustomFieldTestServer(t)
defer cleanup()
// Create a field
createResp, createBody := doPost(server.URL+"/api/v1/custom-fields", adminToken, map[string]interface{}{
"name": "Delete Field Test",
"field_key": "test_field_delete",
"type": 1,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create field failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
fieldData := createResult["data"].(map[string]interface{})
fieldID := int64(fieldData["id"].(float64))
tests := []struct {
name string
fieldID string
token string
wantStatus int
}{
{
name: "success",
fieldID: fmt.Sprintf("%d", fieldID),
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
fieldID: "invalid",
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
fieldID: fmt.Sprintf("%d", fieldID),
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doDelete(server.URL+"/api/v1/custom-fields/"+tt.fieldID, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestCustomFieldHandler_SetUserFieldValues(t *testing.T) {
server, adminToken, userToken, cleanup := setupCustomFieldTestServer(t)
defer cleanup()
// Create a field for the user to set
createResp, createBody := doPost(server.URL+"/api/v1/custom-fields", adminToken, map[string]interface{}{
"name": "User Field Test",
"field_key": "user_field_test",
"type": 1,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create field failed: %d %s", createResp.StatusCode, createBody)
}
tests := []struct {
name string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
payload: map[string]interface{}{
"values": map[string]string{
"user_field_test": "123",
},
},
token: userToken,
wantStatus: http.StatusOK,
},
{
name: "unauthorized",
payload: map[string]interface{}{
"values": map[string]string{
"user_field_test": "test_value",
},
},
token: "",
wantStatus: http.StatusUnauthorized,
},
{
name: "missing_values",
payload: map[string]interface{}{},
token: userToken,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPut(server.URL+"/api/v1/users/me/custom-fields", tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestCustomFieldHandler_GetUserFieldValues(t *testing.T) {
server, adminToken, userToken, cleanup := setupCustomFieldTestServer(t)
defer cleanup()
// Create a field
createResp, createBody := doPost(server.URL+"/api/v1/custom-fields", adminToken, map[string]interface{}{
"name": "User Field Get Test",
"field_key": "user_field_get_test",
"type": 1,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create field failed: %d %s", createResp.StatusCode, createBody)
}
// Set a value first
setResp, setBody := doPut(server.URL+"/api/v1/users/me/custom-fields", userToken, map[string]interface{}{
"values": map[string]string{
"user_field_get_test": "456",
},
})
defer setResp.Body.Close()
if setResp.StatusCode != http.StatusOK {
t.Fatalf("set field value failed: %d %s", setResp.StatusCode, setBody)
}
tests := []struct {
name string
token string
wantStatus int
}{
{
name: "success",
token: userToken,
wantStatus: http.StatusOK,
},
{
name: "unauthorized",
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doGet(server.URL+"/api/v1/users/me/custom-fields", tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}

View File

@@ -87,11 +87,7 @@ func (h *DeviceHandler) GetMyDevices(c *gin.Context) {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize := parsePageAndSize(c)
devices, total, err := h.deviceService.GetUserDevices(c.Request.Context(), userID, page, pageSize)
if err != nil {
@@ -315,11 +311,7 @@ func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize := parsePageAndSize(c)
devices, total, err := h.deviceService.GetUserDevices(c.Request.Context(), userID, page, pageSize)
if err != nil {

View File

@@ -0,0 +1,510 @@
package handler_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
)
func TestDeviceHandler_ListDevices(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicelistuser", "devicelist@test.com", "UserPass123!")
token := getToken(server.URL, "devicelistuser", "UserPass123!")
resp, body := doGet(server.URL+"/api/v1/devices", token)
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 response: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
}
func TestDeviceHandler_ListDevices_Unauthorized(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/devices", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestDeviceHandler_CreateDevice(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicecreateuser", "devicecreate@test.com", "UserPass123!")
token := getToken(server.URL, "devicecreateuser", "UserPass123!")
resp, body := doPost(server.URL+"/api/v1/devices", token, map[string]interface{}{
"name": "Test Device",
"device_id": "device-test-001",
"device_type": 3,
"device_os": "Windows 10",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusCreated, resp.StatusCode, body)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
}
func TestDeviceHandler_CreateDevice_InvalidBody(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicecreatebad", "devicecreatebad@test.com", "UserPass123!")
token := getToken(server.URL, "devicecreatebad", "UserPass123!")
req, _ := http.NewRequest("POST", server.URL+"/api/v1/devices", bytes.NewReader([]byte("not json")))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d for invalid body, got %d", http.StatusBadRequest, resp.StatusCode)
}
}
func TestDeviceHandler_GetDevice(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicegetuser", "deviceget@test.com", "UserPass123!")
token := getToken(server.URL, "devicegetuser", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-get-001", "Get Device")
resp, body := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), token)
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 response: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
}
func TestDeviceHandler_GetDevice_NotFound(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicegetnf", "devicegetnf@test.com", "UserPass123!")
token := getToken(server.URL, "devicegetnf", "UserPass123!")
resp, body := doGet(server.URL+"/api/v1/devices/99999", token)
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected status %d, got %d, body: %s", http.StatusNotFound, resp.StatusCode, body)
}
}
func TestDeviceHandler_GetDevice_InvalidID(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicegetinv", "devicegetinv@test.com", "UserPass123!")
token := getToken(server.URL, "devicegetinv", "UserPass123!")
resp, body := doGet(server.URL+"/api/v1/devices/invalid", token)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestDeviceHandler_UpdateDevice(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceupdateuser", "deviceupdate@test.com", "UserPass123!")
token := getToken(server.URL, "deviceupdateuser", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-update-001", "Original Name")
resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), token, map[string]interface{}{
"device_name": "Updated Name",
})
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 response: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
}
func TestDeviceHandler_UpdateDevice_NotFound(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceupdatenf", "deviceupdatenf@test.com", "UserPass123!")
token := getToken(server.URL, "deviceupdatenf", "UserPass123!")
resp, body := doPut(server.URL+"/api/v1/devices/99999", token, map[string]interface{}{
"device_name": "Updated Name",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected status %d, got %d, body: %s", http.StatusNotFound, resp.StatusCode, body)
}
}
func TestDeviceHandler_DeleteDevice(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicedeluser", "devicedel@test.com", "UserPass123!")
token := getToken(server.URL, "devicedeluser", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-del-001", "Delete Device")
resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), token)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
// Verify deletion
getResp, _ := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), token)
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusNotFound {
t.Errorf("expected device to be deleted, got status %d", getResp.StatusCode)
}
}
func TestDeviceHandler_DeleteDevice_NotFound(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicedelnf", "devicedelnf@test.com", "UserPass123!")
token := getToken(server.URL, "devicedelnf", "UserPass123!")
resp, body := doDelete(server.URL+"/api/v1/devices/99999", token)
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected status %d, got %d, body: %s", http.StatusNotFound, resp.StatusCode, body)
}
}
func TestDeviceHandler_UpdateDeviceStatus(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicestatususer", "devicestatus@test.com", "UserPass123!")
token := getToken(server.URL, "devicestatususer", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-status-001", "Status Device")
resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d/status", server.URL, deviceID), token, map[string]interface{}{
"status": "inactive",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
}
func TestDeviceHandler_UpdateDeviceStatus_InvalidStatus(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicestatusinv", "devicestatusinv@test.com", "UserPass123!")
token := getToken(server.URL, "devicestatusinv", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-status-inv-001", "Status Device")
resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d/status", server.URL, deviceID), token, map[string]interface{}{
"status": "invalid_status",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestDeviceHandler_TrustDevice(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicetrustuser", "devicetrust@test.com", "UserPass123!")
token := getToken(server.URL, "devicetrustuser", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-trust-001", "Trust Device")
resp, body := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), token, map[string]interface{}{
"trust_duration": "24h",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
}
func TestDeviceHandler_UntrustDevice(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceuntrustuser", "deviceuntrust@test.com", "UserPass123!")
token := getToken(server.URL, "deviceuntrustuser", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-untrust-001", "Untrust Device")
// First trust the device
trustResp, trustBody := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), token, map[string]interface{}{
"trust_duration": "24h",
})
defer trustResp.Body.Close()
if trustResp.StatusCode != http.StatusOK {
t.Fatalf("expected trust status %d, got %d, body: %s", http.StatusOK, trustResp.StatusCode, trustBody)
}
// Then untrust
resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), 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 TestDeviceHandler_GetMyTrustedDevices(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicetrusteduser", "devicetrusted@test.com", "UserPass123!")
token := getToken(server.URL, "devicetrusteduser", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-trusted-001", "Trusted Device")
// Trust the device first
trustResp, trustBody := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), token, map[string]interface{}{
"trust_duration": "24h",
})
defer trustResp.Body.Close()
if trustResp.StatusCode != http.StatusOK {
t.Fatalf("expected trust status %d, got %d, body: %s", http.StatusOK, trustResp.StatusCode, trustBody)
}
resp, body := doGet(server.URL+"/api/v1/devices/me/trusted", token)
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 response: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
}
func TestDeviceHandler_LogoutAllOtherDevices(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicelogoutuser", "devicelogout@test.com", "UserPass123!")
token := getToken(server.URL, "devicelogoutuser", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, token, "device-logout-001", "Logout Device")
req, _ := http.NewRequest("POST", server.URL+"/api/v1/devices/me/logout-others", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Device-ID", fmt.Sprintf("%d", deviceID))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := json.Marshal(resp.Body)
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
}
}
func TestDeviceHandler_LogoutAllOtherDevices_MissingDeviceID(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicelogoutbad", "devicelogoutbad@test.com", "UserPass123!")
token := getToken(server.URL, "devicelogoutbad", "UserPass123!")
resp, body := doPost(server.URL+"/api/v1/devices/me/logout-others", token, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestDeviceHandler_GetUserDevices_AdminCanViewOthers(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "deviceadmin", "deviceadmin@test.com", "AdminPass123!")
registerUser(server.URL, "deviceuserview", "deviceuserview@test.com", "UserPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin should return access token")
}
resp, body := doGet(server.URL+"/api/v1/devices/users/2", adminToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
}
func TestDeviceHandler_GetUserDevices_NonAdminForbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceuser1", "deviceuser1@test.com", "UserPass123!")
registerUser(server.URL, "deviceuser2", "deviceuser2@test.com", "UserPass123!")
token := getToken(server.URL, "deviceuser1", "UserPass123!")
resp, body := doGet(server.URL+"/api/v1/devices/users/2", token)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
}
func TestDeviceHandler_GetAllDevices_AdminOnly(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "deviceadmin2", "deviceadmin2@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin should return access token")
}
resp, body := doGet(server.URL+"/api/v1/admin/devices", adminToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
}
func TestDeviceHandler_GetAllDevices_NonAdminForbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceuser3", "deviceuser3@test.com", "UserPass123!")
token := getToken(server.URL, "deviceuser3", "UserPass123!")
resp, body := doGet(server.URL+"/api/v1/admin/devices", token)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
}
func TestDeviceHandler_TrustDeviceByDeviceID(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicetrustiduser", "devicetrustid@test.com", "UserPass123!")
token := getToken(server.URL, "devicetrustiduser", "UserPass123!")
// Create device with specific device_id
resp, body := doPost(server.URL+"/api/v1/devices", token, map[string]interface{}{
"name": "Trust By ID Device",
"device_id": "my-unique-device-id",
"device_type": 1,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected create status %d, got %d, body: %s", http.StatusCreated, resp.StatusCode, body)
}
// Trust by device ID
trustResp, trustBody := doPost(server.URL+"/api/v1/devices/by-device-id/my-unique-device-id/trust", token, map[string]interface{}{
"trust_duration": "24h",
})
defer trustResp.Body.Close()
if trustResp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, trustResp.StatusCode, trustBody)
}
}
func TestDeviceHandler_TrustDeviceByDeviceID_EmptyID(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "devicetrustidbad", "devicetrustidbad@test.com", "UserPass123!")
token := getToken(server.URL, "devicetrustidbad", "UserPass123!")
// The route uses ":deviceId" path param, so empty ID would be a different route or 404
// Actually the route is /by-device-id/:deviceId/trust, so empty deviceId is not matched
// Let's test with a device ID that doesn't exist
resp, body := doPost(server.URL+"/api/v1/devices/by-device-id/nonexistent/trust", token, map[string]interface{}{
"trust_duration": "24h",
})
defer resp.Body.Close()
// Service returns error for non-existent device
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusInternalServerError {
t.Errorf("expected status 404 or 500 for non-existent device, got %d, body: %s", resp.StatusCode, body)
}
}

View File

@@ -0,0 +1,319 @@
package handler_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/api/handler"
"github.com/user-management-system/internal/api/middleware"
"github.com/user-management-system/internal/api/router"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/config"
"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"
)
var exportDbCounter int64
func setupExportTestServer(t *testing.T) (*httptest.Server, string, string, func()) {
t.Helper()
gin.SetMode(gin.TestMode)
id := atomic.AddInt64(&exportDbCounter, 1)
dsn := fmt.Sprintf("file:exportdb_%d_%s?mode=memory&cache=shared", id, t.Name())
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
DriverName: "sqlite",
DSN: dsn,
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Skipf("skipping export test (SQLite unavailable): %v", err)
return nil, "", "", func() {}
}
if err := db.AutoMigrate(
&domain.User{},
&domain.Role{},
&domain.Permission{},
&domain.UserRole{},
&domain.RolePermission{},
); err != nil {
t.Fatalf("db migration failed: %v", err)
}
seedHandlerAuthzData(t, db)
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
HS256Secret: "test-export-secret-key",
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)
authSvc := service.NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
authSvc.SetRoleRepositories(userRoleRepo, roleRepo)
exportSvc := service.NewExportService(userRepo, nil)
exportHandler := handler.NewExportHandler(exportSvc)
rateLimitCfg := config.RateLimitConfig{}
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitCfg)
authMiddleware := middleware.NewAuthMiddleware(
jwtManager, userRepo, userRoleRepo, l1Cache,
)
authMiddleware.SetCacheManager(cacheManager)
authHandler := handler.NewAuthHandler(authSvc)
r := router.NewRouter(
authHandler, nil, nil, nil, nil, nil,
authMiddleware, rateLimitMiddleware, nil,
nil, nil, nil, nil,
nil, exportHandler, nil, nil, nil, nil, nil, nil, nil,
)
engine := r.Setup()
server := httptest.NewServer(engine)
// Register a regular user
regBody := map[string]interface{}{
"username": fmt.Sprintf("exportuser_%d", id),
"password": "TestPass123!",
"email": fmt.Sprintf("ex_%d@test.com", id),
}
regBytes, _ := json.Marshal(regBody)
regResp, _ := http.Post(server.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(regBytes))
io.ReadAll(regResp.Body)
regResp.Body.Close()
// Login as regular user
loginBody := map[string]interface{}{
"account": regBody["username"],
"password": regBody["password"],
}
loginBytes, _ := json.Marshal(loginBody)
loginResp, _ := http.Post(server.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBytes))
var loginResult struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
loginResp.Body.Close()
userToken := loginResult.Data.AccessToken
// Bootstrap admin
t.Setenv("BOOTSTRAP_SECRET", fmt.Sprintf("export-bootstrap-%d", id))
adminToken := bootstrapAdmin(server.URL, fmt.Sprintf("export-bootstrap-%d", id), fmt.Sprintf("exportadmin_%d", id), fmt.Sprintf("exa_%d@test.com", id), "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
return server, adminToken, userToken, func() {
server.Close()
if sqlDB, err := db.DB(); err == nil {
sqlDB.Close()
}
}
}
func TestExportHandler_ExportUsers(t *testing.T) {
server, adminToken, userToken, cleanup := setupExportTestServer(t)
defer cleanup()
tests := []struct {
name string
query string
token string
wantStatus int
}{
{
name: "success_csv",
query: "format=csv",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "success_excel",
query: "format=xlsx",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "forbidden_regular_user",
query: "format=csv",
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "unauthorized",
query: "format=csv",
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := server.URL + "/api/v1/admin/users/export"
if tt.query != "" {
url = url + "?" + tt.query
}
resp, body := doGet(url, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestExportHandler_ImportUsers(t *testing.T) {
server, adminToken, userToken, cleanup := setupExportTestServer(t)
defer cleanup()
csvData := []byte("\xEF\xBB\xBF用户名,密码,邮箱,手机号,昵称,性别,地区,个人简介\nimportuser1,Password123!,import1@test.com,13800138001,Import1,男,北京,简介1\n")
tests := []struct {
name string
fileBody []byte
filename string
token string
wantStatus int
}{
{
name: "success_csv",
fileBody: csvData,
filename: "users.csv",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "forbidden_regular_user",
fileBody: csvData,
filename: "users.csv",
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "unauthorized",
fileBody: csvData,
filename: "users.csv",
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", tt.filename)
if err != nil {
t.Fatalf("create form file failed: %v", err)
}
if _, err := part.Write(tt.fileBody); err != nil {
t.Fatalf("write file body failed: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer failed: %v", err)
}
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/admin/users/import", &body)
if err != nil {
t.Fatalf("create request failed: %v", err)
}
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
respBody, _ := io.ReadAll(resp.Body)
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, string(respBody))
}
})
}
}
func TestExportHandler_GetImportTemplate(t *testing.T) {
server, adminToken, userToken, cleanup := setupExportTestServer(t)
defer cleanup()
tests := []struct {
name string
query string
token string
wantStatus int
}{
{
name: "success_csv",
query: "format=csv",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "success_excel",
query: "format=xlsx",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "forbidden_regular_user",
query: "format=csv",
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "unauthorized",
query: "format=csv",
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := server.URL + "/api/v1/admin/users/import/template"
if tt.query != "" {
url = url + "?" + tt.query
}
resp, body := doGet(url, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ package handler
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -42,11 +41,7 @@ func (h *LogHandler) GetMyLoginLogs(c *gin.Context) {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize := parsePageAndSize(c)
logs, total, err := h.loginLogService.GetMyLoginLogs(c.Request.Context(), userID, page, pageSize)
if err != nil {
@@ -84,11 +79,7 @@ func (h *LogHandler) GetMyOperationLogs(c *gin.Context) {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize := parsePageAndSize(c)
logs, total, err := h.operationLogService.GetMyOperationLogs(c.Request.Context(), userID, page, pageSize)
if err != nil {

View File

@@ -0,0 +1,308 @@
package handler_test
import (
"bytes"
"encoding/json"
"net/http"
"testing"
)
func TestPasswordResetHandler_ForgotPassword(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "resetuser", "resetuser@test.com", "UserPass123!")
resp, body := doPost(server.URL+"/api/v1/auth/forgot-password", "", map[string]interface{}{
"email": "resetuser@test.com",
})
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 response: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
}
func TestPasswordResetHandler_ForgotPassword_MissingEmail(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/auth/forgot-password", "", map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ForgotPassword_NonExistentEmail(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
// For non-existent email, the service returns success to prevent user enumeration
resp, body := doPost(server.URL+"/api/v1/auth/forgot-password", "", map[string]interface{}{
"email": "nonexistent@test.com",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d for non-existent email, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ValidateResetToken(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "validatetokenuser", "validatetoken@test.com", "UserPass123!")
// First request a password reset to generate a token
_, _ = doPost(server.URL+"/api/v1/auth/forgot-password", "", map[string]interface{}{
"email": "validatetoken@test.com",
})
// We can't easily get the token from email, so test with an invalid token
resp, body := doPost(server.URL+"/api/v1/auth/password/validate", "", map[string]interface{}{
"token": "invalid-token-12345",
})
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 response: %v", err)
}
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 data in response, got %s", body)
}
if data["valid"] != false {
t.Errorf("expected valid=false for invalid token, got %v", data["valid"])
}
}
func TestPasswordResetHandler_ValidateResetToken_MissingToken(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/auth/password/validate", "", map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ResetPassword(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "resetpwuser", "resetpw@test.com", "UserPass123!")
// Request reset to generate token
_, _ = doPost(server.URL+"/api/v1/auth/forgot-password", "", map[string]interface{}{
"email": "resetpw@test.com",
})
// Since we can't get the token, test with invalid token
resp, body := doPost(server.URL+"/api/v1/auth/reset-password", "", map[string]interface{}{
"token": "invalid-token",
"new_password": "NewPass123!",
})
defer resp.Body.Close()
// Should fail because token is invalid (service returns 404 for "不存在")
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusNotFound {
t.Errorf("expected status 401, 400 or 404 for invalid token, got %d, body: %s", resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ResetPassword_MissingToken(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/auth/reset-password", "", map[string]interface{}{
"new_password": "NewPass123!",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ResetPassword_MissingPassword(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/auth/reset-password", "", map[string]interface{}{
"token": "some-token",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ResetPassword_WeakPassword(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "resetpwweak", "resetpwweak@test.com", "UserPass123!")
// We need a valid token to test weak password rejection
// Let's manually create one through the cache by using forgot-password
_, _ = doPost(server.URL+"/api/v1/auth/forgot-password", "", map[string]interface{}{
"email": "resetpwweak@test.com",
})
// Use invalid token - the validation happens before password strength check
resp, body := doPost(server.URL+"/api/v1/auth/reset-password", "", map[string]interface{}{
"token": "invalid-token",
"new_password": "123",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusNotFound {
t.Errorf("expected status 401, 400 or 404, got %d, body: %s", resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ForgotPasswordByPhone_ServiceUnavailable(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
// The password reset handler in the test setup does not have SMS service configured
resp, body := doPost(server.URL+"/api/v1/auth/forgot-password/phone", "", map[string]interface{}{
"phone": "13800138000",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("expected status %d, got %d, body: %s", http.StatusServiceUnavailable, resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ResetPasswordByPhone_MissingFields(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/auth/reset-password/phone", "", map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ResetPasswordByPhone_InvalidCode(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "resetphoneuser", "resetphone@test.com", "UserPass123!")
resp, body := doPost(server.URL+"/api/v1/auth/reset-password/phone", "", map[string]interface{}{
"phone": "13800138000",
"code": "000000",
"new_password": "NewPass123!",
})
defer resp.Body.Close()
// Should fail because no code was sent
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status 401 or 400 for invalid code, got %d, body: %s", resp.StatusCode, body)
}
}
func TestPasswordResetHandler_ForgotPassword_InvalidJSON(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/forgot-password", bytes.NewReader([]byte("not json")))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d for invalid JSON, got %d", http.StatusBadRequest, resp.StatusCode)
}
}
func TestPasswordResetHandler_FullFlow(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "fullflowuser", "fullflow@test.com", "UserPass123!")
// Step 1: Request password reset
forgotResp, forgotBody := doPost(server.URL+"/api/v1/auth/forgot-password", "", map[string]interface{}{
"email": "fullflow@test.com",
})
defer forgotResp.Body.Close()
if forgotResp.StatusCode != http.StatusOK {
t.Fatalf("forgot-password failed: status=%d body=%s", forgotResp.StatusCode, forgotBody)
}
// Step 2: Validate token (we don't know the real token, so it will be invalid)
validateResp, validateBody := doPost(server.URL+"/api/v1/auth/password/validate", "", map[string]interface{}{
"token": "unknown-token",
})
defer validateResp.Body.Close()
if validateResp.StatusCode != http.StatusOK {
t.Fatalf("validate token failed: status=%d body=%s", validateResp.StatusCode, validateBody)
}
var validateResult map[string]interface{}
if err := json.Unmarshal([]byte(validateBody), &validateResult); err != nil {
t.Fatalf("failed to parse validate response: %v", err)
}
validateData, ok := validateResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected validate data, got %s", validateBody)
}
if validateData["valid"] != false {
t.Errorf("expected valid=false for unknown token, got %v", validateData["valid"])
}
// Step 3: Try reset with invalid token
resetResp, resetBody := doPost(server.URL+"/api/v1/auth/reset-password", "", map[string]interface{}{
"token": "unknown-token",
"new_password": "NewPass123!",
})
defer resetResp.Body.Close()
// Should fail because token is invalid (service returns 404 for "不存在")
if resetResp.StatusCode != http.StatusUnauthorized && resetResp.StatusCode != http.StatusNotFound {
t.Errorf("expected status 401 or 404 for invalid token reset, got %d, body: %s", resetResp.StatusCode, resetBody)
}
// Step 4: Verify old password still works
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "fullflowuser",
"password": "UserPass123!",
})
defer loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("old password should still work: status=%d body=%s", loginResp.StatusCode, loginBody)
}
}

View File

@@ -0,0 +1,455 @@
package handler_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
)
func TestPermissionHandler_CreatePermission(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "perm-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "perm-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
if ok := registerUser(server.URL, "permuser", "permuser@test.com", "UserPass123!"); !ok {
t.Fatal("register user failed")
}
userToken := getToken(server.URL, "permuser", "UserPass123!")
if userToken == "" {
t.Fatal("get user token failed")
}
tests := []struct {
name string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
payload: map[string]interface{}{
"name": "Test Permission",
"code": "test:permission:create",
"type": 2,
},
token: adminToken,
wantStatus: http.StatusCreated,
},
{
name: "unauthorized",
payload: map[string]interface{}{
"name": "Test Permission",
"code": "test:permission:unauth",
"type": 2,
},
token: "",
wantStatus: http.StatusUnauthorized,
},
{
name: "forbidden",
payload: map[string]interface{}{
"name": "Test Permission",
"code": "test:permission:forbid",
"type": 2,
},
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "invalid_type",
payload: map[string]interface{}{
"name": "Test Permission",
"code": "test:permission:badtype",
"type": 5,
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "missing_required_fields",
payload: map[string]interface{}{"name": "Missing Code"},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPost(server.URL+"/api/v1/permissions", tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestPermissionHandler_ListPermissions(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "perm-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "perm-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
if ok := registerUser(server.URL, "permuser", "permuser@test.com", "UserPass123!"); !ok {
t.Fatal("register user failed")
}
userToken := getToken(server.URL, "permuser", "UserPass123!")
if userToken == "" {
t.Fatal("get user token failed")
}
tests := []struct {
name string
token string
wantStatus int
}{
{
name: "success_admin",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "forbidden_regular_user",
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "unauthorized",
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doGet(server.URL+"/api/v1/permissions", tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestPermissionHandler_GetPermission(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "perm-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "perm-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a permission to retrieve
createResp, createBody := doPost(server.URL+"/api/v1/permissions", adminToken, map[string]interface{}{
"name": "Get Permission Test",
"code": "test:permission:get",
"type": 2,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create permission failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
permData, ok := createResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in create response, got %s", createBody)
}
permID := int64(permData["id"].(float64))
tests := []struct {
name string
permID string
token string
wantStatus int
}{
{
name: "success",
permID: fmt.Sprintf("%d", permID),
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "not_found",
permID: "99999",
token: adminToken,
wantStatus: http.StatusNotFound,
},
{
name: "invalid_id",
permID: "invalid",
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
permID: fmt.Sprintf("%d", permID),
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doGet(server.URL+"/api/v1/permissions/"+tt.permID, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestPermissionHandler_UpdatePermission(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "perm-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "perm-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a permission to update
createResp, createBody := doPost(server.URL+"/api/v1/permissions", adminToken, map[string]interface{}{
"name": "Update Permission Test",
"code": "test:permission:update",
"type": 2,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create permission failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
permData := createResult["data"].(map[string]interface{})
permID := int64(permData["id"].(float64))
tests := []struct {
name string
permID string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
permID: fmt.Sprintf("%d", permID),
payload: map[string]interface{}{
"name": "Updated Permission Name",
},
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
permID: "invalid",
payload: map[string]interface{}{
"name": "Updated Permission Name",
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
permID: fmt.Sprintf("%d", permID),
payload: map[string]interface{}{
"name": "Updated Permission Name",
},
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPut(server.URL+"/api/v1/permissions/"+tt.permID, tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestPermissionHandler_DeletePermission(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "perm-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "perm-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a permission to delete
createResp, createBody := doPost(server.URL+"/api/v1/permissions", adminToken, map[string]interface{}{
"name": "Delete Permission Test",
"code": "test:permission:delete",
"type": 2,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create permission failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
permData := createResult["data"].(map[string]interface{})
permID := int64(permData["id"].(float64))
tests := []struct {
name string
permID string
token string
wantStatus int
}{
{
name: "success",
permID: fmt.Sprintf("%d", permID),
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
permID: "invalid",
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
permID: fmt.Sprintf("%d", permID),
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doDelete(server.URL+"/api/v1/permissions/"+tt.permID, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestPermissionHandler_UpdatePermissionStatus(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "perm-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "perm-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a permission
createResp, createBody := doPost(server.URL+"/api/v1/permissions", adminToken, map[string]interface{}{
"name": "Status Permission Test",
"code": "test:permission:status",
"type": 2,
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create permission failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
permData := createResult["data"].(map[string]interface{})
permID := int64(permData["id"].(float64))
tests := []struct {
name string
permID string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success_numeric",
permID: fmt.Sprintf("%d", permID),
payload: map[string]interface{}{
"status": 0,
},
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
permID: "invalid",
payload: map[string]interface{}{
"status": 0,
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
permID: fmt.Sprintf("%d", permID),
payload: map[string]interface{}{
"status": 0,
},
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPut(server.URL+"/api/v1/permissions/"+tt.permID+"/status", tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestPermissionHandler_GetPermissionTree(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "perm-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "perm-bootstrap-secret", "permadmin", "permadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
resp, body := doGet(server.URL+"/api/v1/permissions/tree", adminToken)
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("parse response failed: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
if result["data"] == nil {
t.Errorf("expected data in response")
}
}

View File

@@ -0,0 +1,527 @@
package handler_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
)
func TestRoleHandler_CreateRole(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
if ok := registerUser(server.URL, "roleuser", "roleuser@test.com", "UserPass123!"); !ok {
t.Fatal("register user failed")
}
userToken := getToken(server.URL, "roleuser", "UserPass123!")
if userToken == "" {
t.Fatal("get user token failed")
}
tests := []struct {
name string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
payload: map[string]interface{}{
"name": "Test Role",
"code": "test_role_create",
},
token: adminToken,
wantStatus: http.StatusCreated,
},
{
name: "unauthorized",
payload: map[string]interface{}{
"name": "Test Role Unauth",
"code": "test_role_unauth",
},
token: "",
wantStatus: http.StatusUnauthorized,
},
{
name: "forbidden",
payload: map[string]interface{}{
"name": "Test Role Forbidden",
"code": "test_role_forbidden",
},
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "missing_required_fields",
payload: map[string]interface{}{"name": "Missing Code"},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPost(server.URL+"/api/v1/roles", tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestRoleHandler_ListRoles(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
if ok := registerUser(server.URL, "roleuser", "roleuser@test.com", "UserPass123!"); !ok {
t.Fatal("register user failed")
}
userToken := getToken(server.URL, "roleuser", "UserPass123!")
if userToken == "" {
t.Fatal("get user token failed")
}
tests := []struct {
name string
token string
wantStatus int
}{
{
name: "success_admin",
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "forbidden_regular_user",
token: userToken,
wantStatus: http.StatusForbidden,
},
{
name: "unauthorized",
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doGet(server.URL+"/api/v1/roles", tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestRoleHandler_GetRole(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a role to retrieve
createResp, createBody := doPost(server.URL+"/api/v1/roles", adminToken, map[string]interface{}{
"name": "Get Role Test",
"code": "test_role_get",
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create role failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
roleData := createResult["data"].(map[string]interface{})
roleID := int64(roleData["id"].(float64))
tests := []struct {
name string
roleID string
token string
wantStatus int
}{
{
name: "success",
roleID: fmt.Sprintf("%d", roleID),
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "not_found",
roleID: "99999",
token: adminToken,
wantStatus: http.StatusNotFound,
},
{
name: "invalid_id",
roleID: "invalid",
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
roleID: fmt.Sprintf("%d", roleID),
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doGet(server.URL+"/api/v1/roles/"+tt.roleID, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestRoleHandler_UpdateRole(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a role to update
createResp, createBody := doPost(server.URL+"/api/v1/roles", adminToken, map[string]interface{}{
"name": "Update Role Test",
"code": "test_role_update",
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create role failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
roleData := createResult["data"].(map[string]interface{})
roleID := int64(roleData["id"].(float64))
tests := []struct {
name string
roleID string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"name": "Updated Role Name",
},
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
roleID: "invalid",
payload: map[string]interface{}{
"name": "Updated Role Name",
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"name": "Updated Role Name",
},
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPut(server.URL+"/api/v1/roles/"+tt.roleID, tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestRoleHandler_DeleteRole(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a role to delete
createResp, createBody := doPost(server.URL+"/api/v1/roles", adminToken, map[string]interface{}{
"name": "Delete Role Test",
"code": "test_role_delete",
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create role failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
roleData := createResult["data"].(map[string]interface{})
roleID := int64(roleData["id"].(float64))
tests := []struct {
name string
roleID string
token string
wantStatus int
}{
{
name: "success",
roleID: fmt.Sprintf("%d", roleID),
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
roleID: "invalid",
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
roleID: fmt.Sprintf("%d", roleID),
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doDelete(server.URL+"/api/v1/roles/"+tt.roleID, tt.token)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestRoleHandler_UpdateRoleStatus(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a role
createResp, createBody := doPost(server.URL+"/api/v1/roles", adminToken, map[string]interface{}{
"name": "Status Role Test",
"code": "test_role_status",
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create role failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
roleData := createResult["data"].(map[string]interface{})
roleID := int64(roleData["id"].(float64))
tests := []struct {
name string
roleID string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success_disabled",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"status": "disabled",
},
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "success_enabled",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"status": "enabled",
},
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_status",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"status": "invalid_status",
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "invalid_id",
roleID: "invalid",
payload: map[string]interface{}{
"status": "disabled",
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"status": "disabled",
},
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPut(server.URL+"/api/v1/roles/"+tt.roleID+"/status", tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}
func TestRoleHandler_GetRolePermissions(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Use the admin role (id=1) for testing
resp, body := doGet(server.URL+"/api/v1/roles/1/permissions", adminToken)
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("parse response failed: %v", err)
}
if result["code"] != float64(0) {
t.Errorf("expected code 0, got %v", result["code"])
}
if result["data"] == nil {
t.Errorf("expected data in response")
}
}
func TestRoleHandler_AssignPermissions(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "role-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "role-bootstrap-secret", "roleadmin", "roleadmin@test.com", "AdminPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin failed")
}
// Create a role
createResp, createBody := doPost(server.URL+"/api/v1/roles", adminToken, map[string]interface{}{
"name": "Assign Perm Role Test",
"code": "test_role_assign_perm",
})
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Fatalf("create role failed: %d %s", createResp.StatusCode, createBody)
}
var createResult map[string]interface{}
if err := json.Unmarshal([]byte(createBody), &createResult); err != nil {
t.Fatalf("parse create response failed: %v", err)
}
roleData := createResult["data"].(map[string]interface{})
roleID := int64(roleData["id"].(float64))
tests := []struct {
name string
roleID string
payload map[string]interface{}
token string
wantStatus int
}{
{
name: "success",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"permission_ids": []int64{1, 2},
},
token: adminToken,
wantStatus: http.StatusOK,
},
{
name: "invalid_id",
roleID: "invalid",
payload: map[string]interface{}{
"permission_ids": []int64{1},
},
token: adminToken,
wantStatus: http.StatusBadRequest,
},
{
name: "unauthorized",
roleID: fmt.Sprintf("%d", roleID),
payload: map[string]interface{}{
"permission_ids": []int64{1},
},
token: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doPut(server.URL+"/api/v1/roles/"+tt.roleID+"/permissions", tt.token, tt.payload)
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected status %d, got %d, body: %s", tt.wantStatus, resp.StatusCode, body)
}
})
}
}

View File

@@ -0,0 +1,855 @@
package handler_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/api/handler"
"github.com/user-management-system/internal/auth"
)
func doPostForm(targetURL, token string, data url.Values) (*http.Response, string) {
var bodyReader io.Reader
if data != nil {
bodyReader = strings.NewReader(data.Encode())
}
req, _ := http.NewRequest("POST", targetURL, bodyReader)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, _ := client.Do(req)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(bodyBytes)
}
func setupSSOTestServer(t *testing.T) (*httptest.Server, func()) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(gin.Recovery())
ssoManager := auth.NewSSOManager()
clientsStore := auth.NewDefaultSSOClientsStore()
clientsStore.RegisterClient(&auth.SSOClient{
ClientID: "test-client",
ClientSecret: "test-secret",
Name: "Test Client",
RedirectURIs: []string{"http://localhost:8080/callback"},
})
ssoHandler := handler.NewSSOHandler(ssoManager, clientsStore)
// Simple auth middleware for testing
authMiddleware := func() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" || token == "Bearer " {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
return
}
c.Set("user_id", int64(1))
c.Set("username", "testuser")
c.Next()
}
}()
ssoGroup := engine.Group("/api/v1/sso")
ssoGroup.Use(authMiddleware)
{
ssoGroup.GET("/authorize", ssoHandler.Authorize)
ssoGroup.POST("/token", ssoHandler.Token)
ssoGroup.POST("/introspect", ssoHandler.Introspect)
ssoGroup.POST("/revoke", ssoHandler.Revoke)
ssoGroup.GET("/userinfo", ssoHandler.UserInfo)
}
server := httptest.NewServer(engine)
return server, func() {
server.Close()
}
}
func TestSSOHandler_Authorize_MissingParams(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doGet(server.URL+"/api/v1/sso/authorize", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Authorize_UnsupportedResponseType(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=unsupported", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Authorize_Unauthorized(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestSSOHandler_Authorize_CodeFlow(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code&state=xyz", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected status %d (redirect), got %d", http.StatusFound, resp.StatusCode)
}
location := resp.Header.Get("Location")
if location == "" {
t.Fatal("expected redirect location")
}
if !strings.Contains(location, "code=") {
t.Errorf("expected redirect with code, got %s", location)
}
if !strings.Contains(location, "state=xyz") {
t.Errorf("expected redirect with state, got %s", location)
}
}
func TestSSOHandler_Authorize_InvalidRedirectURI(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://evil.com/callback&response_type=code", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Authorize_TokenFlow(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=token&state=abc", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected status %d (redirect), got %d", http.StatusFound, resp.StatusCode)
}
location := resp.Header.Get("Location")
if location == "" {
t.Fatal("expected redirect location")
}
if !strings.Contains(location, "access_token=") {
t.Errorf("expected redirect with access_token, got %s", location)
}
}
func TestSSOHandler_Token_MissingParams(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Token_InvalidGrantType(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
formData := url.Values{}
formData.Set("grant_type", "password")
formData.Set("client_id", "test-client")
formData.Set("client_secret", "test-secret")
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Token_InvalidClient(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", "some-code")
formData.Set("client_id", "invalid-client")
formData.Set("client_secret", "wrong-secret")
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
}
}
func TestSSOHandler_Token_InvalidCode(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", "invalid-code")
formData.Set("client_id", "test-client")
formData.Set("client_secret", "test-secret")
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
}
}
func TestSSOHandler_Token_Success(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// First authorize to get a code
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer authResp.Body.Close()
if authResp.StatusCode != http.StatusFound {
t.Fatalf("expected authorize redirect, got %d", authResp.StatusCode)
}
location := authResp.Header.Get("Location")
parsedURL, err := url.Parse(location)
if err != nil {
t.Fatalf("failed to parse redirect URL: %v", err)
}
code := parsedURL.Query().Get("code")
if code == "" {
t.Fatal("expected authorization code in redirect")
}
// Exchange code for token
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", code)
formData.Set("client_id", "test-client")
formData.Set("client_secret", "test-secret")
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
var tokenResp handler.TokenResponse
if err := json.Unmarshal([]byte(body), &tokenResp); err != nil {
t.Fatalf("failed to parse token response: %v", err)
}
if tokenResp.AccessToken == "" {
t.Errorf("expected access_token in response")
}
if tokenResp.TokenType != "Bearer" {
t.Errorf("expected token_type Bearer, got %s", tokenResp.TokenType)
}
}
func TestSSOHandler_Introspect_MissingToken(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Introspect_InvalidToken(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
"token": "invalid-token",
})
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 handler.IntrospectResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("failed to parse introspect response: %v", err)
}
if result.Active != false {
t.Errorf("expected active=false for invalid token, got %v", result.Active)
}
}
func TestSSOHandler_Introspect_ValidToken(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// Authorize and get token
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer authResp.Body.Close()
location := authResp.Header.Get("Location")
parsedURL, _ := url.Parse(location)
code := parsedURL.Query().Get("code")
tokenForm := url.Values{}
tokenForm.Set("grant_type", "authorization_code")
tokenForm.Set("code", code)
tokenForm.Set("client_id", "test-client")
tokenForm.Set("client_secret", "test-secret")
tokenResp, tokenBody := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", tokenForm)
defer tokenResp.Body.Close()
if tokenResp.StatusCode != http.StatusOK {
t.Fatalf("token exchange failed: status=%d body=%s", tokenResp.StatusCode, tokenBody)
}
var tokenResult handler.TokenResponse
if err := json.Unmarshal([]byte(tokenBody), &tokenResult); err != nil {
t.Fatalf("failed to parse token response: %v", err)
}
// Introspect the token
resp, body := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
"token": tokenResult.AccessToken,
})
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 handler.IntrospectResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("failed to parse introspect response: %v", err)
}
if result.Active != true {
t.Errorf("expected active=true for valid token, got %v", result.Active)
}
if result.UserID != 1 {
t.Errorf("expected user_id=1, got %d", result.UserID)
}
}
func TestSSOHandler_Revoke_MissingToken(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doPost(server.URL+"/api/v1/sso/revoke", "Bearer test-token", map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Revoke_Success(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// Authorize and get token
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer authResp.Body.Close()
location := authResp.Header.Get("Location")
parsedURL, _ := url.Parse(location)
code := parsedURL.Query().Get("code")
tokenForm := url.Values{}
tokenForm.Set("grant_type", "authorization_code")
tokenForm.Set("code", code)
tokenForm.Set("client_id", "test-client")
tokenForm.Set("client_secret", "test-secret")
tokenResp, tokenBody := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", tokenForm)
defer tokenResp.Body.Close()
if tokenResp.StatusCode != http.StatusOK {
t.Fatalf("token exchange failed: status=%d body=%s", tokenResp.StatusCode, tokenBody)
}
var tokenResult handler.TokenResponse
if err := json.Unmarshal([]byte(tokenBody), &tokenResult); err != nil {
t.Fatalf("failed to parse token response: %v", err)
}
// Revoke the token
resp, body := doPost(server.URL+"/api/v1/sso/revoke", "Bearer test-token", map[string]interface{}{
"token": tokenResult.AccessToken,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
// Verify token is revoked
introspectResp, introspectBody := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
"token": tokenResult.AccessToken,
})
defer introspectResp.Body.Close()
if introspectResp.StatusCode != http.StatusOK {
t.Fatalf("introspect failed: status=%d body=%s", introspectResp.StatusCode, introspectBody)
}
var introspectResult handler.IntrospectResponse
if err := json.Unmarshal([]byte(introspectBody), &introspectResult); err != nil {
t.Fatalf("failed to parse introspect response: %v", err)
}
if introspectResult.Active != false {
t.Errorf("expected active=false after revoke, got %v", introspectResult.Active)
}
}
func TestSSOHandler_UserInfo_Unauthorized(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/sso/userinfo", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestSSOHandler_UserInfo_Success(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doGet(server.URL+"/api/v1/sso/userinfo", "Bearer test-token")
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 response: %v", err)
}
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 data in response, got %s", body)
}
if data["user_id"] != float64(1) {
t.Errorf("expected user_id=1, got %v", data["user_id"])
}
if data["username"] != "testuser" {
t.Errorf("expected username=testuser, got %v", data["username"])
}
}
func TestSSOHandler_Token_InvalidClientSecret(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// Authorize to get a code
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer authResp.Body.Close()
location := authResp.Header.Get("Location")
parsedURL, _ := url.Parse(location)
code := parsedURL.Query().Get("code")
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", code)
formData.Set("client_id", "test-client")
formData.Set("client_secret", "wrong-secret")
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
}
}
func TestSSOHandler_Authorize_MissingClientID(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doGet(server.URL+"/api/v1/sso/authorize?redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Introspect_FormData(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// Test that introspect accepts form-encoded data
formData := url.Values{}
formData.Set("token", "some-token")
req, _ := http.NewRequest("POST", server.URL+"/api/v1/sso/introspect", strings.NewReader(formData.Encode()))
req.Header.Set("Authorization", "Bearer test-token")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := json.Marshal(resp.Body)
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
}
}
func TestSSOHandler_Token_FormData(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// Authorize to get a code
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer authResp.Body.Close()
location := authResp.Header.Get("Location")
parsedURL, _ := url.Parse(location)
code := parsedURL.Query().Get("code")
// Test that token accepts form-encoded data
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", code)
formData.Set("client_id", "test-client")
formData.Set("client_secret", "test-secret")
req, _ := http.NewRequest("POST", server.URL+"/api/v1/sso/token", strings.NewReader(formData.Encode()))
req.Header.Set("Authorization", "Bearer test-token")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
bodyBytes, _ := json.Marshal(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
}
}
func TestSSOHandler_Revoke_FormData(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
formData := url.Values{}
formData.Set("token", "some-token")
req, _ := http.NewRequest("POST", server.URL+"/api/v1/sso/revoke", strings.NewReader(formData.Encode()))
req.Header.Set("Authorization", "Bearer test-token")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := json.Marshal(resp.Body)
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
}
}
func TestSSOHandler_Authorize_UnknownClientID(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=unknown-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer resp.Body.Close()
// When client is unknown, redirect_uri validation fails
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Token_WithoutAuth(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", "some-code")
formData.Set("client_id", "test-client")
formData.Set("client_secret", "test-secret")
resp, _ := doPostForm(server.URL+"/api/v1/sso/token", "", formData)
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestSSOHandler_UserInfo_WithoutAuth(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/sso/userinfo", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestSSOHandler_Introspect_WithoutAuth(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, _ := doPost(server.URL+"/api/v1/sso/introspect", "", map[string]interface{}{
"token": "some-token",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestSSOHandler_Revoke_WithoutAuth(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
resp, _ := doPost(server.URL+"/api/v1/sso/revoke", "", map[string]interface{}{
"token": "some-token",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestSSOHandler_Authorize_InvalidClientID(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// Test with valid redirect URI but unknown client
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=unknown&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestSSOHandler_Token_MissingCode(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("client_id", "test-client")
formData.Set("client_secret", "test-secret")
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
defer resp.Body.Close()
// Code is empty, so validate should fail
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
}
}
func TestSSOHandler_FullFlow(t *testing.T) {
server, cleanup := setupSSOTestServer(t)
defer cleanup()
// Step 1: Authorize
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code&state=my-state", "Bearer test-token")
defer authResp.Body.Close()
if authResp.StatusCode != http.StatusFound {
t.Fatalf("authorize failed: status=%d", authResp.StatusCode)
}
location := authResp.Header.Get("Location")
parsedURL, _ := url.Parse(location)
code := parsedURL.Query().Get("code")
state := parsedURL.Query().Get("state")
if code == "" {
t.Fatal("expected authorization code")
}
if state != "my-state" {
t.Errorf("expected state=my-state, got %s", state)
}
// Step 2: Exchange code for token
tokenForm := url.Values{}
tokenForm.Set("grant_type", "authorization_code")
tokenForm.Set("code", code)
tokenForm.Set("client_id", "test-client")
tokenForm.Set("client_secret", "test-secret")
tokenResp, tokenBody := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", tokenForm)
defer tokenResp.Body.Close()
if tokenResp.StatusCode != http.StatusOK {
t.Fatalf("token exchange failed: status=%d body=%s", tokenResp.StatusCode, tokenBody)
}
var tokenResult handler.TokenResponse
if err := json.Unmarshal([]byte(tokenBody), &tokenResult); err != nil {
t.Fatalf("failed to parse token response: %v", err)
}
if tokenResult.AccessToken == "" {
t.Fatal("expected access_token")
}
// Step 3: Introspect token
introspectResp, introspectBody := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
"token": tokenResult.AccessToken,
})
defer introspectResp.Body.Close()
if introspectResp.StatusCode != http.StatusOK {
t.Fatalf("introspect failed: status=%d body=%s", introspectResp.StatusCode, introspectBody)
}
var introspectResult handler.IntrospectResponse
if err := json.Unmarshal([]byte(introspectBody), &introspectResult); err != nil {
t.Fatalf("failed to parse introspect response: %v", err)
}
if !introspectResult.Active {
t.Error("expected token to be active")
}
if introspectResult.UserID != 1 {
t.Errorf("expected user_id=1, got %d", introspectResult.UserID)
}
// Step 4: Get userinfo
userinfoResp, userinfoBody := doGet(server.URL+"/api/v1/sso/userinfo", "Bearer test-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)
}
userinfoData, ok := userinfoResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected userinfo data, got %s", userinfoBody)
}
if userinfoData["username"] != "testuser" {
t.Errorf("expected username=testuser, got %v", userinfoData["username"])
}
// Step 5: Revoke token
revokeResp, revokeBody := doPost(server.URL+"/api/v1/sso/revoke", "Bearer test-token", map[string]interface{}{
"token": tokenResult.AccessToken,
})
defer revokeResp.Body.Close()
if revokeResp.StatusCode != http.StatusOK {
t.Fatalf("revoke failed: status=%d body=%s", revokeResp.StatusCode, revokeBody)
}
// Step 6: Verify token is revoked
finalIntrospectResp, finalIntrospectBody := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
"token": tokenResult.AccessToken,
})
defer finalIntrospectResp.Body.Close()
if finalIntrospectResp.StatusCode != http.StatusOK {
t.Fatalf("final introspect failed: status=%d body=%s", finalIntrospectResp.StatusCode, finalIntrospectBody)
}
var finalResult handler.IntrospectResponse
if err := json.Unmarshal([]byte(finalIntrospectBody), &finalResult); err != nil {
t.Fatalf("failed to parse final introspect response: %v", err)
}
if finalResult.Active {
t.Error("expected token to be inactive after revoke")
}
}
func TestSSOHandler_Authorize_NoClientStore(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
ssoManager := auth.NewSSOManager()
// Pass nil clientsStore
ssoHandler := handler.NewSSOHandler(ssoManager, nil)
authMiddleware := func() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user_id", int64(1))
c.Set("username", "testuser")
c.Next()
}
}()
ssoGroup := engine.Group("/api/v1/sso")
ssoGroup.Use(authMiddleware)
{
ssoGroup.GET("/authorize", ssoHandler.Authorize)
}
server := httptest.NewServer(engine)
defer server.Close()
// Without clients store, any redirect_uri should be accepted (or validation skipped)
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=any&redirect_uri=http://any.com/callback&response_type=code", "Bearer test-token")
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("expected redirect when clientsStore is nil, got %d", resp.StatusCode)
}
}

View File

@@ -17,10 +17,6 @@ import (
"gorm.io/gorm/logger"
)
// =============================================================================
// Theme Handler Tests - TDD approach
// =============================================================================
func setupThemeTestEnv(t *testing.T) (*handler.ThemeHandler, *gorm.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -45,10 +41,22 @@ func setupThemeTestEnv(t *testing.T) (*handler.ThemeHandler, *gorm.DB) {
return handler.NewThemeHandler(themeSvc), db
}
func createThemeForTest(t *testing.T, h *handler.ThemeHandler, body string) {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/themes", bytes.NewReader([]byte(body)))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateTheme(c)
if w.Code != http.StatusCreated {
t.Fatalf("create theme failed: %d %s", w.Code, w.Body.String())
}
}
func TestThemeHandler_CreateTheme(t *testing.T) {
h, _ := setupThemeTestEnv(t)
t.Run("创建主题成功", func(t *testing.T) {
t.Run("create success", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"test-theme","primary_color":"#1976d2"}`
@@ -58,20 +66,19 @@ func TestThemeHandler_CreateTheme(t *testing.T) {
h.CreateTheme(c)
if w.Code != http.StatusCreated {
t.Errorf("期望状态码 %d, 得到 %d", http.StatusCreated, w.Code)
t.Fatalf("expected status %d, got %d", http.StatusCreated, w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("解析响应失败: %v", err)
t.Fatalf("decode response failed: %v", err)
}
if resp["code"].(float64) != 0 {
t.Errorf("期望 code=0, 得到 %v", resp["code"])
t.Fatalf("expected code=0, got %v", resp["code"])
}
})
t.Run("创建主题失败-缺少名称", func(t *testing.T) {
t.Run("create missing name", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"primary_color":"#1976d2"}`
@@ -81,15 +88,15 @@ func TestThemeHandler_CreateTheme(t *testing.T) {
h.CreateTheme(c)
if w.Code != http.StatusBadRequest {
t.Errorf("期望状态码 %d, 得到 %d", http.StatusBadRequest, w.Code)
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
}
func TestThemeHandler_ListThemes(t *testing.T) {
h, _ := setupThemeTestEnv(t)
createThemeForTest(t, h, `{"name":"list-theme","primary_color":"#1976d2"}`)
t.Run("获取主题列表", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/themes", nil)
@@ -97,15 +104,14 @@ func TestThemeHandler_ListThemes(t *testing.T) {
h.ListThemes(c)
if w.Code != http.StatusOK {
t.Errorf("期望状态码 %d, 得到 %d", http.StatusOK, w.Code)
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
})
}
func TestThemeHandler_GetTheme(t *testing.T) {
h, _ := setupThemeTestEnv(t)
t.Run("获取主题失败-无效ID", func(t *testing.T) {
t.Run("get invalid id", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "invalid"}}
@@ -114,7 +120,70 @@ func TestThemeHandler_GetTheme(t *testing.T) {
h.GetTheme(c)
if w.Code != http.StatusBadRequest {
t.Errorf("期望状态码 %d, 得到 %d", http.StatusBadRequest, w.Code)
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("get success", func(t *testing.T) {
createThemeForTest(t, h, `{"name":"get-theme","primary_color":"#1976d2"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Request = httptest.NewRequest("GET", "/api/v1/themes/1", nil)
h.GetTheme(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d, body=%s", http.StatusOK, w.Code, w.Body.String())
}
})
}
func TestThemeHandler_UpdateTheme(t *testing.T) {
h, _ := setupThemeTestEnv(t)
createThemeForTest(t, h, `{"name":"theme-update","primary_color":"#111111"}`)
t.Run("update success", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
body := `{"primary_color":"#222222","enabled":true}`
c.Request = httptest.NewRequest("PUT", "/api/v1/themes/1", bytes.NewReader([]byte(body)))
c.Request.Header.Set("Content-Type", "application/json")
h.UpdateTheme(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d, body=%s", http.StatusOK, w.Code, w.Body.String())
}
})
t.Run("update invalid id", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "invalid"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/themes/invalid", bytes.NewReader([]byte(`{}`)))
c.Request.Header.Set("Content-Type", "application/json")
h.UpdateTheme(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("update invalid json", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/themes/1", bytes.NewReader([]byte(`{"primary_color":`)))
c.Request.Header.Set("Content-Type", "application/json")
h.UpdateTheme(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
}
@@ -122,7 +191,7 @@ func TestThemeHandler_GetTheme(t *testing.T) {
func TestThemeHandler_DeleteTheme(t *testing.T) {
h, _ := setupThemeTestEnv(t)
t.Run("删除主题失败-无效ID", func(t *testing.T) {
t.Run("delete invalid id", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "invalid"}}
@@ -131,7 +200,90 @@ func TestThemeHandler_DeleteTheme(t *testing.T) {
h.DeleteTheme(c)
if w.Code != http.StatusBadRequest {
t.Errorf("期望状态码 %d, 得到 %d", http.StatusBadRequest, w.Code)
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("delete success", func(t *testing.T) {
createThemeForTest(t, h, `{"name":"theme-delete","primary_color":"#1976d2"}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Request = httptest.NewRequest("DELETE", "/api/v1/themes/1", nil)
h.DeleteTheme(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d, body=%s", http.StatusOK, w.Code, w.Body.String())
}
})
}
func TestThemeHandler_DefaultAndActiveFlows(t *testing.T) {
h, _ := setupThemeTestEnv(t)
createThemeForTest(t, h, `{"name":"default-theme","primary_color":"#111111","is_default":true}`)
createThemeForTest(t, h, `{"name":"other-theme","primary_color":"#222222"}`)
t.Run("list all themes", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/themes/all", nil)
h.ListAllThemes(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
})
t.Run("get default theme", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/themes/default", nil)
h.GetDefaultTheme(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
})
t.Run("set default invalid id", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "bad"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/themes/bad/default", nil)
h.SetDefaultTheme(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("set default success", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "2"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/themes/2/default", nil)
h.SetDefaultTheme(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d, body=%s", http.StatusOK, w.Code, w.Body.String())
}
})
t.Run("get active theme", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/themes/active", nil)
h.GetActiveTheme(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
})
}

View File

@@ -0,0 +1,685 @@
package handler_test
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"github.com/user-management-system/internal/auth"
)
func TestTOTPHandler_GetTOTPStatus(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "totpstatususer", "totpstatus@test.com", "UserPass123!")
token := getToken(server.URL, "totpstatususer", "UserPass123!")
resp, body := doGet(server.URL+"/api/v1/auth/2fa/status", token)
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 response: %v", err)
}
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 data in response, got %s", body)
}
if data["enabled"] != false {
t.Errorf("expected enabled=false for new user, got %v", data["enabled"])
}
}
func TestTOTPHandler_GetTOTPStatus_Unauthorized(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/auth/2fa/status", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestTOTPHandler_SetupTOTP(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "totpsetupuser", "totpsetup@test.com", "UserPass123!")
token := getToken(server.URL, "totpsetupuser", "UserPass123!")
resp, body := doGet(server.URL+"/api/v1/auth/2fa/setup", token)
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 response: %v", err)
}
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 data in response, got %s", body)
}
if data["secret"] == nil || data["secret"] == "" {
t.Errorf("expected secret in setup response, got %+v", data)
}
}
func TestTOTPHandler_SetupTOTP_Unauthorized(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, _ := doGet(server.URL+"/api/v1/auth/2fa/setup", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestTOTPHandler_EnableTOTP(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpenableuser", "totpenable@test.com", "UserPass123!")
_ = userID
_ = secret
// setupEnabledTOTPUser already enables TOTP, so let's just verify the user can login with TOTP
// Actually, we need a fresh user to test enable
registerUser(server.URL, "totpenableuser2", "totpenable2@test.com", "UserPass123!")
token := getToken(server.URL, "totpenableuser2", "UserPass123!")
// Setup TOTP
setupResp, setupBody := doGet(server.URL+"/api/v1/auth/2fa/setup", token)
defer setupResp.Body.Close()
if setupResp.StatusCode != http.StatusOK {
t.Fatalf("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 setup response: %v", err)
}
setupData, ok := setupResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected setup data, got %s", setupBody)
}
newSecret, ok := setupData["secret"].(string)
if !ok || newSecret == "" {
t.Fatalf("expected secret in setup response, got %s", setupBody)
}
// Generate valid code
code, err := auth.NewTOTPManager().GenerateCurrentCode(newSecret)
if err != nil {
t.Fatalf("failed to generate TOTP code: %v", err)
}
// Enable TOTP
enableResp, enableBody := doPost(server.URL+"/api/v1/auth/2fa/enable", token, map[string]interface{}{
"code": code,
})
defer enableResp.Body.Close()
if enableResp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, enableResp.StatusCode, enableBody)
}
}
func TestTOTPHandler_EnableTOTP_InvalidCode(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "totpenableinv", "totpenableinv@test.com", "UserPass123!")
token := getToken(server.URL, "totpenableinv", "UserPass123!")
// Setup TOTP first
setupResp, setupBody := doGet(server.URL+"/api/v1/auth/2fa/setup", token)
defer setupResp.Body.Close()
if setupResp.StatusCode != http.StatusOK {
t.Fatalf("setup failed: status=%d body=%s", setupResp.StatusCode, setupBody)
}
// Try enable with invalid code
enableResp, enableBody := doPost(server.URL+"/api/v1/auth/2fa/enable", token, map[string]interface{}{
"code": "000000",
})
defer enableResp.Body.Close()
if enableResp.StatusCode != http.StatusUnauthorized && enableResp.StatusCode != http.StatusInternalServerError {
t.Errorf("expected status 401 or 500 for invalid code, got %d, body: %s", enableResp.StatusCode, enableBody)
}
}
func TestTOTPHandler_EnableTOTP_MissingCode(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "totpenablemiss", "totpenablemiss@test.com", "UserPass123!")
token := getToken(server.URL, "totpenablemiss", "UserPass123!")
resp, body := doPost(server.URL+"/api/v1/auth/2fa/enable", token, map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestTOTPHandler_DisableTOTP(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpdisableuser", "totpdisable@test.com", "UserPass123!")
// Login again to get a fresh token (since TOTP is enabled, login may require TOTP)
deviceID := "test-device"
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "totpdisableuser",
"password": "UserPass123!",
"device_id": deviceID,
})
defer loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("login failed: status=%d body=%s", loginResp.StatusCode, loginBody)
}
var loginResult map[string]interface{}
if err := json.Unmarshal([]byte(loginBody), &loginResult); err != nil {
t.Fatalf("failed to parse login response: %v", err)
}
// If requires_totp, we need to verify TOTP first
loginData, ok := loginResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected login data, got %s", loginBody)
}
var token string
if loginData["requires_totp"] == true {
code, err := auth.NewTOTPManager().GenerateCurrentCode(secret)
if err != nil {
t.Fatalf("failed to generate TOTP code: %v", err)
}
tempToken, _ := loginData["temp_token"].(string)
verifyResp, verifyBody := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
"user_id": userID,
"code": code,
"device_id": deviceID,
"temp_token": tempToken,
})
defer verifyResp.Body.Close()
if verifyResp.StatusCode != http.StatusOK {
t.Fatalf("totp verify failed: status=%d body=%s", verifyResp.StatusCode, verifyBody)
}
var verifyResult map[string]interface{}
if err := json.Unmarshal([]byte(verifyBody), &verifyResult); err != nil {
t.Fatalf("failed to parse verify response: %v", err)
}
verifyData, ok := verifyResult["data"].(map[string]interface{})
if ok && verifyData["access_token"] != nil {
token, _ = verifyData["access_token"].(string)
}
} else {
token, _ = loginData["access_token"].(string)
}
if token == "" {
t.Fatal("failed to get token after login")
}
// Generate valid code for disable
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/2fa/disable", token, map[string]interface{}{
"code": code,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
// Verify TOTP is disabled
statusResp, statusBody := doGet(server.URL+"/api/v1/auth/2fa/status", token)
defer statusResp.Body.Close()
if statusResp.StatusCode != http.StatusOK {
t.Fatalf("status check failed: status=%d body=%s", statusResp.StatusCode, statusBody)
}
var statusResult map[string]interface{}
if err := json.Unmarshal([]byte(statusBody), &statusResult); err != nil {
t.Fatalf("failed to parse status response: %v", err)
}
statusData, ok := statusResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected status data, got %s", statusBody)
}
if statusData["enabled"] != false {
t.Errorf("expected enabled=false after disable, got %v", statusData["enabled"])
}
}
func TestTOTPHandler_DisableTOTP_InvalidCode(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpdisableinv", "totpdisableinv@test.com", "UserPass123!")
// Get token (might need TOTP verification)
deviceID := "test-device"
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "totpdisableinv",
"password": "UserPass123!",
"device_id": deviceID,
})
defer loginResp.Body.Close()
var token string
var loginResult map[string]interface{}
if err := json.Unmarshal([]byte(loginBody), &loginResult); err == nil {
if loginData, ok := loginResult["data"].(map[string]interface{}); ok {
if loginData["requires_totp"] == true {
code, _ := auth.NewTOTPManager().GenerateCurrentCode(secret)
tempToken, _ := loginData["temp_token"].(string)
verifyResp, verifyBody := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
"user_id": userID,
"code": code,
"device_id": deviceID,
"temp_token": tempToken,
})
defer verifyResp.Body.Close()
if verifyResp.StatusCode == http.StatusOK {
var verifyResult map[string]interface{}
if err := json.Unmarshal([]byte(verifyBody), &verifyResult); err == nil {
if verifyData, ok := verifyResult["data"].(map[string]interface{}); ok {
token, _ = verifyData["access_token"].(string)
}
}
}
} else {
token, _ = loginData["access_token"].(string)
}
}
}
if token == "" {
t.Fatal("failed to get token after login")
}
resp, body := doPost(server.URL+"/api/v1/auth/2fa/disable", token, map[string]interface{}{
"code": "000000",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusInternalServerError {
t.Errorf("expected status 401 or 500 for invalid code, got %d, body: %s", resp.StatusCode, body)
}
}
func TestTOTPHandler_VerifyTOTP(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpverifyuser", "totpverify@test.com", "UserPass123!")
// Get token (might need TOTP verification)
deviceID := "test-device"
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "totpverifyuser",
"password": "UserPass123!",
"device_id": deviceID,
})
defer loginResp.Body.Close()
var token string
var loginResult map[string]interface{}
if err := json.Unmarshal([]byte(loginBody), &loginResult); err == nil {
if loginData, ok := loginResult["data"].(map[string]interface{}); ok {
if loginData["requires_totp"] == true {
code, _ := auth.NewTOTPManager().GenerateCurrentCode(secret)
tempToken, _ := loginData["temp_token"].(string)
verifyResp, verifyBody := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
"user_id": userID,
"code": code,
"device_id": deviceID,
"temp_token": tempToken,
})
defer verifyResp.Body.Close()
if verifyResp.StatusCode == http.StatusOK {
var verifyResult map[string]interface{}
if err := json.Unmarshal([]byte(verifyBody), &verifyResult); err == nil {
if verifyData, ok := verifyResult["data"].(map[string]interface{}); ok {
token, _ = verifyData["access_token"].(string)
}
}
}
} else {
token, _ = loginData["access_token"].(string)
}
}
}
if token == "" {
t.Fatal("failed to get token after login")
}
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/2fa/verify", token, map[string]interface{}{
"code": code,
"device_id": deviceID,
})
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 response: %v", err)
}
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 data in response, got %s", body)
}
if data["verified"] != true {
t.Errorf("expected verified=true, got %v", data["verified"])
}
}
func TestTOTPHandler_VerifyTOTP_InvalidCode(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpverifyinv", "totpverifyinv@test.com", "UserPass123!")
// Get token
deviceID := "test-device"
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "totpverifyinv",
"password": "UserPass123!",
"device_id": deviceID,
})
defer loginResp.Body.Close()
var token string
var loginResult map[string]interface{}
if err := json.Unmarshal([]byte(loginBody), &loginResult); err == nil {
if loginData, ok := loginResult["data"].(map[string]interface{}); ok {
if loginData["requires_totp"] == true {
code, _ := auth.NewTOTPManager().GenerateCurrentCode(secret)
tempToken, _ := loginData["temp_token"].(string)
verifyResp, verifyBody := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
"user_id": userID,
"code": code,
"device_id": deviceID,
"temp_token": tempToken,
})
defer verifyResp.Body.Close()
if verifyResp.StatusCode == http.StatusOK {
var verifyResult map[string]interface{}
if err := json.Unmarshal([]byte(verifyBody), &verifyResult); err == nil {
if verifyData, ok := verifyResult["data"].(map[string]interface{}); ok {
token, _ = verifyData["access_token"].(string)
}
}
}
} else {
token, _ = loginData["access_token"].(string)
}
}
}
if token == "" {
t.Fatal("failed to get token after login")
}
resp, body := doPost(server.URL+"/api/v1/auth/2fa/verify", token, map[string]interface{}{
"code": "000000",
"device_id": deviceID,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusInternalServerError {
t.Errorf("expected status 401 or 500 for invalid code, got %d, body: %s", resp.StatusCode, body)
}
}
func TestTOTPHandler_VerifyTOTP_MissingCode(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "totpverifymiss", "totpverifymiss@test.com", "UserPass123!")
token := getToken(server.URL, "totpverifymiss", "UserPass123!")
resp, body := doPost(server.URL+"/api/v1/auth/2fa/verify", token, map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestTOTPHandler_VerifyTOTP_Unauthorized(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, _ := doPost(server.URL+"/api/v1/auth/2fa/verify", "", map[string]interface{}{
"code": "123456",
"device_id": "test-device",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestTOTPHandler_DisableTOTP_MissingCode(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpdisablemiss", "totpdisablemiss@test.com", "UserPass123!")
// Get token
deviceID := "test-device"
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "totpdisablemiss",
"password": "UserPass123!",
"device_id": deviceID,
})
defer loginResp.Body.Close()
var token string
var loginResult map[string]interface{}
if err := json.Unmarshal([]byte(loginBody), &loginResult); err == nil {
if loginData, ok := loginResult["data"].(map[string]interface{}); ok {
if loginData["requires_totp"] == true {
code, _ := auth.NewTOTPManager().GenerateCurrentCode(secret)
tempToken, _ := loginData["temp_token"].(string)
verifyResp, verifyBody := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
"user_id": userID,
"code": code,
"device_id": deviceID,
"temp_token": tempToken,
})
defer verifyResp.Body.Close()
if verifyResp.StatusCode == http.StatusOK {
var verifyResult map[string]interface{}
if err := json.Unmarshal([]byte(verifyBody), &verifyResult); err == nil {
if verifyData, ok := verifyResult["data"].(map[string]interface{}); ok {
token, _ = verifyData["access_token"].(string)
}
}
}
} else {
token, _ = loginData["access_token"].(string)
}
}
}
if token == "" {
t.Fatal("failed to get token after login")
}
resp, body := doPost(server.URL+"/api/v1/auth/2fa/disable", token, map[string]interface{}{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
}
}
func TestTOTPHandler_DisableTOTP_Unauthorized(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, _ := doPost(server.URL+"/api/v1/auth/2fa/disable", "", map[string]interface{}{
"code": "123456",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestTOTPHandler_SetupTOTP_AlreadyEnabled(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
userID, secret := setupEnabledTOTPUser(t, server.URL, "totpsetupenabled", "totpsetupenabled@test.com", "UserPass123!")
_ = secret
// Get token after TOTP login
loginResp, loginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "totpsetupenabled",
"password": "UserPass123!",
"device_id": "test-device",
})
defer loginResp.Body.Close()
var token string
var loginResult map[string]interface{}
if err := json.Unmarshal([]byte(loginBody), &loginResult); err == nil {
if loginData, ok := loginResult["data"].(map[string]interface{}); ok {
if loginData["requires_totp"] == true {
tempToken, _ := loginData["temp_token"].(string)
code, _ := auth.NewTOTPManager().GenerateCurrentCode(secret)
verifyResp, verifyBody := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
"user_id": userID,
"temp_token": tempToken,
"code": code,
"device_id": "test-device",
})
defer verifyResp.Body.Close()
if verifyResp.StatusCode == http.StatusOK {
var verifyResult map[string]interface{}
if err := json.Unmarshal([]byte(verifyBody), &verifyResult); err == nil {
if verifyData, ok := verifyResult["data"].(map[string]interface{}); ok {
token, _ = verifyData["access_token"].(string)
}
}
}
} else {
token, _ = loginData["access_token"].(string)
}
}
}
if token == "" {
t.Fatal("failed to get token after login")
}
// Try setup again - should still work or return appropriate response
resp, body := doGet(server.URL+"/api/v1/auth/2fa/setup", token)
defer resp.Body.Close()
// Setup may return 200 with new secret or error if already enabled
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
t.Errorf("unexpected status %d, body: %s", resp.StatusCode, body)
}
}
func TestTOTPHandler_EnableTOTP_Unauthorized(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
resp, _ := doPost(server.URL+"/api/v1/auth/2fa/enable", "", map[string]interface{}{
"code": "123456",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
}
}
func TestTOTPHandler_InvalidJSON(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "totpjsonuser", "totpjson@test.com", "UserPass123!")
token := getToken(server.URL, "totpjsonuser", "UserPass123!")
tests := []struct {
name string
path string
method string
}{
{"enable_invalid_json", "/api/v1/auth/2fa/enable", "POST"},
{"disable_invalid_json", "/api/v1/auth/2fa/disable", "POST"},
{"verify_invalid_json", "/api/v1/auth/2fa/verify", "POST"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, _ := http.NewRequest(tc.method, server.URL+tc.path, bytes.NewReader([]byte("not json")))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status %d for invalid JSON, got %d", http.StatusBadRequest, resp.StatusCode)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"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/pagination"
"github.com/user-management-system/internal/service"
)
@@ -115,7 +116,7 @@ func (h *UserHandler) ListUsers(c *gin.Context) {
// Fallback to legacy offset-based pagination
offset, _ := strconv.ParseInt(c.DefaultQuery("offset", "0"), 10, 64)
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "20"), 10, 64)
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", strconv.Itoa(pagination.DefaultPageSize)), 10, 64)
users, total, err := h.userService.List(c.Request.Context(), int(offset), int(limit))
if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/pagination"
"github.com/user-management-system/internal/service"
)
@@ -65,14 +66,7 @@ func (h *WebhookHandler) CreateWebhook(c *gin.Context) {
// @Failure 500 {object} Response "服务器错误"
// @Router /api/v1/webhooks [get]
func (h *WebhookHandler) ListWebhooks(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
page, pageSize := parsePageAndSize(c)
offset := (page - 1) * pageSize
userID, _ := c.Get("user_id")
@@ -178,10 +172,8 @@ func (h *WebhookHandler) GetWebhookDeliveries(c *gin.Context) {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit < 1 || limit > 100 {
limit = 20
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(pagination.DefaultPageSize)))
limit = pagination.ClampPageSize(limit)
deliveries, err := h.webhookService.GetWebhookDeliveries(c.Request.Context(), id, limit)
if err != nil {

View File

@@ -2,6 +2,7 @@ package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -19,6 +20,68 @@ import (
_ "modernc.org/sqlite"
)
type authStubUserRepo struct {
user *domain.User
err error
}
func (s authStubUserRepo) GetByID(_ context.Context, _ int64) (*domain.User, error) {
return s.user, s.err
}
type authStubUserRoleRepo struct {
roles []*domain.Role
perms []*domain.Permission
err error
}
func (s authStubUserRoleRepo) GetUserRolesAndPermissions(_ context.Context, _ int64) ([]*domain.Role, []*domain.Permission, error) {
return s.roles, s.perms, s.err
}
func newTestJWT(t *testing.T) *auth.JWT {
t.Helper()
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
HS256Secret: "test-middleware-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)
}
return jwtManager
}
func newAuthMiddlewareForTest(t *testing.T, user *domain.User, roles []*domain.Role, perms []*domain.Permission) (*AuthMiddleware, *auth.JWT, *cache.L1Cache) {
t.Helper()
jwtManager := newTestJWT(t)
l1Cache := cache.NewL1Cache()
middleware := NewAuthMiddleware(jwtManager, authStubUserRepo{user: user}, authStubUserRoleRepo{roles: roles, perms: perms}, l1Cache)
return middleware, jwtManager, l1Cache
}
func performMiddlewareRequest(t *testing.T, middleware gin.HandlerFunc, authHeader string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(middleware)
router.GET("/protected", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0})
})
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
router.ServeHTTP(recorder, req)
return recorder
}
func TestAuthMiddleware_AcceptsBootstrapAdminTokenImmediately(t *testing.T) {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -101,3 +164,269 @@ func TestAuthMiddleware_AcceptsBootstrapAdminTokenImmediately(t *testing.T) {
t.Fatalf("expected bootstrap token to pass auth middleware immediately, got %d body: %s", recorder.Code, recorder.Body.String())
}
}
func TestAuthMiddleware_RequiredRejectsMissingToken(t *testing.T) {
middleware, _, _ := newAuthMiddlewareForTest(t, nil, nil, nil)
recorder := performMiddlewareRequest(t, middleware.Required(), "")
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for missing token, got %d", recorder.Code)
}
}
func TestAuthMiddleware_RequiredRejectsInvalidToken(t *testing.T) {
middleware, _, _ := newAuthMiddlewareForTest(t, nil, nil, nil)
recorder := performMiddlewareRequest(t, middleware.Required(), "Bearer not-a-jwt")
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid token, got %d", recorder.Code)
}
}
func TestAuthMiddleware_RequiredRejectsBlacklistedToken(t *testing.T) {
user := &domain.User{ID: 7, Username: "alice", Status: domain.UserStatusActive}
middleware, jwtManager, l1Cache := newAuthMiddlewareForTest(t, user, nil, nil)
token, err := jwtManager.GenerateAccessToken(user.ID, user.Username, 0)
if err != nil {
t.Fatalf("generate access token failed: %v", err)
}
claims, err := jwtManager.ValidateAccessToken(token)
if err != nil {
t.Fatalf("validate access token failed: %v", err)
}
l1Cache.Set("jwt_blacklist:"+claims.JTI, true, time.Minute)
recorder := performMiddlewareRequest(t, middleware.Required(), "Bearer "+token)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for blacklisted token, got %d", recorder.Code)
}
}
func TestAuthMiddleware_RequiredRejectsInactiveUser(t *testing.T) {
user := &domain.User{ID: 8, Username: "disabled", Status: domain.UserStatusDisabled}
middleware, jwtManager, _ := newAuthMiddlewareForTest(t, user, nil, nil)
token, err := jwtManager.GenerateAccessToken(user.ID, user.Username, 0)
if err != nil {
t.Fatalf("generate access token failed: %v", err)
}
recorder := performMiddlewareRequest(t, middleware.Required(), "Bearer "+token)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for inactive user, got %d", recorder.Code)
}
}
func TestAuthMiddleware_RequiredInjectsIdentityAndAuthorizations(t *testing.T) {
gin.SetMode(gin.TestMode)
user := &domain.User{ID: 9, Username: "admin", Status: domain.UserStatusActive}
roles := []*domain.Role{{Code: "admin"}, {Code: "auditor"}}
perms := []*domain.Permission{{Code: "users:read"}, {Code: "users:write"}}
middleware, jwtManager, _ := newAuthMiddlewareForTest(t, user, roles, perms)
token, err := jwtManager.GenerateAccessToken(user.ID, user.Username, 0)
if err != nil {
t.Fatalf("generate access token failed: %v", err)
}
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(middleware.Required())
router.GET("/protected", func(c *gin.Context) {
if got := c.GetInt64("user_id"); got != user.ID {
t.Fatalf("user_id = %d, want %d", got, user.ID)
}
if got := c.GetString("username"); got != user.Username {
t.Fatalf("username = %q, want %q", got, user.Username)
}
roleCodes := GetRoleCodes(c)
if len(roleCodes) != 2 || roleCodes[0] != "admin" || roleCodes[1] != "auditor" {
t.Fatalf("unexpected role codes: %#v", roleCodes)
}
permCodes := GetPermissionCodes(c)
if len(permCodes) != 2 || permCodes[0] != "users:read" || permCodes[1] != "users:write" {
t.Fatalf("unexpected permission codes: %#v", permCodes)
}
c.JSON(http.StatusOK, gin.H{"code": 0})
})
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200 for valid token, got %d body: %s", recorder.Code, recorder.Body.String())
}
}
func TestAuthMiddleware_OptionalAllowsAnonymousRequest(t *testing.T) {
middleware, _, _ := newAuthMiddlewareForTest(t, nil, nil, nil)
recorder := performMiddlewareRequest(t, middleware.Optional(), "")
if recorder.Code != http.StatusOK {
t.Fatalf("expected optional middleware to allow anonymous request, got %d", recorder.Code)
}
}
func TestAuthMiddleware_OptionalInjectsIdentityForValidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
user := &domain.User{ID: 21, Username: "optional-user", Status: domain.UserStatusActive}
roles := []*domain.Role{{Code: "viewer"}}
perms := []*domain.Permission{{Code: "users:read"}}
middleware, jwtManager, _ := newAuthMiddlewareForTest(t, user, roles, perms)
token, err := jwtManager.GenerateAccessToken(user.ID, user.Username, 0)
if err != nil {
t.Fatalf("generate access token failed: %v", err)
}
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(middleware.Optional())
router.GET("/optional", func(c *gin.Context) {
if got := c.GetInt64("user_id"); got != user.ID {
t.Fatalf("user_id = %d, want %d", got, user.ID)
}
if got := c.GetString("username"); got != user.Username {
t.Fatalf("username = %q, want %q", got, user.Username)
}
if got := GetRoleCodes(c); len(got) != 1 || got[0] != "viewer" {
t.Fatalf("role_codes = %#v, want [viewer]", got)
}
if got := GetPermissionCodes(c); len(got) != 1 || got[0] != "users:read" {
t.Fatalf("permission_codes = %#v, want [users:read]", got)
}
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/optional", nil)
req.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected valid optional auth request to pass, got %d", recorder.Code)
}
}
func TestAuthMiddleware_ExtractTokenCases(t *testing.T) {
gin.SetMode(gin.TestMode)
middleware, _, _ := newAuthMiddlewareForTest(t, nil, nil, nil)
testCases := []struct {
name string
header string
want string
}{
{name: "missing header", header: "", want: ""},
{name: "valid bearer", header: "Bearer abc.def", want: "abc.def"},
{name: "lowercase bearer rejected", header: "bearer abc", want: ""},
{name: "missing token value", header: "Bearer", want: ""},
{name: "wrong scheme", header: "Basic abc", want: ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/protected", nil)
if tc.header != "" {
c.Request.Header.Set("Authorization", tc.header)
}
if got := middleware.extractToken(c); got != tc.want {
t.Fatalf("extractToken() = %q, want %q", got, tc.want)
}
})
}
}
func TestAuthMiddleware_ValidateUserStateAndCacheInvalidation(t *testing.T) {
user := &domain.User{
ID: 11,
Username: "cached-user",
Status: domain.UserStatusActive,
PasswordChangedAt: time.Unix(200, 0),
}
middleware, _, l1Cache := newAuthMiddlewareForTest(t, user, nil, nil)
if got := middleware.validateUserState(context.Background(), user.ID, 150); got == "" {
t.Fatal("expected password-changed denial for stale token")
}
if _, ok := l1Cache.Get("user_state:11"); !ok {
t.Fatal("expected user state to be cached")
}
middleware.InvalidateUserStateCache(user.ID)
if _, ok := l1Cache.Get("user_state:11"); ok {
t.Fatal("expected user state cache to be cleared")
}
}
func TestAuthMiddleware_LoadUserRolesAndPermsCachesAndInvalidates(t *testing.T) {
user := &domain.User{ID: 12, Username: "role-user", Status: domain.UserStatusActive}
roles := []*domain.Role{{Code: "admin"}}
perms := []*domain.Permission{{Code: "users:read"}}
middleware, _, l1Cache := newAuthMiddlewareForTest(t, user, roles, perms)
roleCodes, permCodes := middleware.loadUserRolesAndPerms(context.Background(), user.ID)
if len(roleCodes) != 1 || roleCodes[0] != "admin" {
t.Fatalf("unexpected role codes: %#v", roleCodes)
}
if len(permCodes) != 1 || permCodes[0] != "users:read" {
t.Fatalf("unexpected permission codes: %#v", permCodes)
}
if _, ok := l1Cache.Get("user_perms:12"); !ok {
t.Fatal("expected user permissions to be cached")
}
middleware.InvalidateUserPermCache(user.ID)
if _, ok := l1Cache.Get("user_perms:12"); ok {
t.Fatal("expected user permission cache to be cleared")
}
}
func TestAuthMiddleware_AddToBlacklistAndUserHelpers(t *testing.T) {
activeUser := &domain.User{ID: 13, Username: "active", Status: domain.UserStatusActive}
middleware, _, l1Cache := newAuthMiddlewareForTest(t, activeUser, nil, nil)
middleware.AddToBlacklist("jti-1", time.Minute)
if _, ok := l1Cache.Get("jwt_blacklist:jti-1"); !ok {
t.Fatal("expected blacklist entry in cache")
}
if !middleware.isUserActive(context.Background(), activeUser.ID) {
t.Fatal("expected active user to be active")
}
if middleware.isPasswordChangedSinceTokenIssued(context.Background(), activeUser.ID, 0) {
t.Fatal("expected zero token pce to skip password change check")
}
changedUser := &domain.User{
ID: 14,
Username: "changed",
Status: domain.UserStatusActive,
PasswordChangedAt: time.Unix(300, 0),
}
changedMiddleware, _, _ := newAuthMiddlewareForTest(t, changedUser, nil, nil)
if !changedMiddleware.isPasswordChangedSinceTokenIssued(context.Background(), changedUser.ID, 200) {
t.Fatal("expected password-changed helper to return true")
}
}
func TestAuthMiddleware_UserHelpersHandleRepoFailures(t *testing.T) {
middleware, _, _ := newAuthMiddlewareForTest(t, nil, nil, nil)
middleware.userRepo = authStubUserRepo{err: errors.New("db down")}
if middleware.isUserActive(context.Background(), 99) {
t.Fatal("expected repo failure to mark user inactive")
}
if got := middleware.validateUserState(context.Background(), 99, 0); got == "" {
t.Fatal("expected validateUserState to deny on repo failure")
}
}

View File

@@ -0,0 +1,102 @@
package middleware
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestGzipMiddleware_CompressesLargeJSONResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(GzipMiddleware())
router.GET("/data", func(c *gin.Context) {
c.Header("Content-Type", "application/json")
c.String(http.StatusOK, strings.Repeat("a", gzipMinLength+128))
})
req := httptest.NewRequest(http.MethodGet, "/data", nil)
req.Header.Set("Accept-Encoding", "gzip")
router.ServeHTTP(recorder, req)
if got := recorder.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
reader, err := gzip.NewReader(bytes.NewReader(recorder.Body.Bytes()))
if err != nil {
t.Fatalf("gzip.NewReader() error = %v", err)
}
defer reader.Close()
payload, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if got := string(payload); got != strings.Repeat("a", gzipMinLength+128) {
t.Fatalf("decompressed payload length = %d, want %d", len(got), gzipMinLength+128)
}
}
func TestGzipMiddleware_PassesThroughWhenCompressionNotUseful(t *testing.T) {
gin.SetMode(gin.TestMode)
testCases := []struct {
name string
acceptEncoding string
contentType string
body string
}{
{
name: "client does not accept gzip",
acceptEncoding: "",
contentType: "application/json",
body: strings.Repeat("b", gzipMinLength+64),
},
{
name: "body below threshold",
acceptEncoding: "gzip",
contentType: "application/json",
body: "small-body",
},
{
name: "unsupported content type",
acceptEncoding: "gzip",
contentType: "image/png",
body: strings.Repeat("c", gzipMinLength+64),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(GzipMiddleware())
router.GET("/data", func(c *gin.Context) {
c.Header("Content-Type", tc.contentType)
c.String(http.StatusOK, tc.body)
})
req := httptest.NewRequest(http.MethodGet, "/data", nil)
if tc.acceptEncoding != "" {
req.Header.Set("Accept-Encoding", tc.acceptEncoding)
}
router.ServeHTTP(recorder, req)
if got := recorder.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := recorder.Body.String(); got != tc.body {
t.Fatalf("body length = %d, want %d", len(got), len(tc.body))
}
})
}
}

View File

@@ -0,0 +1,165 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/repository"
gormsqlite "gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
_ "modernc.org/sqlite"
)
func newOperationLogRepositoryForTest(t *testing.T) *repository.OperationLogRepository {
t.Helper()
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
DriverName: "sqlite",
DSN: "file:operation_log_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.OperationLog{}); err != nil {
t.Fatalf("migrate failed: %v", err)
}
if err := db.Exec("DELETE FROM operation_logs").Error; err != nil {
t.Fatalf("cleanup operation_logs failed: %v", err)
}
return repository.NewOperationLogRepository(db)
}
func waitForOperationLogs(t *testing.T, repo *repository.OperationLogRepository, want int) []*domain.OperationLog {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
logs, _, err := repo.List(context.Background(), 0, 20)
if err != nil {
t.Fatalf("list operation logs failed: %v", err)
}
if len(logs) >= want {
return logs
}
time.Sleep(25 * time.Millisecond)
}
logs, _, err := repo.List(context.Background(), 0, 20)
if err != nil {
t.Fatalf("list operation logs failed: %v", err)
}
t.Fatalf("timed out waiting for %d operation logs, got %d", want, len(logs))
return nil
}
func TestOperationLogMiddleware_SkipsReadOnlyMethods(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := newOperationLogRepositoryForTest(t)
router := gin.New()
router.Use(NewOperationLogMiddleware(repo).Record())
router.GET("/logs", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/logs", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
time.Sleep(100 * time.Millisecond)
logs, _, err := repo.List(context.Background(), 0, 20)
if err != nil {
t.Fatalf("list operation logs failed: %v", err)
}
if len(logs) != 0 {
t.Fatalf("expected no logs for GET request, got %d", len(logs))
}
}
func TestOperationLogMiddleware_RecordsAdminMutationAndSanitizesParams(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := newOperationLogRepositoryForTest(t)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("user_id", int64(42))
c.Set(ContextKeyRoleCodes, []string{"admin"})
c.Next()
})
router.Use(NewOperationLogMiddleware(repo).Record())
router.POST("/users", func(c *gin.Context) {
c.Status(http.StatusCreated)
})
body := `{"username":"alice","password":"super-secret","token":"abc"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.RemoteAddr = "203.0.113.10:8080"
req.Header.Set("User-Agent", "middleware-test")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", recorder.Code)
}
logs := waitForOperationLogs(t, repo, 1)
entry := logs[0]
if entry.UserID == nil || *entry.UserID != 42 {
t.Fatalf("user_id = %#v, want 42", entry.UserID)
}
if entry.OperationType != "admin:CREATE" {
t.Fatalf("operation_type = %q, want admin:CREATE", entry.OperationType)
}
if entry.ResponseStatus != http.StatusCreated {
t.Fatalf("response_status = %d, want %d", entry.ResponseStatus, http.StatusCreated)
}
if strings.Contains(entry.RequestParams, "super-secret") || strings.Contains(entry.RequestParams, "abc") {
t.Fatalf("expected sanitized params, got %s", entry.RequestParams)
}
}
func TestOperationLogMiddleware_MethodToTypeAndSanitizeFallbacks(t *testing.T) {
if got := methodToType(http.MethodPatch); got != "UPDATE" {
t.Fatalf("methodToType(PATCH) = %q, want UPDATE", got)
}
if got := methodToType(http.MethodDelete); got != "DELETE" {
t.Fatalf("methodToType(DELETE) = %q, want DELETE", got)
}
if got := methodToType(http.MethodGet); got != "OTHER" {
t.Fatalf("methodToType(GET) = %q, want OTHER", got)
}
raw := []byte(`{"password":"secret","name":"alice"}`)
sanitized := sanitizeParams(raw)
if strings.Contains(sanitized, "secret") {
t.Fatalf("expected password to be masked, got %s", sanitized)
}
plain := sanitizeParams([]byte("not-json"))
if plain != "not-json" {
t.Fatalf("sanitizeParams(non-json) = %q, want not-json", plain)
}
var payload map[string]string
if err := json.Unmarshal([]byte(sanitized), &payload); err != nil {
t.Fatalf("unmarshal sanitized params failed: %v", err)
}
if payload["password"] != "***" {
t.Fatalf("password = %q, want ***", payload["password"])
}
}

View File

@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
@@ -138,3 +139,155 @@ func TestRateLimitMiddleware_Refresh_ScopesBudgetByRefreshTokenBody(t *testing.T
t.Fatalf("request for refresh-token-b body after exhausting refresh-token-a budget returned %d, want %d", differentToken.Code, http.StatusOK)
}
}
func TestExtractRefreshToken_PreservesRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
body := bytes.NewBufferString(`{"refresh_token":"refresh-token-a"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", body)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = req
if got := extractRefreshToken(c); got != "refresh-token-a" {
t.Fatalf("extractRefreshToken() = %q, want refresh-token-a", got)
}
readBack := new(bytes.Buffer)
if _, err := readBack.ReadFrom(c.Request.Body); err != nil {
t.Fatalf("re-read body failed: %v", err)
}
if got := readBack.String(); got != `{"refresh_token":"refresh-token-a"}` {
t.Fatalf("request body after extraction = %q, want original JSON", got)
}
}
func TestRateLimitMiddleware_CleanupRemovesExpiredLimiters(t *testing.T) {
middleware := NewRateLimitMiddleware(config.RateLimitConfig{})
limiter := middleware.getOrCreateLimiter("login:ip:127.0.0.1", time.Millisecond, 1)
limiter.requests = []int64{time.Now().Add(-time.Second).UnixMilli()}
middleware.Cleanup()
if _, exists := middleware.limiters["login:ip:127.0.0.1"]; exists {
t.Fatal("expected expired limiter to be removed")
}
}
func TestRateLimitMiddleware_ResolveLimiterKeyPrefersUserIDForAPI(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/users/1", nil)
c.Params = gin.Params{{Key: "id", Value: "1"}}
c.Set("user_id", int64(99))
middleware := NewRateLimitMiddleware(config.RateLimitConfig{})
key := middleware.resolveLimiterKey(c, "api")
if key != "api:GET:/users/1:user:99" {
t.Fatalf("resolveLimiterKey() = %q, want api:GET:/users/1:user:99", key)
}
}
func TestSlidingWindowLimiter_EnforcesCapacityWithinWindow(t *testing.T) {
limiter := NewSlidingWindowLimiter(time.Second, 2)
if !limiter.Allow() {
t.Fatal("expected first request to pass")
}
if !limiter.Allow() {
t.Fatal("expected second request to pass")
}
if limiter.Allow() {
t.Fatal("expected third request to be rejected")
}
}
func TestRateLimitMiddleware_StartCleanupStopsSafely(t *testing.T) {
middleware := NewRateLimitMiddleware(config.RateLimitConfig{})
middleware.cleanupInt = 10 * time.Millisecond
stop := middleware.StartCleanup()
time.Sleep(25 * time.Millisecond)
stop()
}
func TestRateLimitMiddleware_ResolveLimiterKeyRefreshFallsBackToIP(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewBufferString(`{}`))
c.Request.RemoteAddr = "127.0.0.1:12345"
middleware := NewRateLimitMiddleware(config.RateLimitConfig{})
key := middleware.resolveLimiterKey(c, "refresh")
if key != "refresh:ip:127.0.0.1" {
t.Fatalf("resolveLimiterKey() = %q, want refresh:ip:127.0.0.1", key)
}
}
func TestFingerprintValue_IsDeterministic(t *testing.T) {
first := fingerprintValue("refresh-token-a")
second := fingerprintValue("refresh-token-a")
third := fingerprintValue("refresh-token-b")
if first != second {
t.Fatalf("expected same input fingerprint to match: %q vs %q", first, second)
}
if first == third {
t.Fatalf("expected different inputs to produce different fingerprints: %q vs %q", first, third)
}
}
func TestRateLimitMiddleware_RegisterAndLoginLimiters(t *testing.T) {
gin.SetMode(gin.TestMode)
middleware := NewRateLimitMiddleware(config.RateLimitConfig{})
router := gin.New()
router.POST("/register", middleware.Register(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
router.POST("/login", middleware.Login(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
for i := 0; i < 10; i++ {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/register", nil)
req.RemoteAddr = "127.0.0.1:12345"
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("register request %d returned %d, want %d", i+1, recorder.Code, http.StatusOK)
}
}
registerOverflow := httptest.NewRecorder()
registerReq := httptest.NewRequest(http.MethodPost, "/register", nil)
registerReq.RemoteAddr = "127.0.0.1:12345"
router.ServeHTTP(registerOverflow, registerReq)
if registerOverflow.Code != http.StatusTooManyRequests {
t.Fatalf("register overflow returned %d, want %d", registerOverflow.Code, http.StatusTooManyRequests)
}
for i := 0; i < 5; i++ {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "127.0.0.1:54321"
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("login request %d returned %d, want %d", i+1, recorder.Code, http.StatusOK)
}
}
loginOverflow := httptest.NewRecorder()
loginReq := httptest.NewRequest(http.MethodPost, "/login", nil)
loginReq.RemoteAddr = "127.0.0.1:54321"
router.ServeHTTP(loginOverflow, loginReq)
if loginOverflow.Code != http.StatusTooManyRequests {
t.Fatalf("login overflow returned %d, want %d", loginOverflow.Code, http.StatusTooManyRequests)
}
}

View File

@@ -0,0 +1,114 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func performRBACRequest(t *testing.T, setup func(*gin.Context), middleware gin.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
if setup != nil {
router.Use(setup)
}
router.Use(middleware)
router.GET("/protected", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0})
})
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
router.ServeHTTP(recorder, req)
return recorder
}
func TestRequirePermissionRejectsMissingPermission(t *testing.T) {
recorder := performRBACRequest(t, func(c *gin.Context) {
c.Set(ContextKeyPermissionCodes, []string{"users:read"})
c.Next()
}, RequirePermission("users:write"))
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", recorder.Code)
}
}
func TestRequirePermissionAllowsMatchingPermission(t *testing.T) {
recorder := performRBACRequest(t, func(c *gin.Context) {
c.Set(ContextKeyPermissionCodes, []string{"users:read"})
c.Next()
}, RequirePermission("users:read"))
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
}
func TestRequireAllPermissionsRequiresEveryCode(t *testing.T) {
recorder := performRBACRequest(t, func(c *gin.Context) {
c.Set(ContextKeyPermissionCodes, []string{"users:read"})
c.Next()
}, RequireAllPermissions("users:read", "users:write"))
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", recorder.Code)
}
}
func TestRequireAnyPermissionIsAliasOfRequirePermission(t *testing.T) {
recorder := performRBACRequest(t, func(c *gin.Context) {
c.Set(ContextKeyPermissionCodes, []string{"users:write"})
c.Next()
}, RequireAnyPermission("users:read", "users:write"))
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
}
func TestRequireRoleAndAdminOnly(t *testing.T) {
roleRecorder := performRBACRequest(t, func(c *gin.Context) {
c.Set(ContextKeyRoleCodes, []string{"auditor"})
c.Next()
}, RequireRole("admin"))
if roleRecorder.Code != http.StatusForbidden {
t.Fatalf("expected role check to return 403, got %d", roleRecorder.Code)
}
adminRecorder := performRBACRequest(t, func(c *gin.Context) {
c.Set(ContextKeyRoleCodes, []string{"admin"})
c.Next()
}, AdminOnly())
if adminRecorder.Code != http.StatusOK {
t.Fatalf("expected admin check to return 200, got %d", adminRecorder.Code)
}
}
func TestRBACHelpersHandleMissingContextValues(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/protected", nil)
if got := GetRoleCodes(c); got != nil {
t.Fatalf("GetRoleCodes() = %#v, want nil", got)
}
if got := GetPermissionCodes(c); got != nil {
t.Fatalf("GetPermissionCodes() = %#v, want nil", got)
}
if IsAdmin(c) {
t.Fatal("IsAdmin() = true, want false")
}
c.Set(ContextKeyRoleCodes, []string{"admin"})
c.Set(ContextKeyPermissionCodes, []string{"users:read"})
if !IsAdmin(c) {
t.Fatal("IsAdmin() = false, want true")
}
}

View File

@@ -0,0 +1,119 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestResponseWrapper_WrapsSuccessfulJSONPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(ResponseWrapper())
router.GET("/users", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"id": 1, "name": "alice"})
})
req := httptest.NewRequest(http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
want := `{"code":0,"data":{"id":1,"name":"alice"},"message":"success"}`
if got := recorder.Body.String(); got != want {
t.Fatalf("body = %s, want %s", got, want)
}
}
func TestResponseWrapper_PassesThroughMarkedResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(ResponseWrapper())
router.GET("/users", func(c *gin.Context) {
WrapResponse(c)
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "already wrapped"})
})
req := httptest.NewRequest(http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
want := `{"code":0,"message":"already wrapped"}`
if got := recorder.Body.String(); got != want {
t.Fatalf("body = %s, want %s", got, want)
}
}
func TestResponseWrapper_PassesThroughNonSuccessStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(ResponseWrapper())
router.GET("/users", func(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"message": "bad request"})
})
req := httptest.NewRequest(http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
want := `{"message":"bad request"}`
if got := recorder.Body.String(); got != want {
t.Fatalf("body = %s, want %s", got, want)
}
}
func TestResponseWrapper_PassesThroughInvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(ResponseWrapper())
router.GET("/users", func(c *gin.Context) {
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.WriteString("plain text")
})
req := httptest.NewRequest(http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if got := recorder.Body.String(); got != "plain text" {
t.Fatalf("body = %q, want plain text", got)
}
}
func TestResponseWrapper_NoWrapperMarksContext(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(NoWrapper())
router.GET("/users", func(c *gin.Context) {
if _, exists := c.Get("response_wrapped"); !exists {
t.Fatal("expected response_wrapped marker in context")
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
}

View File

@@ -1,15 +1,21 @@
package middleware
import (
"bytes"
"encoding/json"
"errors"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/config"
apierrors "github.com/user-management-system/internal/pkg/errors"
"github.com/user-management-system/internal/security"
)
func TestCORS_UsesConfiguredOrigins(t *testing.T) {
@@ -44,6 +50,31 @@ func TestCORS_UsesConfiguredOrigins(t *testing.T) {
}
}
func TestCORS_RejectsDisallowedOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
SetCORSConfig(config.CORSConfig{
AllowedOrigins: []string{"https://app.example.com"},
AllowCredentials: false,
})
t.Cleanup(func() {
SetCORSConfig(config.CORSConfig{
AllowedOrigins: []string{"*"},
AllowCredentials: true,
})
})
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
c.Request.Header.Set("Origin", "https://evil.example.com")
CORS()(c)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", recorder.Code)
}
}
func TestSanitizeQuery_MasksSensitiveValues(t *testing.T) {
raw := "token=abc123&foo=bar&access_token=xyz&secret=s1"
sanitized := sanitizeQuery(raw)
@@ -180,6 +211,23 @@ func TestTraceID_ExtractsExistingTraceID(t *testing.T) {
}
}
func TestTraceID_GetTraceIDHandlesMissingAndPresentValue(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
if got := GetTraceID(c); got != "" {
t.Fatalf("GetTraceID() = %q, want empty string", got)
}
c.Set(TraceIDKey, "trace-123")
if got := GetTraceID(c); got != "trace-123" {
t.Fatalf("GetTraceID() = %q, want trace-123", got)
}
}
// ---------- Error handling middleware ----------
func TestErrorHandler_HandlesErrors(t *testing.T) {
@@ -198,6 +246,35 @@ func TestErrorHandler_HandlesErrors(t *testing.T) {
}
}
func TestErrorHandler_ApplicationErrorPreservesStatusAndReason(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(ErrorHandler())
router.GET("/users", func(c *gin.Context) {
_ = c.Error(apierrors.Forbidden("FORBIDDEN", "denied"))
})
req := httptest.NewRequest(http.MethodGet, "/users", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d", recorder.Code)
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal body failed: %v", err)
}
if got := body["reason"]; got != "FORBIDDEN" {
t.Fatalf("reason = %#v, want FORBIDDEN", got)
}
if got := body["message"]; got != "denied" {
t.Fatalf("message = %#v, want denied", got)
}
}
func TestRecover_HandlesPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -216,3 +293,277 @@ func TestRecover_HandlesPanic(t *testing.T) {
t.Fatalf("expected status 500 after panic, got %d", recorder.Code)
}
}
func TestRecover_ReturnsInternalServerErrorPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(Recover())
router.GET("/panic", func(c *gin.Context) {
panic("boom")
})
req := httptest.NewRequest(http.MethodGet, "/panic", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500 after panic, got %d", recorder.Code)
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal body failed: %v", err)
}
if got := body["code"]; got != float64(http.StatusInternalServerError) {
t.Fatalf("code = %#v, want %d", got, http.StatusInternalServerError)
}
}
func TestLogger_WritesSanitizedQueryAndErrorContext(t *testing.T) {
gin.SetMode(gin.TestMode)
var buf bytes.Buffer
originalWriter := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() {
log.SetOutput(originalWriter)
})
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(TraceID())
router.Use(Logger())
router.GET("/users", func(c *gin.Context) {
c.Set("user_id", int64(7))
_ = c.Error(errors.New("boom"))
c.Status(http.StatusAccepted)
})
req := httptest.NewRequest(http.MethodGet, "/users?token=secret&name=alice", nil)
req.RemoteAddr = "203.0.113.5:1234"
req.Header.Set("User-Agent", "logger-test")
router.ServeHTTP(recorder, req)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && !strings.Contains(buf.String(), "[Query] /users?name=alice&token=%2A%2A%2A") {
time.Sleep(10 * time.Millisecond)
}
logOutput := buf.String()
if !strings.Contains(logOutput, "[API]") {
t.Fatalf("expected API log entry, got %q", logOutput)
}
if !strings.Contains(logOutput, "user_id: 7") {
t.Fatalf("expected user id in logs, got %q", logOutput)
}
if !strings.Contains(logOutput, "[Error]") || !strings.Contains(logOutput, "boom") {
t.Fatalf("expected error log entry, got %q", logOutput)
}
if strings.Contains(logOutput, "token=secret") {
t.Fatalf("expected sanitized query string, got %q", logOutput)
}
}
func TestLogger_DropsMalformedQueryString(t *testing.T) {
gin.SetMode(gin.TestMode)
var buf bytes.Buffer
originalWriter := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() {
log.SetOutput(originalWriter)
})
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(Logger())
router.GET("/users", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/users?bad=%zz", nil)
router.ServeHTTP(recorder, req)
time.Sleep(25 * time.Millisecond)
if strings.Contains(buf.String(), "[Query]") {
t.Fatalf("expected malformed query to be skipped, got %q", buf.String())
}
}
func TestResponseWrapper_SkipsSSEAndBinaryResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
testCases := []struct {
name string
path string
contentType string
}{
{name: "sse", path: "/stream", contentType: "text/event-stream"},
{name: "binary", path: "/download", contentType: "application/octet-stream"},
{name: "swagger", path: "/swagger/index.html", contentType: ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(ResponseWrapper())
router.GET(tc.path, func(c *gin.Context) {
c.Header("Content-Type", "application/json")
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if got := recorder.Body.String(); got != `{"ok":true}` {
t.Fatalf("body = %s, want raw payload", got)
}
})
}
}
func TestResponseWrapper_BufferMethodsTrackStatusAndBody(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
wrapper := &responseWrapper{
ResponseWriter: c.Writer,
body: bytes.NewBuffer(nil),
statusCode: http.StatusOK,
}
if _, err := wrapper.Write([]byte("abc")); err != nil {
t.Fatalf("Write() error = %v", err)
}
if _, err := wrapper.WriteString("def"); err != nil {
t.Fatalf("WriteString() error = %v", err)
}
wrapper.WriteHeader(http.StatusAccepted)
if got := wrapper.body.String(); got != "abcdef" {
t.Fatalf("buffered body = %q, want abcdef", got)
}
if wrapper.statusCode != http.StatusAccepted {
t.Fatalf("statusCode = %d, want %d", wrapper.statusCode, http.StatusAccepted)
}
}
func TestIPFilter_RealIPAndInternalOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
filter := security.NewIPFilter()
middleware := NewIPFilterMiddleware(filter, IPFilterConfig{
TrustProxy: true,
TrustedProxies: []string{"10.0.0.2"},
})
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/protected", nil)
c.Request.RemoteAddr = "10.0.0.2:8080"
c.Request.Header.Set("X-Forwarded-For", "198.51.100.10, 10.0.0.2")
if got := middleware.realIP(c); got != "198.51.100.10" {
t.Fatalf("realIP() = %q, want 198.51.100.10", got)
}
if !middleware.isTrustedProxy("10.0.0.2") {
t.Fatal("expected trusted proxy match")
}
if middleware.isTrustedProxy("10.0.0.3") {
t.Fatal("unexpected trusted proxy match")
}
if !isPrivateIP("127.0.0.1") {
t.Fatal("expected loopback to be private")
}
if isPrivateIP("198.51.100.10") {
t.Fatal("expected public address to be non-private")
}
allowed := httptest.NewRecorder()
allowedRouter := gin.New()
allowedRouter.Use(InternalOnly())
allowedRouter.GET("/metrics", func(c *gin.Context) {
c.Status(http.StatusOK)
})
allowedReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
allowedReq.RemoteAddr = "127.0.0.1:12345"
allowedRouter.ServeHTTP(allowed, allowedReq)
if allowed.Code != http.StatusOK {
t.Fatalf("expected private IP to pass, got %d", allowed.Code)
}
blocked := httptest.NewRecorder()
blockedRouter := gin.New()
blockedRouter.Use(InternalOnly())
blockedRouter.GET("/metrics", func(c *gin.Context) {
c.Status(http.StatusOK)
})
blockedReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
blockedReq.RemoteAddr = "198.51.100.10:12345"
blockedRouter.ServeHTTP(blocked, blockedReq)
if blocked.Code != http.StatusForbidden {
t.Fatalf("expected public IP to be rejected, got %d", blocked.Code)
}
}
func TestIPFilter_FilterAndFallbacks(t *testing.T) {
gin.SetMode(gin.TestMode)
filter := security.NewIPFilter()
if err := filter.AddToBlacklist("198.51.100.10", "manual", time.Minute); err != nil {
t.Fatalf("AddToBlacklist() error = %v", err)
}
middleware := NewIPFilterMiddleware(filter, IPFilterConfig{})
if middleware.GetFilter() != filter {
t.Fatal("expected GetFilter() to expose the original filter")
}
blockedRecorder := httptest.NewRecorder()
blockedRouter := gin.New()
blockedRouter.Use(middleware.Filter())
blockedRouter.GET("/protected", func(c *gin.Context) {
c.Status(http.StatusOK)
})
blockedReq := httptest.NewRequest(http.MethodGet, "/protected", nil)
blockedReq.RemoteAddr = "198.51.100.10:12345"
blockedRouter.ServeHTTP(blockedRecorder, blockedReq)
if blockedRecorder.Code != http.StatusForbidden {
t.Fatalf("expected blocked IP to be rejected, got %d", blockedRecorder.Code)
}
allowedRecorder := httptest.NewRecorder()
allowedRouter := gin.New()
allowedRouter.Use(middleware.Filter())
allowedRouter.GET("/protected", func(c *gin.Context) {
if got := c.GetString("client_ip"); got != "127.0.0.1" {
t.Fatalf("client_ip = %q, want 127.0.0.1", got)
}
c.Status(http.StatusOK)
})
allowedReq := httptest.NewRequest(http.MethodGet, "/protected", nil)
allowedReq.RemoteAddr = "127.0.0.1:54321"
allowedRouter.ServeHTTP(allowedRecorder, allowedReq)
if allowedRecorder.Code != http.StatusOK {
t.Fatalf("expected allowed IP to pass, got %d", allowedRecorder.Code)
}
trustedProxyMiddleware := NewIPFilterMiddleware(filter, IPFilterConfig{
TrustProxy: true,
})
proxyRecorder := httptest.NewRecorder()
proxyCtx, _ := gin.CreateTestContext(proxyRecorder)
proxyCtx.Request = httptest.NewRequest(http.MethodGet, "/protected", nil)
proxyCtx.Request.RemoteAddr = "10.0.0.2:8080"
proxyCtx.Request.Header.Set("X-Real-IP", "203.0.113.9")
if got := trustedProxyMiddleware.realIP(proxyCtx); got != "203.0.113.9" {
t.Fatalf("realIP() X-Real-IP fallback = %q, want 203.0.113.9", got)
}
}

View File

@@ -0,0 +1,136 @@
package domain
import (
"testing"
"time"
)
func TestDeviceType_Constants(t *testing.T) {
tests := []struct {
name string
value DeviceType
expected int
}{
{"Unknown", DeviceTypeUnknown, 0},
{"Web", DeviceTypeWeb, 1},
{"Mobile", DeviceTypeMobile, 2},
{"Desktop", DeviceTypeDesktop, 3},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if int(tc.value) != tc.expected {
t.Errorf("expected %d, got %d", tc.expected, int(tc.value))
}
})
}
}
func TestDeviceStatus_Constants(t *testing.T) {
tests := []struct {
name string
value DeviceStatus
expected int
}{
{"Inactive", DeviceStatusInactive, 0},
{"Active", DeviceStatusActive, 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if int(tc.value) != tc.expected {
t.Errorf("expected %d, got %d", tc.expected, int(tc.value))
}
})
}
}
func TestDevice_TableName(t *testing.T) {
var d Device
if got := d.TableName(); got != "devices" {
t.Errorf("expected table name 'devices', got %q", got)
}
}
func TestDevice_StructFields(t *testing.T) {
now := time.Now()
trustExpires := now.Add(24 * time.Hour)
d := Device{
ID: 1,
UserID: 2,
DeviceID: "device-123",
DeviceName: "Test Device",
DeviceType: DeviceTypeWeb,
DeviceOS: "Windows",
DeviceBrowser: "Chrome",
IP: "127.0.0.1",
Location: "Beijing",
IsTrusted: true,
TrustExpiresAt: &trustExpires,
Status: DeviceStatusActive,
LastActiveTime: now,
CreatedAt: now,
UpdatedAt: now,
}
if d.ID != 1 {
t.Errorf("expected ID 1, got %d", d.ID)
}
if d.UserID != 2 {
t.Errorf("expected UserID 2, got %d", d.UserID)
}
if d.DeviceID != "device-123" {
t.Errorf("expected DeviceID 'device-123', got %q", d.DeviceID)
}
if d.DeviceName != "Test Device" {
t.Errorf("expected DeviceName 'Test Device', got %q", d.DeviceName)
}
if d.DeviceType != DeviceTypeWeb {
t.Errorf("expected DeviceTypeWeb, got %d", d.DeviceType)
}
if d.DeviceOS != "Windows" {
t.Errorf("expected DeviceOS 'Windows', got %q", d.DeviceOS)
}
if d.DeviceBrowser != "Chrome" {
t.Errorf("expected DeviceBrowser 'Chrome', got %q", d.DeviceBrowser)
}
if d.IP != "127.0.0.1" {
t.Errorf("expected IP '127.0.0.1', got %q", d.IP)
}
if d.Location != "Beijing" {
t.Errorf("expected Location 'Beijing', got %q", d.Location)
}
if !d.IsTrusted {
t.Error("expected IsTrusted to be true")
}
if d.TrustExpiresAt == nil || !d.TrustExpiresAt.Equal(trustExpires) {
t.Error("expected TrustExpiresAt to match")
}
if d.Status != DeviceStatusActive {
t.Errorf("expected DeviceStatusActive, got %d", d.Status)
}
if d.LastActiveTime.IsZero() {
t.Error("expected LastActiveTime to be set")
}
if d.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
if d.UpdatedAt.IsZero() {
t.Error("expected UpdatedAt to be set")
}
}
func TestDevice_DefaultStatus(t *testing.T) {
var d Device
if d.Status != DeviceStatusInactive {
t.Errorf("expected default status Inactive(0), got %d", d.Status)
}
}
func TestDevice_DefaultDeviceType(t *testing.T) {
var d Device
if d.DeviceType != DeviceTypeUnknown {
t.Errorf("expected default device type Unknown(0), got %d", d.DeviceType)
}
}

View File

@@ -14,15 +14,15 @@ const (
// LoginLog 登录日志
type LoginLog struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID *int64 `gorm:"index;index:idx_login_logs_user_created_at" json:"user_id,omitempty"`
ID int64 `gorm:"primaryKey;autoIncrement;index:idx_login_logs_created_at_id,priority:2;index:idx_login_logs_user_created_at,priority:3;index:idx_login_logs_status_created_at_id,priority:3" json:"id"`
UserID *int64 `gorm:"index;index:idx_login_logs_user_created_at,priority:1" json:"user_id,omitempty"`
LoginType int `gorm:"not null" json:"login_type"` // 1-密码, 2-邮箱验证码, 3-手机验证码, 4-OAuth
DeviceID string `gorm:"type:varchar(100)" json:"device_id"`
IP string `gorm:"type:varchar(50)" json:"ip"`
Location string `gorm:"type:varchar(100)" json:"location"`
Status int `gorm:"not null" json:"status"` // 0-失败, 1-成功
Status int `gorm:"not null;index:idx_login_logs_status_created_at_id,priority:1" json:"status"` // 0-失败, 1-成功
FailReason string `gorm:"type:varchar(255)" json:"fail_reason,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_login_logs_user_created_at" json:"created_at"`
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_login_logs_created_at_id,priority:1;index:idx_login_logs_user_created_at,priority:2;index:idx_login_logs_status_created_at_id,priority:2" json:"created_at"`
}
// TableName 指定表名

View File

@@ -4,7 +4,7 @@ import "time"
// OperationLog 操作日志
type OperationLog struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
ID int64 `gorm:"primaryKey;autoIncrement;index:idx_operation_logs_created_at_id,priority:2" json:"id"`
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
OperationType string `gorm:"type:varchar(50)" json:"operation_type"`
OperationName string `gorm:"type:varchar(100)" json:"operation_name"`
@@ -14,7 +14,7 @@ type OperationLog struct {
ResponseStatus int `json:"response_status"`
IP string `gorm:"type:varchar(50)" json:"ip"`
UserAgent string `gorm:"type:varchar(500)" json:"user_agent"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_operation_logs_created_at_id,priority:1" json:"created_at"`
}
// TableName 指定表名

View File

@@ -0,0 +1,35 @@
package domain
import (
"testing"
"time"
)
func TestPasswordHistory_TableName(t *testing.T) {
var h PasswordHistory
if got := h.TableName(); got != "password_histories" {
t.Errorf("expected table name 'password_histories', got %q", got)
}
}
func TestPasswordHistory_StructTags(t *testing.T) {
h := PasswordHistory{
ID: 1,
UserID: 2,
PasswordHash: "hash123",
CreatedAt: time.Now(),
}
if h.ID != 1 {
t.Errorf("expected ID 1, got %d", h.ID)
}
if h.UserID != 2 {
t.Errorf("expected UserID 2, got %d", h.UserID)
}
if h.PasswordHash != "hash123" {
t.Errorf("expected PasswordHash 'hash123', got %q", h.PasswordHash)
}
if h.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
}

View File

@@ -1,5 +1,3 @@
//go:build unit
package errors
import (

View File

@@ -1,5 +1,3 @@
//go:build unit
package ip
import (

View File

@@ -0,0 +1,77 @@
package pagination
import (
"testing"
)
func TestDefaultPagination(t *testing.T) {
p := DefaultPagination()
if p.Page != 1 {
t.Errorf("expected default page 1, got %d", p.Page)
}
if p.PageSize != 20 {
t.Errorf("expected default page_size 20, got %d", p.PageSize)
}
}
func TestPaginationParams_Offset(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
wantOffset int
}{
{"page 1", 1, 20, 0},
{"page 2", 2, 20, 20},
{"page 5", 5, 20, 80},
{"zero page", 0, 20, 0},
{"negative page", -1, 20, 0},
{"page 1 size 10", 1, 10, 0},
{"page 3 size 10", 3, 10, 20},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := PaginationParams{Page: tc.page, PageSize: tc.pageSize}
if got := p.Offset(); got != tc.wantOffset {
t.Errorf("expected offset %d, got %d", tc.wantOffset, got)
}
})
}
}
func TestPaginationParams_Limit(t *testing.T) {
tests := []struct {
name string
pageSize int
want int
}{
{"default", 20, 20},
{"size 10", 10, 10},
{"size 50", 50, 50},
{"size 100", 100, 100},
{"max cap", 101, 100},
{"zero size", 0, 20},
{"negative size", -1, 20},
{"size 1", 1, 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := PaginationParams{PageSize: tc.pageSize}
if got := p.Limit(); got != tc.want {
t.Errorf("expected limit %d, got %d", tc.want, got)
}
})
}
}
func TestPaginationParams_OffsetAndLimit(t *testing.T) {
p := PaginationParams{Page: 3, PageSize: 15}
if got := p.Offset(); got != 30 {
t.Errorf("expected offset 30, got %d", got)
}
if got := p.Limit(); got != 15 {
t.Errorf("expected limit 15, got %d", got)
}
}

View File

@@ -42,7 +42,7 @@ func (r *LoginLogRepository) ListByUserID(ctx context.Context, userID int64, off
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -56,7 +56,7 @@ func (r *LoginLogRepository) List(ctx context.Context, offset, limit int) ([]*do
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -70,7 +70,7 @@ func (r *LoginLogRepository) ListByStatus(ctx context.Context, status int, offse
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -85,7 +85,7 @@ func (r *LoginLogRepository) ListByTimeRange(ctx context.Context, start, end tim
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -137,7 +137,7 @@ func (r *LoginLogRepository) ListAllForExport(ctx context.Context, userID int64,
query = query.Where("created_at <= ?", endAt)
}
if err := query.Order("created_at DESC").Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Find(&logs).Error; err != nil {
return nil, err
}
return logs, nil

View File

@@ -42,7 +42,7 @@ func (r *OperationLogRepository) ListByUserID(ctx context.Context, userID int64,
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -56,7 +56,7 @@ func (r *OperationLogRepository) List(ctx context.Context, offset, limit int) ([
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -70,7 +70,7 @@ func (r *OperationLogRepository) ListByMethod(ctx context.Context, method string
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -85,7 +85,7 @@ func (r *OperationLogRepository) ListByTimeRange(ctx context.Context, start, end
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -110,7 +110,7 @@ func (r *OperationLogRepository) Search(ctx context.Context, keyword string, off
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil

View File

@@ -0,0 +1,95 @@
package repository
import (
"testing"
"github.com/user-management-system/internal/pkg/pagination"
)
func TestPaginationResultFromTotal(t *testing.T) {
tests := []struct {
name string
total int64
params pagination.PaginationParams
wantPages int
wantTotal int64
wantPage int
wantPageSize int
}{
{
name: "exact division",
total: 100,
params: pagination.PaginationParams{Page: 1, PageSize: 20},
wantPages: 5,
wantTotal: 100,
wantPage: 1,
wantPageSize: 20,
},
{
name: "with remainder",
total: 105,
params: pagination.PaginationParams{Page: 1, PageSize: 20},
wantPages: 6,
wantTotal: 105,
wantPage: 1,
wantPageSize: 20,
},
{
name: "zero total",
total: 0,
params: pagination.PaginationParams{Page: 1, PageSize: 20},
wantPages: 0,
wantTotal: 0,
wantPage: 1,
wantPageSize: 20,
},
{
name: "single page",
total: 5,
params: pagination.PaginationParams{Page: 1, PageSize: 20},
wantPages: 1,
wantTotal: 5,
wantPage: 1,
wantPageSize: 20,
},
{
name: "page 2",
total: 50,
params: pagination.PaginationParams{Page: 2, PageSize: 20},
wantPages: 3,
wantTotal: 50,
wantPage: 2,
wantPageSize: 20,
},
{
name: "small page size",
total: 10,
params: pagination.PaginationParams{Page: 1, PageSize: 3},
wantPages: 4,
wantTotal: 10,
wantPage: 1,
wantPageSize: 3,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := paginationResultFromTotal(tc.total, tc.params)
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Total != tc.wantTotal {
t.Errorf("expected total %d, got %d", tc.wantTotal, result.Total)
}
if result.Page != tc.wantPage {
t.Errorf("expected page %d, got %d", tc.wantPage, result.Page)
}
if result.PageSize != tc.wantPageSize {
t.Errorf("expected page_size %d, got %d", tc.wantPageSize, result.PageSize)
}
if result.Pages != tc.wantPages {
t.Errorf("expected pages %d, got %d", tc.wantPages, result.Pages)
}
})
}
}

View File

@@ -0,0 +1,224 @@
package repository
import (
"context"
"testing"
"time"
"github.com/user-management-system/internal/domain"
)
func TestPasswordHistoryRepository_Create(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&domain.PasswordHistory{}); err != nil {
t.Fatalf("migrate password_history failed: %v", err)
}
repo := NewPasswordHistoryRepository(db)
ctx := context.Background()
history := &domain.PasswordHistory{
UserID: 1,
PasswordHash: "hash1",
CreatedAt: time.Now(),
}
if err := repo.Create(ctx, history); err != nil {
t.Fatalf("create failed: %v", err)
}
if history.ID == 0 {
t.Error("expected ID to be set after create")
}
}
func TestPasswordHistoryRepository_GetByUserID(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&domain.PasswordHistory{}); err != nil {
t.Fatalf("migrate password_history failed: %v", err)
}
repo := NewPasswordHistoryRepository(db)
ctx := context.Background()
// Create multiple records for user 1
for i := 0; i < 5; i++ {
h := &domain.PasswordHistory{
UserID: 1,
PasswordHash: "hash",
CreatedAt: time.Now().Add(time.Duration(i) * time.Second),
}
if err := repo.Create(ctx, h); err != nil {
t.Fatalf("create failed: %v", err)
}
}
// Create record for user 2
if err := repo.Create(ctx, &domain.PasswordHistory{UserID: 2, PasswordHash: "hash", CreatedAt: time.Now()}); err != nil {
t.Fatalf("create failed: %v", err)
}
tests := []struct {
name string
userID int64
limit int
wantLen int
wantUser int64
}{
{"get all for user 1", 1, 10, 5, 1},
{"limit 3 for user 1", 1, 3, 3, 1},
{"get for user 2", 2, 10, 1, 2},
{"get for nonexistent user", 999, 10, 0, 999},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
histories, err := repo.GetByUserID(ctx, tc.userID, tc.limit)
if err != nil {
t.Fatalf("get failed: %v", err)
}
if len(histories) != tc.wantLen {
t.Errorf("expected %d histories, got %d", tc.wantLen, len(histories))
}
for _, h := range histories {
if h.UserID != tc.wantUser {
t.Errorf("expected user_id %d, got %d", tc.wantUser, h.UserID)
}
}
})
}
}
func TestPasswordHistoryRepository_GetByUserID_Order(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&domain.PasswordHistory{}); err != nil {
t.Fatalf("migrate password_history failed: %v", err)
}
repo := NewPasswordHistoryRepository(db)
ctx := context.Background()
// Create records with different timestamps
now := time.Now()
for i := 0; i < 3; i++ {
h := &domain.PasswordHistory{
UserID: 1,
PasswordHash: "hash",
CreatedAt: now.Add(time.Duration(i) * time.Hour),
}
if err := repo.Create(ctx, h); err != nil {
t.Fatalf("create failed: %v", err)
}
}
histories, err := repo.GetByUserID(ctx, 1, 10)
if err != nil {
t.Fatalf("get failed: %v", err)
}
if len(histories) != 3 {
t.Fatalf("expected 3 histories, got %d", len(histories))
}
// Should be ordered by created_at DESC (newest first)
for i := 0; i < len(histories)-1; i++ {
if !histories[i].CreatedAt.After(histories[i+1].CreatedAt) && !histories[i].CreatedAt.Equal(histories[i+1].CreatedAt) {
t.Errorf("expected descending order, got %v before %v", histories[i].CreatedAt, histories[i+1].CreatedAt)
}
}
}
func TestPasswordHistoryRepository_DeleteOldRecords(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&domain.PasswordHistory{}); err != nil {
t.Fatalf("migrate password_history failed: %v", err)
}
repo := NewPasswordHistoryRepository(db)
ctx := context.Background()
// Create 5 records for user 1
now := time.Now()
for i := 0; i < 5; i++ {
h := &domain.PasswordHistory{
UserID: 1,
PasswordHash: "hash",
CreatedAt: now.Add(time.Duration(i) * time.Hour),
}
if err := repo.Create(ctx, h); err != nil {
t.Fatalf("create failed: %v", err)
}
}
// Delete old records, keep only 3
if err := repo.DeleteOldRecords(ctx, 1, 3); err != nil {
t.Fatalf("delete old records failed: %v", err)
}
histories, err := repo.GetByUserID(ctx, 1, 10)
if err != nil {
t.Fatalf("get failed: %v", err)
}
if len(histories) != 3 {
t.Errorf("expected 3 histories after cleanup, got %d", len(histories))
}
}
func TestPasswordHistoryRepository_DeleteOldRecords_NoRecords(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&domain.PasswordHistory{}); err != nil {
t.Fatalf("migrate password_history failed: %v", err)
}
repo := NewPasswordHistoryRepository(db)
ctx := context.Background()
// Should not error when no records exist
if err := repo.DeleteOldRecords(ctx, 999, 3); err != nil {
t.Fatalf("delete old records on empty table should not error: %v", err)
}
}
func TestPasswordHistoryRepository_KeepsNewestRecords(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&domain.PasswordHistory{}); err != nil {
t.Fatalf("migrate password_history failed: %v", err)
}
repo := NewPasswordHistoryRepository(db)
ctx := context.Background()
// Create 5 records with different timestamps
now := time.Now()
var createdIDs []int64
for i := 0; i < 5; i++ {
h := &domain.PasswordHistory{
UserID: 1,
PasswordHash: "hash",
CreatedAt: now.Add(time.Duration(i) * time.Hour),
}
if err := repo.Create(ctx, h); err != nil {
t.Fatalf("create failed: %v", err)
}
createdIDs = append(createdIDs, h.ID)
}
// Delete old records, keep only 2
if err := repo.DeleteOldRecords(ctx, 1, 2); err != nil {
t.Fatalf("delete old records failed: %v", err)
}
histories, err := repo.GetByUserID(ctx, 1, 10)
if err != nil {
t.Fatalf("get failed: %v", err)
}
if len(histories) != 2 {
t.Fatalf("expected 2 histories after cleanup, got %d", len(histories))
}
// The remaining records should be the newest (last 2 created)
expectedIDs := map[int64]bool{createdIDs[3]: true, createdIDs[4]: true}
for _, h := range histories {
if !expectedIDs[h.ID] {
t.Errorf("expected remaining IDs to be %v, got %d", expectedIDs, h.ID)
}
}
}

View File

@@ -0,0 +1,117 @@
package repository
import (
"context"
"database/sql"
"errors"
"testing"
)
// mockQueryer implements sqlQueryer for testing
type mockQueryer struct {
rows *sql.Rows
err error
}
func (m *mockQueryer) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return m.rows, m.err
}
func TestScanSingleRow_QueryError(t *testing.T) {
ctx := context.Background()
mockErr := errors.New("query failed")
q := &mockQueryer{err: mockErr}
var dest int
err := scanSingleRow(ctx, q, "SELECT 1", nil, &dest)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, mockErr) {
t.Errorf("expected query error, got %v", err)
}
}
func TestScanSingleRow_NoRows(t *testing.T) {
// This test requires a real database connection to create sql.Rows.
// scanSingleRow is designed to work with any sqlQueryer, but creating
// a mock sql.Rows without a real driver is complex.
// We test the behavior through integration with the test database.
db := openTestDB(t)
ctx := context.Background()
// Use the raw sql.DB from gorm
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql.DB failed: %v", err)
}
var dest int
err = scanSingleRow(ctx, sqlDB, "SELECT 1 WHERE 1=0", nil, &dest)
if err == nil {
t.Fatal("expected error for no rows, got nil")
}
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("expected sql.ErrNoRows, got %v", err)
}
}
func TestScanSingleRow_Success(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql.DB failed: %v", err)
}
var dest int
err = scanSingleRow(ctx, sqlDB, "SELECT 42", nil, &dest)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if dest != 42 {
t.Errorf("expected 42, got %d", dest)
}
}
func TestScanSingleRow_MultipleColumns(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql.DB failed: %v", err)
}
var a, b int
err = scanSingleRow(ctx, sqlDB, "SELECT 1, 2", nil, &a, &b)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if a != 1 {
t.Errorf("expected a=1, got %d", a)
}
if b != 2 {
t.Errorf("expected b=2, got %d", b)
}
}
func TestScanSingleRow_StringResult(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql.DB failed: %v", err)
}
var dest string
err = scanSingleRow(ctx, sqlDB, "SELECT 'hello'", nil, &dest)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if dest != "hello" {
t.Errorf("expected 'hello', got %q", dest)
}
}

View File

@@ -362,10 +362,10 @@ func (r *UserRepository) AdvancedSearch(ctx context.Context, filter *AdvancedFil
// 分页
limit := filter.Limit
if limit <= 0 {
limit = 20
limit = pagination.DefaultPageSize
}
if limit > 200 {
limit = 200
if limit > pagination.MaxPageSize {
limit = pagination.MaxPageSize
}
query = query.Offset(filter.Offset).Limit(limit)

View File

@@ -30,6 +30,7 @@ const (
defaultTOTPChallengeTTL = 5 * time.Minute
defaultPasswordMinLen = 8
refreshTokenRetryGrace = 10 * time.Second
defaultBETimeout = 5 * time.Second // best-effort 后台操作默认超时
MaxUsernameAttempts = 100 // 最大尝试次数P1性能优化减少循环查询
MaxUsernameLength = 40 // 用户名最大长度
)
@@ -553,7 +554,7 @@ func (s *AuthService) writeLoginLog(
log.Printf("auth: write login log panic recovered, user_id=%v login_type=%d err=%v", userID, loginType, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
if err := s.loginLogRepo.Create(bgCtx, loginRecord); err != nil {
log.Printf("auth: write login log failed, user_id=%v login_type=%d err=%v", userID, loginType, err)
@@ -634,7 +635,7 @@ func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64
log.Printf("auth: register device panic recovered, user_id=%d device_id=%s err=%v", userID, req.DeviceID, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
_, _ = s.deviceService.CreateDevice(bgCtx, userID, createReq)
}()

View File

@@ -95,7 +95,7 @@ func (s *AuthService) bestEffortUpdateLastLogin(ctx context.Context, userID int6
log.Printf("auth: update last login panic recovered, source=%s user_id=%d err=%v", source, userID, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
if err := s.userRepo.UpdateLastLogin(bgCtx, userID, ip); err != nil {
log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err)

View File

@@ -223,7 +223,7 @@ func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page,
page = 1
}
if pageSize <= 0 {
pageSize = 20
pageSize = pagination.DefaultPageSize
}
offset := (page - 1) * pageSize
@@ -275,7 +275,7 @@ func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int
page = 1
}
if pageSize <= 0 {
pageSize = 20
pageSize = pagination.DefaultPageSize
}
offset := (page - 1) * pageSize

View File

@@ -228,7 +228,7 @@ func (s *LoginLogService) GetMyLoginLogs(ctx context.Context, userID int64, page
page = 1
}
if pageSize <= 0 {
pageSize = 20
pageSize = pagination.DefaultPageSize
}
offset := (page - 1) * pageSize
return s.loginLogRepo.ListByUserID(ctx, userID, offset, pageSize)

View File

@@ -143,7 +143,7 @@ func (s *OperationLogService) GetMyOperationLogs(ctx context.Context, userID int
page = 1
}
if pageSize <= 0 {
pageSize = 20
pageSize = pagination.DefaultPageSize
}
offset := (page - 1) * pageSize
return s.operationLogRepo.ListByUserID(ctx, userID, offset, pageSize)

View File

@@ -299,7 +299,7 @@ func (s *PasswordResetService) doResetPassword(ctx context.Context, user *domain
if s.passwordHistoryRepo != nil {
// #nosec G118 - 使用带超时的独立 context防止 DB 写入无限等待
go func() { // #nosec G118
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
UserID: user.ID,

View File

@@ -132,7 +132,7 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
if s.passwordHistoryRepo != nil {
// #nosec G118 - 使用带超时的独立 context不能使用请求 ctx该 goroutine 在请求完成后仍可能运行)
go func(hashedPw string) { // #nosec G118
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
UserID: userID,
@@ -199,7 +199,7 @@ func (s *UserService) applyNewPassword(ctx context.Context, user *domain.User, n
log.Printf("user_service: password history save panic recovered, user_id=%d err=%v", userID, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
UserID: userID,

View File

@@ -295,7 +295,7 @@ func (s *WebhookService) recordDelivery(task *deliveryTask, statusCode int, body
delivery.DeliveredAt = &now
}
// 使用带超时的独立 context防止 DB 写入无限等待
writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
writeCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
_ = s.repo.CreateDelivery(writeCtx, delivery)
}