| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- package user_test
- import (
- "context"
- "testing"
- "time"
- "perms-system-server/internal/model/user"
- "perms-system-server/internal/testutil"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- // M-2 回归:UpdatePassword / UpdateStatus 必须校验 RowsAffected。
- //
- // 旧实现的 bug:Model 层 UPDATE 语句使用 `conn.ExecCtx`,若行已被删除或并发被修改,
- // `affected=0`,但代码直接 `return nil`,让上层误判"改密成功 / 冻结成功"。
- // 利用 FindOne 的二级缓存(Redis):删除数据库中的行但不清缓存 →
- // 后续 UpdatePassword/UpdateStatus 内部的 FindOne 命中 stale cache 仍返回用户,
- // 真实 UPDATE 落空 `affected=0`,新实现必须回 ErrUpdateConflict。
- //
- // 如果测试跑挂说明 M-2 修复被回退,必须立即修复 Model 层而非改测试。
- // TC-0924: UpdatePassword 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
- func TestSysUserModel_UpdatePassword_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) {
- ctx := context.Background()
- m, conn := newModel(t)
- username := "m2_pw_del_" + testutil.UniqueId()
- data := newTestSysUser(username, 1)
- res, err := m.Insert(ctx, data)
- require.NoError(t, err)
- id, err := res.LastInsertId()
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
- // 触发 FindOne 填充二级缓存 (id-key + username-key),模拟 Loader 刚读过用户的场景
- _, err = m.FindOne(ctx, id)
- require.NoError(t, err)
- _, err = m.FindOneByUsername(ctx, username)
- require.NoError(t, err)
- // 直接走原始 SQL 删除行,**绕过** Model 的缓存失效钩子——此时 Redis 里仍保留用户快照
- _, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
- require.NoError(t, err)
- // UpdatePassword 内部 WHERE id=? AND updateTime=?(外层透传 expectedUpdateTime, 审计 H-R11-1)。
- // 行已被删除,affected=0。旧实现 `return nil` 被视为"改密成功";新实现必须回 ErrUpdateConflict。
- // expectedUpdateTime 用 stale cache 的 UpdateTime,即"观测到的快照" —— DB 已无对应行,CAS 必失败。
- stale, _ := m.FindOne(ctx, id)
- var expectedUpdateTime int64
- if stale != nil {
- expectedUpdateTime = stale.UpdateTime
- }
- err = m.UpdatePassword(ctx, id, username, "new_hashed_pw", 1, expectedUpdateTime)
- require.ErrorIs(t, err, user.ErrUpdateConflict,
- "M-2/H-R11-1:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
- }
- // TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
- func TestSysUserModel_UpdateStatus_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) {
- ctx := context.Background()
- m, conn := newModel(t)
- username := "m2_st_del_" + testutil.UniqueId()
- data := newTestSysUser(username, 1)
- res, err := m.Insert(ctx, data)
- require.NoError(t, err)
- id, err := res.LastInsertId()
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
- _, err = m.FindOne(ctx, id)
- require.NoError(t, err)
- _, err = m.FindOneByUsername(ctx, username)
- require.NoError(t, err)
- _, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
- require.NoError(t, err)
- // UpdateStatus 内部:FindOne 命中 stale cache → UPDATE WHERE id=? AND updateTime=? 仍 affected=0。
- // 旧实现返回 nil;新实现必须回 ErrUpdateConflict,让上层区分"冻结生效 / 用户已不存在"。
- // L-N4 新签名:需要把 FindOne 拿到的 UpdateTime 作为 expectedUpdateTime 传入
- staleUd, _ := m.FindOne(ctx, id)
- var expectedUpdateTime int64
- if staleUd != nil {
- expectedUpdateTime = staleUd.UpdateTime
- }
- err = m.UpdateStatus(ctx, id, username, 2, expectedUpdateTime)
- require.ErrorIs(t, err, user.ErrUpdateConflict,
- "M-2/L-N4/M-R11-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁")
- }
- // TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流)
- func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
- ctx := context.Background()
- m, conn := newModel(t)
- username := "m2_pw_ok_" + testutil.UniqueId()
- data := newTestSysUser(username, 1)
- res, err := m.Insert(ctx, data)
- require.NoError(t, err)
- id, err := res.LastInsertId()
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
- orig, err := m.FindOne(ctx, id)
- require.NoError(t, err)
- origTv := orig.TokenVersion
- // 乐观锁依赖秒级 updateTime,必须让 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime,
- // 否则"空白更新"仍 affected=1 但 updateTime 值不变,容易掩盖后续断言
- time.Sleep(1100 * time.Millisecond)
- newPw := "new_hashed_password_xyz"
- err = m.UpdatePassword(ctx, id, username, newPw, 1, orig.UpdateTime)
- require.NoError(t, err)
- got, err := m.FindOne(ctx, id)
- require.NoError(t, err)
- assert.Equal(t, newPw, got.Password)
- assert.Equal(t, int64(1), got.MustChangePassword)
- assert.Equal(t, origTv+1, got.TokenVersion, "改密必须递增 tokenVersion 以注销旧会话")
- assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则乐观锁无法生效")
- }
- // TC-0927: UpdateStatus 正常路径仍然成功且 tokenVersion 递增
- func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
- ctx := context.Background()
- m, conn := newModel(t)
- username := "m2_st_ok_" + testutil.UniqueId()
- data := newTestSysUser(username, 1)
- res, err := m.Insert(ctx, data)
- require.NoError(t, err)
- id, err := res.LastInsertId()
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
- orig, err := m.FindOne(ctx, id)
- require.NoError(t, err)
- origTv := orig.TokenVersion
- require.Equal(t, int64(1), orig.Status)
- // L-N4:乐观锁依赖秒级 updateTime,确保 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime
- time.Sleep(1100 * time.Millisecond)
- err = m.UpdateStatus(ctx, id, username, 2, orig.UpdateTime)
- require.NoError(t, err)
- got, err := m.FindOne(ctx, id)
- require.NoError(t, err)
- assert.Equal(t, int64(2), got.Status)
- assert.Equal(t, origTv+1, got.TokenVersion, "冻结 / 解冻必须递增 tokenVersion 使旧 token 全部失效")
- assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则后续乐观锁失效")
- }
- // TC-0928(R11 重写):UpdatePassword 对不存在的 userId 必须回 ErrUpdateConflict
- // (H-R11-1 后,Model 不再内部 FindOne;不存在的 id + 任意 expectedUpdateTime → affected=0 → ErrUpdateConflict)
- func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsConflict(t *testing.T) {
- ctx := context.Background()
- m, _ := newModel(t)
- err := m.UpdatePassword(ctx, 999999999999, "ghost_user", "irrelevant", 1, 1)
- require.ErrorIs(t, err, user.ErrUpdateConflict,
- "H-R11-1:UpdatePassword 不再内部 FindOne,对不存在的 id 回 ErrUpdateConflict")
- }
- // TC-0929(R11 重写):UpdateStatus 对不存在的 userId 必须回 ErrUpdateConflict
- func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsConflict(t *testing.T) {
- ctx := context.Background()
- m, _ := newModel(t)
- err := m.UpdateStatus(ctx, 999999999999, "ghost_user", 2, 1)
- require.ErrorIs(t, err, user.ErrUpdateConflict,
- "M-R11-2:UpdateStatus 不再内部 FindOne,对不存在的 id 回 ErrUpdateConflict")
- }
|