setUserPermsCountRecheck_audit_test.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. package user
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "time"
  7. permModel "perms-system-server/internal/model/perm"
  8. "perms-system-server/internal/response"
  9. "perms-system-server/internal/svc"
  10. "perms-system-server/internal/testutil"
  11. "perms-system-server/internal/testutil/ctxhelper"
  12. "perms-system-server/internal/types"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/require"
  15. )
  16. // ---------------------------------------------------------------------------
  17. // 审计 L-4(第 8 轮)—— SetUserPerms 事务末 COUNT(*) 复核 sys_perm.status=1,
  18. // 把"FindByIds 通过 → 事务外某次 SyncPermissions 先把 permId 置为 DISABLED →
  19. // BatchInsertWithTx 把脏行写进 sys_user_perm"的 TOCTOU 窗口收紧。
  20. //
  21. // 测试思路:把 SysPermModel 用一个薄装饰器替换,让 FindByIds 说谎(返回 Enabled),
  22. // 但实际上 DB 里这批 permId 其实是 Disabled。这样:
  23. // - 前置 FindByIds 校验通过;
  24. // - 进入 TransactCtx,BatchInsertWithTx 成功;
  25. // - 事务末 COUNT(*) WHERE status=1 的真实 DB 读返回 0 ≠ 1 → 回滚,返回 409;
  26. // - sys_user_perm 必须一行脏数据都不剩。
  27. //
  28. // 如果 L-4 的复核被移除(或误改成 status != 0),COUNT 返回会≠0,脏行会被落盘,
  29. // 此测试自动失败。
  30. // ---------------------------------------------------------------------------
  31. // lyingSysPermModel 只重写 FindByIds:不管 DB 里 status 是什么,都声称是 Enabled。
  32. // 这是唯一一个能稳定模拟"前置 FindByIds → tx 内真实 status" 时序差的办法。
  33. type lyingSysPermModel struct {
  34. permModel.SysPermModel
  35. lyingProductCode string
  36. }
  37. func (m *lyingSysPermModel) FindByIds(ctx context.Context, ids []int64) ([]*permModel.SysPerm, error) {
  38. real, err := m.SysPermModel.FindByIds(ctx, ids)
  39. if err != nil {
  40. return nil, err
  41. }
  42. for _, p := range real {
  43. p.ProductCode = m.lyingProductCode
  44. p.Status = 1
  45. }
  46. return real, nil
  47. }
  48. // TC-0988: TOCTOU 复核 —— 前置检查通过但实际 Disabled,事务末 COUNT 必须触发 409 回滚。
  49. func TestSetUserPerms_L4_TOCTOU_CountMismatch_RollsBackWith409(t *testing.T) {
  50. ctx := ctxhelper.SuperAdminCtx()
  51. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  52. conn := testutil.GetTestSqlConn()
  53. username := testutil.UniqueId()
  54. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  55. mId := insertTestMember(t, svcCtx, "test_product", userId)
  56. // 直接在 DB 里塞一个 status=Disabled 的 perm,模拟 SyncPermissions 已经提交
  57. // 把这个 perm 落盘为 Disabled 的状态。
  58. now := time.Now().Unix()
  59. res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
  60. ProductCode: "test_product",
  61. Name: "l4_disabled_" + testutil.UniqueId(),
  62. Code: "l4_dis_" + testutil.UniqueId(),
  63. Status: 2, // Disabled
  64. CreateTime: now, UpdateTime: now,
  65. })
  66. require.NoError(t, err)
  67. disabledPermId, _ := res.LastInsertId()
  68. t.Cleanup(func() {
  69. testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
  70. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  71. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  72. testutil.CleanTable(ctx, conn, "`sys_perm`", disabledPermId)
  73. })
  74. // 装饰 SysPermModel:让 FindByIds 撒谎(Status=1, productCode=test_product)。
  75. svcCtx.SysPermModel = &lyingSysPermModel{
  76. SysPermModel: svcCtx.SysPermModel,
  77. lyingProductCode: "test_product",
  78. }
  79. err = NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
  80. UserId: userId,
  81. Perms: []types.UserPermItem{{PermId: disabledPermId, Effect: "ALLOW"}},
  82. })
  83. require.Error(t, err, "L-4:前置通过但 DB 实际 Disabled 时,事务末 COUNT 必须触发 409")
  84. var ce *response.CodeError
  85. require.True(t, errors.As(err, &ce))
  86. assert.Equal(t, 409, ce.Code(),
  87. "L-4:TOCTOU 复核必须返回 409 Conflict;若仍是 200/4xx 说明复核 COUNT 被移除,"+
  88. "脏 user_perm 会被真实落盘")
  89. assert.Contains(t, ce.Error(), "已被禁用",
  90. "L-4:错误文案必须明示'部分权限在提交时已被禁用',供前端判定是否重试")
  91. // 最关键的断言:脏行必须不可能落盘。
  92. leftover := findUserPerms(t, ctx, userId)
  93. assert.Empty(t, leftover,
  94. "L-4:事务必须回滚;如果发现 sys_user_perm 有脏行,说明 COUNT 复核失效或"+
  95. "事务隔离性被破坏,loadPerms 的 status=1 过滤能兜底但会绕开审计链")
  96. }
  97. // TC-0989: 正向基线 —— 所有 perm 真实 Enabled 时,不得被 L-4 复核误杀。
  98. // 这条显式"不回滚"的断言防止未来有人把 COUNT 改成 "!=" 逻辑或把阈值改错。
  99. func TestSetUserPerms_L4_AllEnabled_CountPasses(t *testing.T) {
  100. ctx := ctxhelper.SuperAdminCtx()
  101. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  102. conn := testutil.GetTestSqlConn()
  103. username := testutil.UniqueId()
  104. userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
  105. mId := insertTestMember(t, svcCtx, "test_product", userId)
  106. p1 := insertTestPerm(t, svcCtx, "test_product")
  107. p2 := insertTestPerm(t, svcCtx, "test_product")
  108. t.Cleanup(func() {
  109. testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
  110. testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
  111. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  112. testutil.CleanTable(ctx, conn, "`sys_perm`", p1, p2)
  113. })
  114. err := NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
  115. UserId: userId,
  116. Perms: []types.UserPermItem{
  117. {PermId: p1, Effect: "ALLOW"},
  118. {PermId: p2, Effect: "DENY"},
  119. },
  120. })
  121. require.NoError(t, err, "L-4 复核不得误杀正常写入;一旦误报会把正常管理操作变 409")
  122. rows := findUserPerms(t, ctx, userId)
  123. assert.Len(t, rows, 2, "两条 user_perm 必须落盘")
  124. }