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" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 H-R11-1 —— UpdatePassword 的 expectedUpdateTime 必须由调用方透传, // Model 层不得再内部 FindOne 自对齐 CAS;否则两个会话"都知道旧密码"时第二个会把第一个 // 紧急修改过的新密码盖回。此文件从 Model 层钉死三条关键契约: // 1) 签名强制 6 参 (id, username, password, mustChangePassword, expectedUpdateTime); // 2) 外层持有的陈旧 updateTime 传入 → ErrUpdateConflict(并发写入已让 DB 的 updateTime 推进); // 3) 正常路径 expectedUpdateTime 匹配时落盘成功,password / tokenVersion 正确持久化。 // --------------------------------------------------------------------------- // TC-1039: H-R11-1 —— 并发场景下外层快照的 expectedUpdateTime 已被他人推进,CAS 必须失败 func TestSysUserModel_UpdatePassword_StaleExpectedUpdateTime_Conflict(t *testing.T) { ctx := context.Background() m, conn := newModel(t) username := "hr111_stale_" + 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) }) // 外层 Session A 观测到的 updateTime(会校验旧密码时一起拿到) snapshotA, err := m.FindOne(ctx, id) require.NoError(t, err) snapshotAUpdateTime := snapshotA.UpdateTime // sys_user.updateTime 精度到秒,确保 Session B 提交的 UPDATE 严格推进 updateTime; // 否则同秒写回值与 snapshotAUpdateTime 相同,CAS 仍然匹配,无法复现 TOCTOU。 time.Sleep(1100 * time.Millisecond) // Session B("设备 B 紧急改密 P2")抢先基于 snapshotA 成功完成一次 CAS require.NoError(t, m.UpdatePassword(ctx, id, username, "H_P2", 1, snapshotAUpdateTime), "Session B 基于快照 A 的 updateTime 抢先完成 CAS,应当成功") // 现在 DB 的 updateTime 已经不是 snapshotAUpdateTime。 // Session A(持有旧密码 P0、已校验过旧密码)再用**同一份**旧 snapshot 的 updateTime // 去改密 P1,CAS 必须失败,否则 P2 会被 P1 覆盖(H-R11-1 TOCTOU)。 err = m.UpdatePassword(ctx, id, username, "H_P1_to_cover_P2", 1, snapshotAUpdateTime) require.ErrorIs(t, err, user.ErrUpdateConflict, "H-R11-1:expectedUpdateTime 必须是外层快照;Session B 已推进时,Session A 的改密 CAS 必须失败") // DB 终态保持为 Session B 的 H_P2,不被 Session A 覆盖 got, err := m.FindOne(ctx, id) require.NoError(t, err) assert.Equal(t, "H_P2", got.Password, "H-R11-1:TOCTOU 被关闭后,DB 终态必须是后到而胜出的那一方,不得被旧快照覆盖") } // TC-1040: H-R11-1 —— 正常路径 expectedUpdateTime 匹配时 UpdatePassword 落盘并递增 tokenVersion func TestSysUserModel_UpdatePassword_HappyPath_ExplicitExpectedUpdateTime(t *testing.T) { ctx := context.Background() m, conn := newModel(t) username := "hr111_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 time.Sleep(1100 * time.Millisecond) require.NoError(t, m.UpdatePassword(ctx, id, username, "H_NEW", 0, orig.UpdateTime), "H-R11-1:expectedUpdateTime 与 DB 当前 updateTime 一致时必须成功") got, err := m.FindOne(ctx, id) require.NoError(t, err) assert.Equal(t, "H_NEW", got.Password) assert.Equal(t, int64(0), got.MustChangePassword) assert.Equal(t, origTV+1, got.TokenVersion, "H-R11-1:UpdatePassword 必须递增 tokenVersion 以注销旧会话") assert.Greater(t, got.UpdateTime, orig.UpdateTime, "H-R11-1:updateTime 必须推进以支撑下一次 CAS") } // TC-1041: H-R11-1 —— 同一行被并发修改(如 UpdateProfile 改了昵称)之后,UpdatePassword 的 CAS 必须失败 // 覆盖"任何修改 sys_user 行的并发写入都会触发 ErrUpdateConflict"这一更严的契约: // 不仅是另一次改密可以"偷走"本次;改昵称/解冻/任何推进 updateTime 的操作也必须把本次改密拦住。 func TestSysUserModel_UpdatePassword_ConcurrentProfileWrite_BlocksPasswordUpdate(t *testing.T) { ctx := context.Background() m, conn := newModel(t) username := "hr111_prof_" + 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) }) snapshot, err := m.FindOne(ctx, id) require.NoError(t, err) // sys_user.updateTime 秒级,sleep 以确保 UpdateProfile 的 UPDATE 真的推进 time.Sleep(1100 * time.Millisecond) // Session B 改了昵称(完全合法的场景:管理员在用户"修改密码"弹窗打开的同一时刻修了昵称) require.NoError(t, m.UpdateProfile(ctx, id, username, "new_nick", snapshot.Email, snapshot.Phone, snapshot.Remark, snapshot.DeptId, snapshot.Status, false, snapshot.UpdateTime), "UpdateProfile 旁路已成功执行") // Session A 仍然基于 snapshot.UpdateTime 改密 —— 必须被 CAS 拦住 err = m.UpdatePassword(ctx, id, username, "H_LOST", 1, snapshot.UpdateTime) require.ErrorIs(t, err, user.ErrUpdateConflict, "H-R11-1:任何改动(含改昵称)都推进 updateTime;基于旧快照的改密必须被 CAS 拦住") got, err := m.FindOne(ctx, id) require.NoError(t, err) assert.Equal(t, snapshot.Password, got.Password, "Password 必须保持原值,未被 Session A 覆盖") assert.Equal(t, "new_nick", got.Nickname, "Profile 写入必须成功落盘") }