package middleware_test import ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "perms-system-server/internal/consts" "perms-system-server/internal/middleware" "perms-system-server/internal/model" productModel "perms-system-server/internal/model/product" productmemberModel "perms-system-server/internal/model/productmember" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TC-0749: L-B 修复回归 —— jwtauthMiddleware 必须优先判定 TokenVersion 失效, // 而不是 ProductStatus/MemberType。旧实现会在"产品已禁用"场景下先返回 403 ProductDisabled, // 使用户被强制退出时看到无关文案;修复后 TokenVersion 不一致应返回 401 "登录状态已失效,请重新登录"。 func TestJwtAuthMiddleware_TokenVersionCheckedBeforeProductStatus(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() // 1) 创建"已禁用"的产品 pCode := "mw_ord_" + testutil.UniqueId() pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s", Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() // 2) 创建启用中的用户,TokenVersion=5 username := "mw_ord_u_" + testutil.UniqueId() uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: "x", Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 5, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() // 3) 该用户是禁用产品的 ADMIN 成员 mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{ ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) m, _ := newTestMiddleware() // 携带 stale TokenVersion=3 的 access token(DB 是 5)+ 禁用产品 code tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: pCode, TokenVersion: 3, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) assert.Equal(t, 401, body.Code, "L-B:TokenVersion 失配必须先于产品禁用被识别(返回 401 而非 403)") assert.Equal(t, "登录状态已失效,请重新登录", body.Msg, "L-B:文案必须是'登录状态已失效'而不是'该产品已被禁用',否则用户会被无关信息误导") } // TC-0750: L-B 修复回归 —— TokenVersion 匹配但产品被禁用,仍应返回 403 "该产品已被禁用"。 // 保证修复未把所有场景都吞成 401。 func TestJwtAuthMiddleware_ProductDisabledAfterVersionOk(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() pCode := "mw_ord2_" + testutil.UniqueId() pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s", Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() username := "mw_ord2_u_" + testutil.UniqueId() uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: "x", Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{ ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) m, _ := newTestMiddleware() tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: pCode, TokenVersion: 0, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) assert.Equal(t, 403, body.Code) assert.Equal(t, "该产品已被禁用", body.Msg) } // --- 鉴权优先级完整矩阵(L-B 延伸,TC-0754 ~ TC-0758)--- // 代码中顺序: Username empty -> Status disabled -> TokenVersion mismatch -> ProductStatus -> MemberType。 // 这 5 个用例用"同时踩两个坑"的组合方式, 严格断言哪个错误文案胜出, 形成鉴权优先级的冻结矩阵。 // TC-0754: 用户已被删除 + TokenVersion 失配 -> 优先返回 401 "用户不存在或已被删除"。 // 场景: 攻击者拿着 stale token + 账号已被删除, 服务端必须先识别出用户不存在而不是"登录已失效", // 否则会把"软删除"语义泄漏成"用户登出"从而引导攻击者再次尝试重登。 func TestJwtAuthMiddleware_UserDeletedBeatsTokenVersion(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() username := "mw_del_" + testutil.UniqueId() uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: "x", Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 5, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() require.NoError(t, models.SysUserModel.Delete(ctx, userId)) m, _ := newTestMiddleware() tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, TokenVersion: 1, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) assert.Equal(t, 401, body.Code, "L-B 矩阵: Username empty 必须在 TokenVersion 之前裁决") assert.Equal(t, "用户不存在或已被删除", body.Msg, "L-B 矩阵: 用户被删除时文案不可退化成 '登录已失效',否则泄漏软删除语义") } // TC-0755: 账号被冻结 + TokenVersion 失配 + 产品被禁用 -> 胜出应是 403 "账号已被冻结"。 // 三重 failing condition 叠加, 验证"账号级"问题比"会话级"(TokenVersion) 和"产品级"(ProductStatus) 优先级更高。 func TestJwtAuthMiddleware_FrozenBeatsEverything(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() pCode := "mw_fz_" + testutil.UniqueId() pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s", Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() username := "mw_fz_u_" + testutil.UniqueId() uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: "x", Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusDisabled, TokenVersion: 9, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{ ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) m, _ := newTestMiddleware() tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: pCode, TokenVersion: 1, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) assert.Equal(t, 403, body.Code, "L-B 矩阵: 账号冻结(403) 胜出, 而非 TokenVersion(401) 或 ProductStatus(403/禁用)") assert.Equal(t, "账号已被冻结", body.Msg, "L-B 矩阵: 冻结文案必须先于 '登录已失效'/'产品禁用' 返回给客户端") } // TC-0756: TokenVersion OK + 产品启用 + 非超管 + MemberType 为空 -> 403 "您已不是该产品的有效成员"。 // 场景: 用户曾是产品成员, 后被移除, 但老 token 未过期; 本用例保证"移除成员"的写路径会被读路径识别。 func TestJwtAuthMiddleware_NonMemberRejected(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() pCode := "mw_nm_" + testutil.UniqueId() pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s", Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() username := "mw_nm_u_" + testutil.UniqueId() uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: "x", Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) m, _ := newTestMiddleware() tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: pCode, TokenVersion: 0, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) assert.Equal(t, 403, body.Code) assert.Equal(t, "您已不是该产品的有效成员", body.Msg, "L-B 矩阵: MemberType 空 + 非超管 + 产品启用 必须精确命中'不是有效成员'文案") } // TC-0757: 超级管理员 + ProductCode 携带 + MemberType 空 -> 正常放行。 // 超管"旁路"分支不能被移除, 否则超管在产品上下文会被错误踢出。 func TestJwtAuthMiddleware_SuperAdminBypassesMemberCheck(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() pCode := "mw_sa_" + testutil.UniqueId() pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s", Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() username := "mw_sa_u_" + testutil.UniqueId() uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: "x", Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminYes, MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) m, _ := newTestMiddleware() tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, ProductCode: pCode, TokenVersion: 0, }) var reached bool handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { reached = true w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code, "L-B 矩阵: 超管必须放行, 即使在产品上下文无 MemberType") assert.True(t, reached, "请求必须到达业务 handler") } // TC-0758: 无 ProductCode 时, Frozen 用户 + TokenVersion 失配 -> 403 "账号已被冻结"。 // 验证即使不走产品相关分支, Status 检查仍先于 TokenVersion 裁决。 func TestJwtAuthMiddleware_FrozenBeatsTokenVersionNoProduct(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) now := time.Now().Unix() username := "mw_fz2_u_" + testutil.UniqueId() uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: "x", Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusDisabled, TokenVersion: 7, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) m, _ := newTestMiddleware() tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{ TokenType: consts.TokenTypeAccess, UserId: userId, Username: username, TokenVersion: 0, }) handler := m.Handle(func(w http.ResponseWriter, r *http.Request) { t.Fatal("unreachable") }) req := httptest.NewRequest(http.MethodPost, "/test", nil) req.Header.Set("Authorization", "Bearer "+tokenStr) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) var body response.Body require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body)) assert.Equal(t, 403, body.Code) assert.Equal(t, "账号已被冻结", body.Msg) }