updatePasswordStatus_rowsaffected_audit_test.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  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=? 仍 affected=0。
  63. // 旧实现返回 nil;新实现必须回 ErrUpdateConflict,让上层区分"冻结生效 / 用户已不存在"。
  64. err = m.UpdateStatus(ctx, id, 2)
  65. require.ErrorIs(t, err, user.ErrUpdateConflict,
  66. "M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁")
  67. }
  68. // TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流)
  69. func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
  70. ctx := context.Background()
  71. m, conn := newModel(t)
  72. username := "m2_pw_ok_" + testutil.UniqueId()
  73. data := newTestSysUser(username, 1)
  74. res, err := m.Insert(ctx, data)
  75. require.NoError(t, err)
  76. id, err := res.LastInsertId()
  77. require.NoError(t, err)
  78. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  79. orig, err := m.FindOne(ctx, id)
  80. require.NoError(t, err)
  81. origTv := orig.TokenVersion
  82. // 乐观锁依赖秒级 updateTime,必须让 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime,
  83. // 否则"空白更新"仍 affected=1 但 updateTime 值不变,容易掩盖后续断言
  84. time.Sleep(1100 * time.Millisecond)
  85. newPw := "new_hashed_password_xyz"
  86. err = m.UpdatePassword(ctx, id, newPw, 1)
  87. require.NoError(t, err)
  88. got, err := m.FindOne(ctx, id)
  89. require.NoError(t, err)
  90. assert.Equal(t, newPw, got.Password)
  91. assert.Equal(t, int64(1), got.MustChangePassword)
  92. assert.Equal(t, origTv+1, got.TokenVersion, "改密必须递增 tokenVersion 以注销旧会话")
  93. assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则乐观锁无法生效")
  94. }
  95. // TC-0927: UpdateStatus 正常路径仍然成功且 tokenVersion 递增
  96. func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
  97. ctx := context.Background()
  98. m, conn := newModel(t)
  99. username := "m2_st_ok_" + testutil.UniqueId()
  100. data := newTestSysUser(username, 1)
  101. res, err := m.Insert(ctx, data)
  102. require.NoError(t, err)
  103. id, err := res.LastInsertId()
  104. require.NoError(t, err)
  105. t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
  106. orig, err := m.FindOne(ctx, id)
  107. require.NoError(t, err)
  108. origTv := orig.TokenVersion
  109. require.Equal(t, int64(1), orig.Status)
  110. err = m.UpdateStatus(ctx, id, 2)
  111. require.NoError(t, err)
  112. got, err := m.FindOne(ctx, id)
  113. require.NoError(t, err)
  114. assert.Equal(t, int64(2), got.Status)
  115. assert.Equal(t, origTv+1, got.TokenVersion, "冻结 / 解冻必须递增 tokenVersion 使旧 token 全部失效")
  116. }
  117. // TC-0928: UpdatePassword 对不存在的 userId 必须回 ErrNotFound(FindOne 先失败),
  118. // 确保 M-2 的 "affected=0 → ErrUpdateConflict" 不会把 "FindOne miss" 误报成 Conflict
  119. func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsNotFound(t *testing.T) {
  120. ctx := context.Background()
  121. m, _ := newModel(t)
  122. err := m.UpdatePassword(ctx, 999999999999, "irrelevant", 1)
  123. require.ErrorIs(t, err, user.ErrNotFound)
  124. }
  125. // TC-0929: UpdateStatus 对不存在的 userId 必须回 ErrNotFound
  126. func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsNotFound(t *testing.T) {
  127. ctx := context.Background()
  128. m, _ := newModel(t)
  129. err := m.UpdateStatus(ctx, 999999999999, 2)
  130. require.ErrorIs(t, err, user.ErrNotFound)
  131. }