updatePasswordStatus_rowsaffected_audit_test.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. package user_test
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "perms-system-server/internal/model/user"
  7. "perms-system-server/internal/testutil"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/require"
  10. )
  11. // M-2 回归:UpdatePassword / UpdateStatus 必须校验 RowsAffected。
  12. //
  13. // 旧实现的 bug:Model 层 UPDATE 语句使用 `conn.ExecCtx`,若行已被删除或并发被修改,
  14. // `affected=0`,但代码直接 `return nil`,让上层误判"改密成功 / 冻结成功"。
  15. // 利用 FindOne 的二级缓存(Redis):删除数据库中的行但不清缓存 →
  16. // 后续 UpdatePassword/UpdateStatus 内部的 FindOne 命中 stale cache 仍返回用户,
  17. // 真实 UPDATE 落空 `affected=0`,新实现必须回 ErrUpdateConflict。
  18. //
  19. // 如果测试跑挂说明 M-2 修复被回退,必须立即修复 Model 层而非改测试。
  20. // TC-0924: UpdatePassword 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
  21. func TestSysUserModel_UpdatePassword_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) {
  22. ctx := context.Background()
  23. m, conn := newModel(t)
  24. username := "m2_pw_del_" + testutil.UniqueId()
  25. data := newTestSysUser(username, 1)
  26. res, err := m.Insert(ctx, data)
  27. require.NoError(t, err)
  28. id, err := res.LastInsertId()
  29. require.NoError(t, err)
  30. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  31. // 触发 FindOne 填充二级缓存 (id-key + username-key),模拟 Loader 刚读过用户的场景
  32. _, err = m.FindOne(ctx, id)
  33. require.NoError(t, err)
  34. _, err = m.FindOneByUsername(ctx, username)
  35. require.NoError(t, err)
  36. // 直接走原始 SQL 删除行,**绕过** Model 的缓存失效钩子——此时 Redis 里仍保留用户快照
  37. _, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
  38. require.NoError(t, err)
  39. // UpdatePassword 内部 WHERE id=? AND updateTime=?(外层透传 expectedUpdateTime, 审计 H-R11-1)。
  40. // 行已被删除,affected=0。旧实现 `return nil` 被视为"改密成功";新实现必须回 ErrUpdateConflict。
  41. // expectedUpdateTime 用 stale cache 的 UpdateTime,即"观测到的快照" —— DB 已无对应行,CAS 必失败。
  42. stale, _ := m.FindOne(ctx, id)
  43. var expectedUpdateTime int64
  44. if stale != nil {
  45. expectedUpdateTime = stale.UpdateTime
  46. }
  47. err = m.UpdatePassword(ctx, id, username, "new_hashed_pw", 1, expectedUpdateTime)
  48. require.ErrorIs(t, err, user.ErrUpdateConflict,
  49. "M-2/H-R11-1:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
  50. }
  51. // TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
  52. func TestSysUserModel_UpdateStatus_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) {
  53. ctx := context.Background()
  54. m, conn := newModel(t)
  55. username := "m2_st_del_" + testutil.UniqueId()
  56. data := newTestSysUser(username, 1)
  57. res, err := m.Insert(ctx, data)
  58. require.NoError(t, err)
  59. id, err := res.LastInsertId()
  60. require.NoError(t, err)
  61. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  62. _, err = m.FindOne(ctx, id)
  63. require.NoError(t, err)
  64. _, err = m.FindOneByUsername(ctx, username)
  65. require.NoError(t, err)
  66. _, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
  67. require.NoError(t, err)
  68. // UpdateStatus 内部:FindOne 命中 stale cache → UPDATE WHERE id=? AND updateTime=? 仍 affected=0。
  69. // 旧实现返回 nil;新实现必须回 ErrUpdateConflict,让上层区分"冻结生效 / 用户已不存在"。
  70. // L-N4 新签名:需要把 FindOne 拿到的 UpdateTime 作为 expectedUpdateTime 传入
  71. staleUd, _ := m.FindOne(ctx, id)
  72. var expectedUpdateTime int64
  73. if staleUd != nil {
  74. expectedUpdateTime = staleUd.UpdateTime
  75. }
  76. err = m.UpdateStatus(ctx, id, username, 2, expectedUpdateTime)
  77. require.ErrorIs(t, err, user.ErrUpdateConflict,
  78. "M-2/L-N4/M-R11-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁")
  79. }
  80. // TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流)
  81. func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
  82. ctx := context.Background()
  83. m, conn := newModel(t)
  84. username := "m2_pw_ok_" + testutil.UniqueId()
  85. data := newTestSysUser(username, 1)
  86. res, err := m.Insert(ctx, data)
  87. require.NoError(t, err)
  88. id, err := res.LastInsertId()
  89. require.NoError(t, err)
  90. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  91. orig, err := m.FindOne(ctx, id)
  92. require.NoError(t, err)
  93. origTv := orig.TokenVersion
  94. // 乐观锁依赖秒级 updateTime,必须让 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime,
  95. // 否则"空白更新"仍 affected=1 但 updateTime 值不变,容易掩盖后续断言
  96. time.Sleep(1100 * time.Millisecond)
  97. newPw := "new_hashed_password_xyz"
  98. err = m.UpdatePassword(ctx, id, username, newPw, 1, orig.UpdateTime)
  99. require.NoError(t, err)
  100. got, err := m.FindOne(ctx, id)
  101. require.NoError(t, err)
  102. assert.Equal(t, newPw, got.Password)
  103. assert.Equal(t, int64(1), got.MustChangePassword)
  104. assert.Equal(t, origTv+1, got.TokenVersion, "改密必须递增 tokenVersion 以注销旧会话")
  105. assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则乐观锁无法生效")
  106. }
  107. // TC-0927: UpdateStatus 正常路径仍然成功且 tokenVersion 递增
  108. func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
  109. ctx := context.Background()
  110. m, conn := newModel(t)
  111. username := "m2_st_ok_" + testutil.UniqueId()
  112. data := newTestSysUser(username, 1)
  113. res, err := m.Insert(ctx, data)
  114. require.NoError(t, err)
  115. id, err := res.LastInsertId()
  116. require.NoError(t, err)
  117. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  118. orig, err := m.FindOne(ctx, id)
  119. require.NoError(t, err)
  120. origTv := orig.TokenVersion
  121. require.Equal(t, int64(1), orig.Status)
  122. // L-N4:乐观锁依赖秒级 updateTime,确保 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime
  123. time.Sleep(1100 * time.Millisecond)
  124. err = m.UpdateStatus(ctx, id, username, 2, orig.UpdateTime)
  125. require.NoError(t, err)
  126. got, err := m.FindOne(ctx, id)
  127. require.NoError(t, err)
  128. assert.Equal(t, int64(2), got.Status)
  129. assert.Equal(t, origTv+1, got.TokenVersion, "冻结 / 解冻必须递增 tokenVersion 使旧 token 全部失效")
  130. assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则后续乐观锁失效")
  131. }
  132. // TC-0928(R11 重写):UpdatePassword 对不存在的 userId 必须回 ErrUpdateConflict
  133. // (H-R11-1 后,Model 不再内部 FindOne;不存在的 id + 任意 expectedUpdateTime → affected=0 → ErrUpdateConflict)
  134. func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsConflict(t *testing.T) {
  135. ctx := context.Background()
  136. m, _ := newModel(t)
  137. err := m.UpdatePassword(ctx, 999999999999, "ghost_user", "irrelevant", 1, 1)
  138. require.ErrorIs(t, err, user.ErrUpdateConflict,
  139. "H-R11-1:UpdatePassword 不再内部 FindOne,对不存在的 id 回 ErrUpdateConflict")
  140. }
  141. // TC-0929(R11 重写):UpdateStatus 对不存在的 userId 必须回 ErrUpdateConflict
  142. func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsConflict(t *testing.T) {
  143. ctx := context.Background()
  144. m, _ := newModel(t)
  145. err := m.UpdateStatus(ctx, 999999999999, "ghost_user", 2, 1)
  146. require.ErrorIs(t, err, user.ErrUpdateConflict,
  147. "M-R11-2:UpdateStatus 不再内部 FindOne,对不存在的 id 回 ErrUpdateConflict")
  148. }