updateUserWriteSkew_audit_test.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. package user
  2. import (
  3. "context"
  4. "errors"
  5. "sync"
  6. "sync/atomic"
  7. "testing"
  8. deptLogic "perms-system-server/internal/logic/dept"
  9. "perms-system-server/internal/response"
  10. "perms-system-server/internal/svc"
  11. "perms-system-server/internal/testutil"
  12. "perms-system-server/internal/testutil/ctxhelper"
  13. "perms-system-server/internal/types"
  14. "github.com/stretchr/testify/assert"
  15. "github.com/stretchr/testify/require"
  16. )
  17. // ---------------------------------------------------------------------------
  18. // 覆盖目标:审计 M-R11-3 —— UpdateUser 把 deptId 调入 targetDept 与 DeleteDept 并发执行时,
  19. // 绝不能同时成功(否则会出现 "sys_user.deptId 指向已被删除的部门行" 的 orphan 数据)。
  20. //
  21. // 修复前的 write skew:
  22. // T1 DeleteDept 事务:SELECT id FROM sys_dept WHERE id=X FOR UPDATE
  23. // → SELECT id FROM sys_user WHERE deptId=X FOR SHARE —— 空集
  24. // → DELETE sys_dept[X]
  25. // T2 UpdateUser :UPDATE sys_user SET deptId=X WHERE id=U —— 无锁同时进行
  26. // 两边各自提交后,U 的 deptId 指向了已被删除的 X。
  27. //
  28. // 修复后(M-R11-3):
  29. // UpdateUser 在事务内 `SELECT ... LOCK IN SHARE MODE` sys_dept[X],
  30. // DeleteDept 在事务内 `SELECT ... FOR UPDATE` sys_dept[X] —— S vs X 互斥,
  31. // 后到者必被前者阻塞。先到者提交后,DeleteDept 重读 sys_user WHERE deptId=X FOR SHARE
  32. // 能看到 UpdateUser 提交的新成员,转为 400;先到的是 DeleteDept,则 UpdateUser 的
  33. // S 锁获取会卡住等待 DeleteDept 的 X 锁,DeleteDept 提交后 sys_dept[X] 消失,
  34. // UpdateUser 的 FindOneForShareTx 返回 ErrNotFound → 400 "部门不存在"。
  35. //
  36. // 这里跑真实 MySQL 事务,对"不可能出现 orphan"做闭环断言。
  37. // ---------------------------------------------------------------------------
  38. // TC-1049: M-R11-3 —— UpdateUser 调入 X 与 DeleteDept(X) 并发:两者互斥,结果必须自洽
  39. // - 要么 UpdateUser 成功 + DeleteDept 收到 400「该部门下仍有关联用户」(dept 仍在、user.deptId 指向 dept);
  40. // - 要么 DeleteDept 成功 + UpdateUser 收到 400「部门不存在」(dept 已删、user.deptId 未被改到已删 dept)。
  41. // 严禁同时成功,也严禁同时失败。DB 终态必须自洽。
  42. func TestUpdateUser_DeptIdSwitch_VsDeleteDept_NoWriteSkew(t *testing.T) {
  43. bootstrap := ctxhelper.SuperAdminCtx()
  44. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  45. conn := testutil.GetTestSqlConn()
  46. // 构造:用户 U 在 deptA,新候选部门 deptX 空(无子部门 + 无关联用户,满足 DeleteDept 可删条件)。
  47. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptA", "/3100/")
  48. deptXId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptX", "/3200/")
  49. targetId := insertTestUserWithDept(t, bootstrap, "m113_user", deptAId)
  50. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  51. t.Cleanup(func() {
  52. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  53. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  54. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId, deptXId)
  55. })
  56. // 超管身份用于 UpdateUser / DeleteDept。
  57. superCtx := ctxhelper.SuperAdminCtx()
  58. // 两个 goroutine 并发:
  59. // G1: UpdateUser targetId.deptId = deptXId
  60. // G2: DeleteDept deptXId
  61. var (
  62. wg sync.WaitGroup
  63. upErr atomic.Value
  64. upOK atomic.Bool
  65. delErr atomic.Value
  66. delOK atomic.Bool
  67. unexpected atomic.Value
  68. )
  69. start := make(chan struct{})
  70. wg.Add(2)
  71. go func() {
  72. defer wg.Done()
  73. <-start
  74. err := NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  75. Id: targetId,
  76. DeptId: &deptXId,
  77. })
  78. if err == nil {
  79. upOK.Store(true)
  80. } else {
  81. upErr.Store(err)
  82. }
  83. }()
  84. go func() {
  85. defer wg.Done()
  86. <-start
  87. err := deptLogic.NewDeleteDeptLogic(superCtx, svcCtx).DeleteDept(&types.DeleteDeptReq{
  88. Id: deptXId,
  89. })
  90. if err == nil {
  91. delOK.Store(true)
  92. } else {
  93. delErr.Store(err)
  94. }
  95. }()
  96. close(start)
  97. wg.Wait()
  98. // 允许的结果共两种:
  99. // A) upOK && !delOK:user.deptId==deptX,sys_dept[deptX] 仍在,DeleteDept 收 400
  100. // B) !upOK && delOK:user.deptId==deptA(未动),sys_dept[deptX] 已删,UpdateUser 收 400
  101. // 绝不能同时成功(write skew)。DB 终态须自洽。
  102. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  103. require.NoError(t, err)
  104. // dept X 存在性:**绕过 go-zero 的 WithCache**,直接从 MySQL 查真相,避免 UpdateUser
  105. // 在 FindOne 时预热的缓存把 DeleteDept 的真实删除"遮住"。
  106. var deptCount int64
  107. require.NoError(t,
  108. conn.QueryRowCtx(context.Background(), &deptCount,
  109. "SELECT COUNT(*) FROM `sys_dept` WHERE `id` = ?", deptXId))
  110. deptStillThere := deptCount > 0
  111. switch {
  112. case upOK.Load() && !delOK.Load():
  113. assert.Equal(t, deptXId, u.DeptId,
  114. "M-R11-3:UpdateUser 胜出,user.deptId 必须为 deptX")
  115. assert.True(t, deptStillThere,
  116. "M-R11-3:UpdateUser 胜出后 deptX 必须仍存在,否则存在 orphan 引用")
  117. var ce *response.CodeError
  118. require.NotNil(t, delErr.Load(), "DeleteDept 应返回 400 解释失败原因")
  119. if errors.As(delErr.Load().(error), &ce) {
  120. assert.Equal(t, 400, ce.Code(),
  121. "M-R11-3:DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'")
  122. assert.Contains(t, ce.Error(), "关联用户")
  123. }
  124. case !upOK.Load() && delOK.Load():
  125. assert.Equal(t, deptAId, u.DeptId,
  126. "M-R11-3:DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)")
  127. assert.False(t, deptStillThere,
  128. "M-R11-3:DeleteDept 胜出后 deptX 必须已被删除")
  129. var ce *response.CodeError
  130. require.NotNil(t, upErr.Load(), "UpdateUser 应返回 400 解释失败原因")
  131. if errors.As(upErr.Load().(error), &ce) {
  132. assert.Equal(t, 400, ce.Code(),
  133. "M-R11-3:UpdateUser 发现目标 dept 已消失必须 400'部门不存在'")
  134. assert.Contains(t, ce.Error(), "部门不存在")
  135. }
  136. case upOK.Load() && delOK.Load():
  137. t.Fatalf("M-R11-3 回归:UpdateUser + DeleteDept **同时成功** —— write skew 未被闭合。" +
  138. "DB 现在持有 user.deptId 指向已被删 dept 的 orphan 数据。")
  139. case !upOK.Load() && !delOK.Load():
  140. unexpected.Store(struct{ up, del error }{upErr.Load().(error), delErr.Load().(error)})
  141. t.Fatalf("M-R11-3:两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load())
  142. }
  143. }
  144. // TC-1050: M-R11-3 —— UpdateUser 只改 deptId 之外的字段(或 deptId=0)时不进事务(性能与锁范围约束)
  145. // 这是修复的**对偶契约**:避免 DEV 未来不分 case 把所有 UpdateProfile 都塞进事务 / 或反之。
  146. // 用"目标部门不存在但仅改 Nickname"的 case 证明:非 deptId 变更路径不需要 FindOneForShareTx,
  147. // 且走的是 UpdateProfile(非事务)。该契约只能以"只调昵称也能成功"的正向场景间接证实:
  148. func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T) {
  149. bootstrap := ctxhelper.SuperAdminCtx()
  150. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  151. conn := testutil.GetTestSqlConn()
  152. deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_onlyNick", "/3300/")
  153. targetId := insertTestUserWithDept(t, bootstrap, "m113_onlyNick", deptAId)
  154. mId := insertTestMember(t, svcCtx, "test_product", targetId)
  155. t.Cleanup(func() {
  156. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  157. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  158. testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId)
  159. })
  160. newNick := "only_nick_mutate"
  161. superCtx := ctxhelper.SuperAdminCtx()
  162. require.NoError(t,
  163. NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
  164. Id: targetId, Nickname: &newNick,
  165. }),
  166. "M-R11-3 对偶:只改昵称不应走事务路径(若走事务会无谓扩大锁范围)")
  167. u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
  168. require.NoError(t, err)
  169. assert.Equal(t, newNick, u.Nickname)
  170. assert.Equal(t, deptAId, u.DeptId, "deptId 未变")
  171. }
  172. // 备注:原本想写一条"deptId 从 A 改到 A 不走事务路径"的对偶用例,但 MySQL 对"所有字段都
  173. // 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。
  174. // 这是底层驱动/引擎层的 side-effect,非 M-R11-3 关心的契约。若要验证该对偶,请同时改一个
  175. // 真实字段(参见上面的 Nickname 用例)。