updatePasswordStatus_rowsaffected_audit_test.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  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 内部:FindOne 命中 stale cache 返回用户 → UPDATE WHERE id=? AND updateTime=?
  40. // 因为行已不存在,affected=0。旧实现 `return nil` 被视为"改密成功";新实现必须回 ErrUpdateConflict。
  41. err = m.UpdatePassword(ctx, id, "new_hashed_pw", 1)
  42. require.ErrorIs(t, err, user.ErrUpdateConflict,
  43. "M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
  44. }
  45. // TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
  46. func TestSysUserModel_UpdateStatus_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) {
  47. ctx := context.Background()
  48. m, conn := newModel(t)
  49. username := "m2_st_del_" + testutil.UniqueId()
  50. data := newTestSysUser(username, 1)
  51. res, err := m.Insert(ctx, data)
  52. require.NoError(t, err)
  53. id, err := res.LastInsertId()
  54. require.NoError(t, err)
  55. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  56. _, err = m.FindOne(ctx, id)
  57. require.NoError(t, err)
  58. _, err = m.FindOneByUsername(ctx, username)
  59. require.NoError(t, err)
  60. _, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
  61. require.NoError(t, err)
  62. // UpdateStatus 内部:FindOne 命中 stale cache → UPDATE WHERE id=? AND updateTime=? 仍 affected=0。
  63. // 旧实现返回 nil;新实现必须回 ErrUpdateConflict,让上层区分"冻结生效 / 用户已不存在"。
  64. // L-N4 新签名:需要把 FindOne 拿到的 UpdateTime 作为 expectedUpdateTime 传入
  65. staleUd, _ := m.FindOne(ctx, id)
  66. var expectedUpdateTime int64
  67. if staleUd != nil {
  68. expectedUpdateTime = staleUd.UpdateTime
  69. }
  70. err = m.UpdateStatus(ctx, id, 2, expectedUpdateTime)
  71. require.ErrorIs(t, err, user.ErrUpdateConflict,
  72. "M-2/L-N4:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁")
  73. }
  74. // TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流)
  75. func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
  76. ctx := context.Background()
  77. m, conn := newModel(t)
  78. username := "m2_pw_ok_" + testutil.UniqueId()
  79. data := newTestSysUser(username, 1)
  80. res, err := m.Insert(ctx, data)
  81. require.NoError(t, err)
  82. id, err := res.LastInsertId()
  83. require.NoError(t, err)
  84. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  85. orig, err := m.FindOne(ctx, id)
  86. require.NoError(t, err)
  87. origTv := orig.TokenVersion
  88. // 乐观锁依赖秒级 updateTime,必须让 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime,
  89. // 否则"空白更新"仍 affected=1 但 updateTime 值不变,容易掩盖后续断言
  90. time.Sleep(1100 * time.Millisecond)
  91. newPw := "new_hashed_password_xyz"
  92. err = m.UpdatePassword(ctx, id, newPw, 1)
  93. require.NoError(t, err)
  94. got, err := m.FindOne(ctx, id)
  95. require.NoError(t, err)
  96. assert.Equal(t, newPw, got.Password)
  97. assert.Equal(t, int64(1), got.MustChangePassword)
  98. assert.Equal(t, origTv+1, got.TokenVersion, "改密必须递增 tokenVersion 以注销旧会话")
  99. assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则乐观锁无法生效")
  100. }
  101. // TC-0927: UpdateStatus 正常路径仍然成功且 tokenVersion 递增
  102. func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
  103. ctx := context.Background()
  104. m, conn := newModel(t)
  105. username := "m2_st_ok_" + testutil.UniqueId()
  106. data := newTestSysUser(username, 1)
  107. res, err := m.Insert(ctx, data)
  108. require.NoError(t, err)
  109. id, err := res.LastInsertId()
  110. require.NoError(t, err)
  111. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  112. orig, err := m.FindOne(ctx, id)
  113. require.NoError(t, err)
  114. origTv := orig.TokenVersion
  115. require.Equal(t, int64(1), orig.Status)
  116. // L-N4:乐观锁依赖秒级 updateTime,确保 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime
  117. time.Sleep(1100 * time.Millisecond)
  118. err = m.UpdateStatus(ctx, id, 2, orig.UpdateTime)
  119. require.NoError(t, err)
  120. got, err := m.FindOne(ctx, id)
  121. require.NoError(t, err)
  122. assert.Equal(t, int64(2), got.Status)
  123. assert.Equal(t, origTv+1, got.TokenVersion, "冻结 / 解冻必须递增 tokenVersion 使旧 token 全部失效")
  124. assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则后续乐观锁失效")
  125. }
  126. // TC-0928: UpdatePassword 对不存在的 userId 必须回 ErrNotFound(FindOne 先失败),
  127. // 确保 M-2 的 "affected=0 → ErrUpdateConflict" 不会把 "FindOne miss" 误报成 Conflict
  128. func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsNotFound(t *testing.T) {
  129. ctx := context.Background()
  130. m, _ := newModel(t)
  131. err := m.UpdatePassword(ctx, 999999999999, "irrelevant", 1)
  132. require.ErrorIs(t, err, user.ErrNotFound)
  133. }
  134. // TC-0929: UpdateStatus 对不存在的 userId 必须回 ErrNotFound
  135. func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsNotFound(t *testing.T) {
  136. ctx := context.Background()
  137. m, _ := newModel(t)
  138. err := m.UpdateStatus(ctx, 999999999999, 2, 0)
  139. require.ErrorIs(t, err, user.ErrNotFound)
  140. }