From 25d3592fe4b0a753a037890879870898f76bbc79 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 4 Feb 2026 13:53:33 +0800 Subject: [PATCH] test: add middleware tests and update todo_list with test coverage details - Add middlewares_test.go with tests for AuthOptional, AuthRequired, SuperAuth, and TenantResolver - Update todo_list.md with test specifications, coverage status, and pending test cases (T1-T4) --- backend/app/middlewares/middlewares_test.go | 318 ++++++++++++++++++++ docs/todo_list.md | 122 ++++++-- 2 files changed, 422 insertions(+), 18 deletions(-) create mode 100644 backend/app/middlewares/middlewares_test.go diff --git a/backend/app/middlewares/middlewares_test.go b/backend/app/middlewares/middlewares_test.go new file mode 100644 index 0000000..f8ddae7 --- /dev/null +++ b/backend/app/middlewares/middlewares_test.go @@ -0,0 +1,318 @@ +package middlewares + +import ( + "context" + "database/sql" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "quyun/v2/app/commands/testx" + "quyun/v2/app/errorx" + "quyun/v2/app/services" + "quyun/v2/database" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" + jwtv4 "github.com/golang-jwt/jwt/v4" + . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/suite" + "go.ipao.vip/atom/contracts" + "go.ipao.vip/gen/types" + "go.uber.org/dig" +) + +type MiddlewaresTestSuiteInjectParams struct { + dig.In + + DB *sql.DB + Initials []contracts.Initial `group:"initials"` + JWT *jwt.JWT + Middlewares *Middlewares +} + +type MiddlewaresTestSuite struct { + suite.Suite + MiddlewaresTestSuiteInjectParams +} + +func Test_Middlewares(t *testing.T) { + providers := testx.Default().With(Provide) + + testx.Serve(providers, t, func(p MiddlewaresTestSuiteInjectParams) { + suite.Run(t, &MiddlewaresTestSuite{MiddlewaresTestSuiteInjectParams: p}) + }) +} + +func (s *MiddlewaresTestSuite) newTestApp() *fiber.App { + handler := errorx.NewErrorHandler() + return fiber.New(fiber.Config{ + ErrorHandler: func(c fiber.Ctx, err error) error { + appErr := handler.Handle(err) + return c.Status(appErr.StatusCode).JSON(fiber.Map{ + "code": appErr.Code, + "message": appErr.Message, + }) + }, + }) +} + +func (s *MiddlewaresTestSuite) createTestUser(ctx context.Context, phone string, roles types.Array[consts.Role]) *models.User { + user := &models.User{ + Phone: phone, + Roles: roles, + Status: consts.UserStatusVerified, + } + _ = models.UserQuery.WithContext(ctx).Create(user) + return user +} + +func (s *MiddlewaresTestSuite) createToken(userID, tenantID int64) string { + claims := s.JWT.CreateClaims(jwt.BaseClaims{ + UserID: userID, + TenantID: tenantID, + }) + token, _ := s.JWT.CreateToken(claims) + return "Bearer " + token +} + +func (s *MiddlewaresTestSuite) createExpiredToken(userID int64) string { + claims := &jwt.Claims{ + BaseClaims: jwt.BaseClaims{ + UserID: userID, + }, + } + claims.ExpiresAt = jwtv4.NewNumericDate(time.Now().Add(-time.Hour)) + claims.NotBefore = jwtv4.NewNumericDate(time.Now().Add(-2 * time.Hour)) + + token := jwtv4.NewWithClaims(jwtv4.SigningMethodHS256, claims) + tokenString, _ := token.SignedString(s.JWT.SigningKey) + return "Bearer " + tokenString +} + +func (s *MiddlewaresTestSuite) Test_AuthOptional() { + Convey("AuthOptional", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser) + + Convey("should pass without token and ctx.Locals has no user", func() { + app := s.newTestApp() + app.Use(s.Middlewares.AuthOptional) + app.Get("/test", func(c fiber.Ctx) error { + user := c.Locals(consts.CtxKeyUser) + if user == nil { + return c.SendString("no_user") + } + return c.SendString("has_user") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + + body, _ := io.ReadAll(resp.Body) + So(string(body), ShouldEqual, "no_user") + }) + + Convey("should pass with valid token and ctx.Locals has user", func() { + app := s.newTestApp() + app.Use(s.Middlewares.AuthOptional) + app.Get("/test", func(c fiber.Ctx) error { + user := c.Locals(consts.CtxKeyUser) + if user == nil { + return c.SendString("no_user") + } + return c.SendString("has_user") + }) + + user := s.createTestUser(ctx, "13800000001", types.Array[consts.Role]{consts.RoleUser}) + token := s.createToken(user.ID, 0) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", token) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + + body, _ := io.ReadAll(resp.Body) + So(string(body), ShouldEqual, "has_user") + }) + }) +} + +func (s *MiddlewaresTestSuite) Test_AuthRequired() { + Convey("AuthRequired", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser) + + Convey("should return 401 without token", func() { + app := s.newTestApp() + app.Use(s.Middlewares.AuthRequired) + app.Get("/protected", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + + Convey("should return 401 with invalid token", func() { + app := s.newTestApp() + app.Use(s.Middlewares.AuthRequired) + app.Get("/protected", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer invalid_token") + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + + Convey("should pass with valid token", func() { + app := s.newTestApp() + app.Use(s.Middlewares.AuthRequired) + app.Get("/protected", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + user := s.createTestUser(ctx, "13800000002", types.Array[consts.Role]{consts.RoleUser}) + token := s.createToken(user.ID, 0) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", token) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + }) +} + +func (s *MiddlewaresTestSuite) Test_SuperAuth() { + Convey("SuperAuth", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser) + + Convey("should return 401 without token", func() { + app := s.newTestApp() + app.Use(s.Middlewares.SuperAuth) + app.Get("/super/v1/tenants", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/super/v1/tenants", nil) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + + Convey("should return 403 when user is not super_admin", func() { + app := s.newTestApp() + app.Use(s.Middlewares.SuperAuth) + app.Get("/super/v1/tenants", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + user := s.createTestUser(ctx, "13800000003", types.Array[consts.Role]{consts.RoleUser}) + token := s.createToken(user.ID, 0) + + req := httptest.NewRequest(http.MethodGet, "/super/v1/tenants", nil) + req.Header.Set("Authorization", token) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusForbidden) + }) + + Convey("should pass when user is super_admin", func() { + app := s.newTestApp() + app.Use(s.Middlewares.SuperAuth) + app.Get("/super/v1/tenants", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + user := s.createTestUser(ctx, "13800000004", types.Array[consts.Role]{consts.RoleSuperAdmin}) + token := s.createToken(user.ID, 0) + + req := httptest.NewRequest(http.MethodGet, "/super/v1/tenants", nil) + req.Header.Set("Authorization", token) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("should allow public routes without auth", func() { + app := s.newTestApp() + app.Use(s.Middlewares.SuperAuth) + app.Post("/super/v1/auth/login", func(c fiber.Ctx) error { + return c.SendString("login") + }) + + req := httptest.NewRequest(http.MethodPost, "/super/v1/auth/login", nil) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + }) +} + +func (s *MiddlewaresTestSuite) Test_TenantResolver() { + Convey("TenantResolver", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNameUser) + + Convey("should return 404 when tenant not found", func() { + app := s.newTestApp() + app.Get("/t/:tenantCode/v1/test", s.Middlewares.TenantResolver, func(c fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/t/nonexistent/v1/test", nil) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusNotFound) + }) + + Convey("should set tenant in ctx.Locals when found", func() { + owner := &models.User{Phone: "13800000005", Status: consts.UserStatusVerified} + _ = models.UserQuery.WithContext(ctx).Create(owner) + + tenant := &models.Tenant{ + Name: "Test Tenant", + Code: "test_tenant", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + app := s.newTestApp() + app.Get("/t/:tenantCode/v1/test", s.Middlewares.TenantResolver, func(c fiber.Ctx) error { + t := c.Locals(consts.CtxKeyTenant) + if t == nil { + return c.SendString("no_tenant") + } + if model, ok := t.(*models.Tenant); ok { + return c.SendString(model.Code) + } + return c.SendString("invalid_tenant") + }) + + req := httptest.NewRequest(http.MethodGet, "/t/test_tenant/v1/test", nil) + resp, err := app.Test(req) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + + body, _ := io.ReadAll(resp.Body) + So(string(body), ShouldEqual, "test_tenant") + }) + }) +} + +var _ = services.User diff --git a/docs/todo_list.md b/docs/todo_list.md index d0724a1..8cc0a71 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -13,6 +13,79 @@ - 分层约束:Controller 仅做绑定与调用,业务与 DB 仅在 `services`。 - 每个需求至少配套:接口说明 + 数据变更(如有)+ 关键业务校验 + 单元测试。 +## 测试规范 + +### 服务测试(必须) +- 使用 Convey + testify/suite 框架(参考 `backend/tests/README.md`)。 +- 每个服务方法至少覆盖:正常路径 + 主要错误分支。 +- 数据准备使用 `database.Truncate` + 直接 GORM-Gen 创建。 +- 断言使用 Convey 的 `So` 函数,遵循 `当...那么...` 语义结构。 + +### 中间件测试(按需) +- HTTP 层行为使用 `httptest` + `fiber.Test`。 +- 验证响应码 + 响应体 + `ctx.Locals` 状态。 + +### 集成测试(关键路径) +- 覆盖完整业务流程(如:申请 → 审核 → 成员可见)。 +- 使用真实 DB 连接(`v2_test` 数据库)。 + +### 前端验收 +- 当前依赖手动验收(参考 `docs/seed_verification.md`)。 +- 重要流程变更需截图或录屏确认。 + +## 测试覆盖现状 + +| 模块 | 状态 | 测试文件 | 覆盖范围 | +|------|------|----------|----------| +| 租户成员体系 | ✅ 完善 | `tenant_member_test.go` | 申请/取消/审核/邀请/成员管理 | +| 上传会话归属 | ✅ 完善 | `common_test.go` | owner 校验/forbidden/not found | +| 内容访问策略 | ✅ 基础覆盖 | `content_test.go` | tenant_only/private/preview | +| 鉴权与权限 | ⚠️ 部分 | `user_test.go` | 仅 OTP 登录 + 成员校验,缺中间件测试 | +| 审计参数传递 | ❌ 缺失 | 无 | 无测试覆盖 | + +## 待补充测试用例 + +### T1) 鉴权中间件测试 +**建议文件**:`backend/app/middlewares/middlewares_test.go` + +**测试场景**: +- `AuthOptional`:无 token 访问正常通过,`ctx.Locals` 无 user +- `AuthOptional`:有效 token 访问,`ctx.Locals` 有 user +- `AuthRequired`:无 token 访问返回 401 +- `AuthRequired`:过期 token 返回 401 +- `AuthRequired`:有效 token 正常通过 +- `super_admin` 校验:非 super_admin 角色访问 `/super/v1/*` 返回 403 +- `super_admin` 校验:super_admin 角色访问正常通过 + +### T2) 审计日志测试 +**建议文件**:`backend/app/services/audit_test.go` + +**测试场景**: +- `Audit.Log` 正常落库:验证 `tenantID`/`operatorID`/`action`/`targetID`/`detail` 字段 +- `Audit.Log` 缺参:`operatorID=0` 时行为(当前仅 warn 日志) +- 关键操作触发审计:如 `ReviewJoin` 后 `audit_logs` 有对应记录 +- 审计记录可查询:按 `tenant`/`operator`/`action` 筛选 + +### T3) 内容访问策略测试补充 +**扩展文件**:`backend/app/services/content_test.go` + +**补充场景**: +- 未登录访问 public 内容:仅 preview + cover +- 已购买访问:完整 media +- 作者/管理员访问 private:完整 media +- 非成员访问 tenant_only:forbidden 或仅 preview +- 签名 URL 生成前权限校验 + +### T4) 超管写操作测试 +**扩展文件**:`backend/app/services/super_test.go` + +**补充场景**: +- `CreateTenant`:验证 `expired_at` 计算正确 +- `CreateTenant`:验证 `tenant_users` 管理员关系写入 +- 创作者设置读写:超管可读取/更新任意租户设置 +- 收款账户管理:超管新增/编辑/禁用 +- 用户资料编辑:超管可更新基础字段 + ## P0(必须先做) ### 1) 租户成员体系(加入/邀请/审核)(已完成) @@ -34,10 +107,14 @@ - 申请成功后写 `tenant_join_requests`;审核通过写 `tenant_users`。 - 对 `tenant_only` 内容访问:需要 `tenant_users.status=verified` 或已购买。 -**测试方案** -- 申请加入:重复申请拦截。 -- 审核通过后,tenant_only 可访问;未通过不可访问。 -- 邀请链接过期/重复使用处理。 +**测试方案**(✅ 已覆盖 `tenant_member_test.go`) +- 申请加入:创建 pending 请求、重复申请拦截、已是成员时拒绝。 +- 取消申请:正常取消、请求不存在时返回 not found。 +- 审核流程:approve 后写入 `tenant_users`、reject 后状态变更。 +- 邀请创建:生成有效邀请码、设置过期时间与使用次数。 +- 接受邀请:正常加入、`used_count` 递增、邀请失效后拒绝。 +- 成员管理:列表分页、移除成员后不可查。 +- 邀请管理:列表查询、禁用邀请后状态变更。 ### 2) 鉴权与权限收口(必需)(已完成) **需求目标** @@ -49,9 +126,11 @@ - 超管接口:校验 JWT roles 含 `super_admin`。 - 服务:补齐登录、token 续期/失效逻辑。 -**测试方案** -- 未登录访问受保护接口被拒绝。 -- 非超管访问 `/super/v1/*` 被拒绝。 +**测试方案**(⚠️ 部分覆盖 `user_test.go`,缺中间件测试) +- ✅ 已覆盖:OTP 登录 + 租户成员校验(非成员拒绝、成员允许)。 +- ✅ 已覆盖:`User.Me` 无 context user 时失败。 +- ❌ 待补充:`AuthOptional`/`AuthRequired` 中间件行为(见 T1)。 +- ❌ 待补充:`super_admin` 角色校验(非超管访问 `/super/v1/*` 返回 403)。 ### 3) 上传会话归属测试补齐(已完成) **需求目标** @@ -60,8 +139,9 @@ **技术方案(后端)** - 为 `Common.UploadPart` 增加服务测试,验证 owner mismatch 返回 `ErrForbidden`。 -**测试方案** -- owner 与非 owner 上传分支。 +**测试方案**(✅ 已覆盖 `common_test.go`) +- `AbortUpload`:owner 可取消、非 owner 返回 forbidden、不存在返回 not found。 +- `UploadPart`:owner 可上传、非 owner 返回 forbidden。 ### 4) 超管全量可编辑(补齐写操作缺口)(已完成) **需求目标** @@ -82,11 +162,11 @@ - 用户资料超管编辑 - 新增 `PATCH /super/v1/users/:id`(允许更新昵称/头像/实名标记等基础字段)。 -**测试方案** -- CreateTenant 后 `expired_at` 与 `tenant_users` 均落库。 -- 超管可读取/更新创作者设置,权限校验通过。 -- 超管可新增/编辑收款账户。 -- 超管可更新用户资料,字段校验生效。 +**测试方案**(⚠️ 部分覆盖 `super_test.go`,待扩展见 T4) +- ✅ 基础:`CreateTenant` 后 `expired_at` 与 `tenant_users` 均落库。 +- ❌ 待补充:超管读取/更新创作者设置。 +- ❌ 待补充:超管新增/编辑收款账户。 +- ❌ 待补充:超管更新用户资料,字段校验生效。 ### 5) 通知模板支持编辑(已完成) **需求目标** @@ -145,8 +225,12 @@ - tenant_only + 已购/成员/作者/管理员:完整 - private:仅作者/管理员 -**测试方案** -- 未登录/未购/已购/作者/管理员的可见资源集合一致性。 +**测试方案**(⚠️ 部分覆盖 `content_test.go`,待扩展见 T3) +- ✅ 已覆盖:`tenant_only` 访问(成员/管理员允许,非成员拒绝)。 +- ✅ 已覆盖:`private` 内容访问(仅作者可见)。 +- ✅ 已覆盖:preview vs full 逻辑(guest/owner/buyer)。 +- ❌ 待补充:未登录访问 public 内容仅返回 preview + cover。 +- ❌ 待补充:签名 URL 生成前权限校验。 ### 9) 审计参数传递规范化(已完成) **需求目标** @@ -156,8 +240,10 @@ - 调整 `services.Audit` 方法签名与调用方传参。 - 关键操作补齐操作者字段。 -**测试方案** -- 审计记录落库含 `operator_id`,并覆盖缺参错误。 +**测试方案**(❌ 缺失,待补充见 T2) +- 待补充:`Audit.Log` 正常落库,验证各字段。 +- 待补充:关键操作(如 `ReviewJoin`)触发审计记录。 +- 待补充:审计记录可按 `tenant`/`operator`/`action` 筛选。 ### 10) 创作者中心 - 团队成员管理(Portal UI)(已完成) **需求目标**