userDetailsLoader_contract_audit_test.go 8.5 KB

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