userDetailsLoader_contract_audit_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. package loaders
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "strings"
  8. "testing"
  9. "time"
  10. "perms-system-server/internal/consts"
  11. productModel "perms-system-server/internal/model/product"
  12. memberModel "perms-system-server/internal/model/productmember"
  13. userModel "perms-system-server/internal/model/user"
  14. "github.com/stretchr/testify/assert"
  15. "github.com/stretchr/testify/require"
  16. )
  17. // ---------------------------------------------------------------------------
  18. // 覆盖目标:审计 M-1 / H-1 / L-3 / L-6 的 Loader 新契约。
  19. // 旧契约 Load 返回单值 *UserDetails;DB 故障被同化为"用户不存在",而且任何 perms / role / dept
  20. // 子步骤失败都会把"半残 UD"写 5 分钟缓存。新契约:
  21. // 1) (ud, err) 双返回:err 表示基础设施故障;
  22. // 2) 真实不存在的用户 → (ud, nil) 且 ud.Username == "";
  23. // 3) 主体加载成功但子步骤失败 → (ud, nil) 且 "不写缓存"(下次 Load 重试);
  24. // 4) L-6:在 Load 期间被 CreateUser 的 userId 不得被留下负缓存哨兵(投毒防御)。
  25. // ---------------------------------------------------------------------------
  26. // TC-0913: M-1 —— 不存在用户走 (ud, nil) 语义,而不是 (nil, err),让中间件能区分 401 vs 503
  27. func TestUserDetailsLoader_Load_NotExist_ReturnsUdWithNilErr(t *testing.T) {
  28. ctx := context.Background()
  29. loader := newTestLoader()
  30. nonExistId := int64(900_100_000 + time.Now().UnixNano()%100_000)
  31. productCode := "pc_nxud_" + uniqueId()
  32. t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
  33. ud, err := loader.Load(ctx, nonExistId, productCode)
  34. require.NoError(t, err,
  35. "M-1:用户不存在必须走 (ud,nil) 语义;否则中间件会把 DB 抖动同化成 401 强制下线引发雪崩")
  36. require.NotNil(t, ud)
  37. assert.Equal(t, nonExistId, ud.UserId)
  38. assert.Equal(t, productCode, ud.ProductCode)
  39. assert.Empty(t, ud.Username, "Username 必须为空以便调用方判定为 404 用户")
  40. }
  41. // TC-0914: L-6 —— 并发时序:CreateUser 成功但 Load 已经走到"写负缓存哨兵"分支之前,
  42. // 再次 FindOne 复核必须把"刚创建的用户"识别出来,跳过哨兵写入,避免新用户被投毒。
  43. //
  44. // 本测试构造的时序:先 Insert 一个真实用户(这步 Insert 会 DEL 用户主键缓存),
  45. // 再立即 Load 该 userId+productCode。L-6 的 freshCheck 必须让"这个第一 Load"拿到用户数据,
  46. // 而不是把 ud:<id>:<pc> 写为 _NOT_FOUND_。
  47. func TestUserDetailsLoader_Load_L6_CreateUserThenLoadDoesNotWriteSentinel(t *testing.T) {
  48. ctx := context.Background()
  49. loader := newTestLoader()
  50. conn := testConn()
  51. m := testModels()
  52. ts := now()
  53. uid := uniqueId()
  54. productCode := "pc_l6_" + uid
  55. userId := insertUser(ctx, t, m, &userModel.SysUser{
  56. Username: uid, Password: hashPwd("pw"), Nickname: "l6",
  57. Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
  58. Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
  59. })
  60. // M-N1 修复后,Load 要求 productCode 对应的产品真实存在才能进入正缓存分支;否则
  61. // loadProduct 失败会被提升为 ErrLoaderDegraded。L-6 的主题是"新用户写入后首次 Load
  62. // 不得被自身写的负缓存哨兵投毒",与"产品不存在"正交,因此这里补一条真实产品。
  63. pid := insertProduct(ctx, t, m, &productModel.SysProduct{
  64. Code: productCode, Name: "l6_prod", AppKey: "ak", AppSecret: "as",
  65. Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
  66. })
  67. t.Cleanup(func() {
  68. loader.Del(ctx, userId, productCode)
  69. cleanTable(ctx, conn, "`sys_user`", userId)
  70. cleanTable(ctx, conn, "`sys_product`", pid)
  71. })
  72. loader.Del(ctx, userId, productCode)
  73. ud, err := loader.Load(ctx, userId, productCode)
  74. require.NoError(t, err)
  75. require.NotNil(t, ud)
  76. assert.Equal(t, uid, ud.Username, "L-6:Load 必须识别出这是真实用户而不是写哨兵")
  77. // 关键断言:Redis key 里的值绝不能是哨兵。
  78. val, err := loader.rds.GetCtx(ctx, loader.cacheKey(userId, productCode))
  79. require.NoError(t, err)
  80. assert.NotEqual(t, negativeCacheMarker, val,
  81. "L-6:新创建的用户首次 Load 不得被写入负缓存哨兵,否则 10s 内所有请求都会被判为'已删除'")
  82. }
  83. // TC-0915 (重写 · M-N1): partial load 失败必须返回 ErrLoaderDegraded(而非 (ud,nil) 半成品),
  84. // 让调用方统一把它映射为 503 / codes.Unavailable;同时 5 分钟正缓存绝不能被写入。
  85. //
  86. // 历史契约:loadOk=false 时 Load 返回 (ud, nil),ud 是 Username 非空但 DeptPath=""/Perms=nil 的
  87. // 半成品,然后 jwtauth / refreshToken / GetUserPerms 等调用方因 MemberType=="" 或
  88. // ProductStatus!=Enabled 错把它当成"产品已被禁用 / 无权限" 返 403,一次 DB 抖动全站静默 403。
  89. // 新契约(审计 M-N1):loadOk=false → (nil, ErrLoaderDegraded);调用方 err!=nil 分支自然映射
  90. // 503 / codes.Unavailable,SOC 侧能明确观测到基础设施故障。
  91. func TestUserDetailsLoader_Load_MN1_PartialLoadReturnsErrDegradedAndSkipsCache(t *testing.T) {
  92. ctx := context.Background()
  93. loader := newTestLoader()
  94. conn := testConn()
  95. m := testModels()
  96. ts := now()
  97. uid := uniqueId()
  98. productCode := "pc_mn1_" + uid
  99. // 用一个极大的 DeptId 指向不存在的部门,让 loadDept 报 ErrNotFound → loadFromDB loadOk=false。
  100. phantomDeptId := int64(999_000_000_000)
  101. userId := insertUser(ctx, t, m, &userModel.SysUser{
  102. Username: uid, Password: hashPwd("pw"), Nickname: "mn1",
  103. Avatar: sql.NullString{}, DeptId: phantomDeptId,
  104. IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
  105. Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
  106. })
  107. // 给产品落一条真实数据,让 loadProduct 本身成功,单独锁定"dept 子步骤失败"这个变量。
  108. pid := insertProduct(ctx, t, m, &productModel.SysProduct{
  109. Code: productCode, Name: "mn1_prod", AppKey: "ak", AppSecret: "as",
  110. Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
  111. })
  112. t.Cleanup(func() {
  113. loader.Del(ctx, userId, productCode)
  114. cleanTable(ctx, conn, "`sys_user`", userId)
  115. cleanTable(ctx, conn, "`sys_product`", pid)
  116. })
  117. loader.Del(ctx, userId, productCode)
  118. ud, err := loader.Load(ctx, userId, productCode)
  119. // 新契约:partial load 必须向上冒 ErrLoaderDegraded;ud 必须为 nil,避免调用方误用半成品。
  120. require.ErrorIs(t, err, ErrLoaderDegraded,
  121. "M-N1:partial load 必须返回 ErrLoaderDegraded,而不是把半成品 ud 静默当成业务拒绝")
  122. assert.Nil(t, ud, "M-N1:err 非 nil 时 ud 必须为 nil,杜绝上层误用半成品字段")
  123. // 断言 1:Redis 里没有 5 分钟正缓存,主 key 要么完全未写,要么仅留空串。
  124. val, err := loader.rds.GetCtx(ctx, loader.cacheKey(userId, productCode))
  125. require.NoError(t, err)
  126. if val != "" {
  127. assert.NotContains(t, val, "\"username\":\""+uid+"\"",
  128. "M-N1/M-1/H-1/L-3:partial-load 不得把半残 UD 写进 5 分钟正缓存")
  129. }
  130. }
  131. // TC-0917 (新增 · M-N1): ErrLoaderDegraded 必须是可用 errors.Is 断言的独立 sentinel,
  132. // 供调用方在 HTTP 中间件 / gRPC 拦截器里做到"统一映射 503"而不需要字符串匹配。
  133. func TestUserDetailsLoader_ErrLoaderDegraded_IsStableSentinel(t *testing.T) {
  134. require.NotNil(t, ErrLoaderDegraded, "必须导出 sentinel 便于调用方识别")
  135. // 再次发生的派生错误仍应 errors.Is 成立(防御"被包一层后调用方失配")。
  136. wrapped := errors.New("extra: " + ErrLoaderDegraded.Error())
  137. assert.False(t, errors.Is(wrapped, ErrLoaderDegraded),
  138. "新 error 与 sentinel 不应共享身份;如需传染请显式 fmt.Errorf(\"%%w\", ErrLoaderDegraded)")
  139. assert.True(t, errors.Is(ErrLoaderDegraded, ErrLoaderDegraded),
  140. "自身 Is 必须为 true(sanity check)")
  141. }
  142. // TC-0916: M-1 —— deny 查询失败时 fail-close 保底(H-1)。通过写一个完全无 perm 的普通 MEMBER,
  143. // 再通过 productCode 设为 disabled 让 loadPerms 走 ProductStatus != Enabled 提前返回;再切回
  144. // Enabled 状态,确保 perm 分支被正常 reach 到,覆盖 "allowIds 查询路径正常结束" 的成功契约。
  145. // 这里的反面(fail-close)契约已经由上面 TC-0915 的 "dept 失败不写缓存" 验证;单独断言 deny 失败
  146. // 路径需要 mock 数据库错误,属于下一轮覆盖。
  147. func TestUserDetailsLoader_Load_H1_EnabledProductMemberPermsNonNil(t *testing.T) {
  148. ctx := context.Background()
  149. loader := newTestLoader()
  150. conn := testConn()
  151. m := testModels()
  152. ts := now()
  153. uid := uniqueId()
  154. productCode := "pc_h1_" + uid
  155. userId := insertUser(ctx, t, m, &userModel.SysUser{
  156. Username: uid, Password: hashPwd("pw"), Nickname: "h1",
  157. Avatar: sql.NullString{}, DeptId: 0,
  158. IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
  159. Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
  160. })
  161. pid := insertProduct(ctx, t, m, &productModel.SysProduct{
  162. Code: productCode, Name: "h1_prod", AppKey: "ak", AppSecret: "as",
  163. Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
  164. })
  165. memberId := insertMember(ctx, t, m, &memberModel.SysProductMember{
  166. ProductCode: productCode, UserId: userId, MemberType: consts.MemberTypeMember,
  167. Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
  168. })
  169. _ = memberId
  170. t.Cleanup(func() {
  171. loader.Del(ctx, userId, productCode)
  172. cleanTable(ctx, conn, "`sys_user`", userId)
  173. cleanTable(ctx, conn, "`sys_product`", pid)
  174. cleanTableByField(ctx, conn, "`sys_product_member`", "productCode", productCode)
  175. })
  176. loader.Del(ctx, userId, productCode)
  177. ud, err := loader.Load(ctx, userId, productCode)
  178. require.NoError(t, err)
  179. require.NotNil(t, ud)
  180. // 这里不强制 Perms 非 nil —— 用户没有任何角色 / allow,Perms 为空 slice 或 nil 都合理;
  181. // 重点是 Load 不返回 error、不被 deny 查询(null 结果)污染。
  182. assert.Equal(t, uid, ud.Username)
  183. assert.Equal(t, productCode, ud.ProductCode)
  184. // 再次 Load 必须命中正缓存:GET 出的 value 一定是合法 JSON 且能反序列化回同样的 UD。
  185. val, err := loader.rds.GetCtx(ctx, loader.cacheKey(userId, productCode))
  186. require.NoError(t, err)
  187. require.NotEmpty(t, val, "H-1 正常路径必须落正缓存")
  188. if strings.HasPrefix(val, "{") {
  189. var cached UserDetails
  190. require.NoError(t, json.Unmarshal([]byte(val), &cached))
  191. assert.Equal(t, uid, cached.Username)
  192. }
  193. }