package auth import ( "context" "database/sql" "errors" "testing" "time" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/mocks" "perms-system-server/internal/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/crypto/bcrypt" ) // --------------------------------------------------------------------------- // 覆盖目标:H-R11-1 的 E2E 边界契约 —— 改密的 "400 原密码错误" 与 "409 被其他会话抢改" // 两条分支必须互不污染。 // // H-R11-1 的核心是把 `UpdatePassword` 的乐观锁 expected 从内部自取改为外层 FindOne 透传, // 并在 Logic 层把 `ErrUpdateConflict` 显式映射 409。这里补一条"正常串行"回归: // T0 ChangePassword(old=P0, new=P1) → 首改成功 // T1 ChangePassword(old=P0, new=P2) → 旧密码已失配,必须 400"原密码错误"; // 绝不能因为"外层快照已陈旧"之类的原因落到 409 分支。 // // 该契约直接护栏 H-R11-1 的 ErrUpdateConflict 映射逻辑不被误写成"吞掉所有错误都回 409"。 // 底层 CAS 正确性已在 model 层 TestSysUserModel_UpdatePassword_StaleExpectedUpdateTime_Conflict / // ConcurrentProfileWrite_BlocksPasswordUpdate 两个用例闭合;Logic→Model 的签名传参契约 // 则由 TestChangePassword_UpdateConflict_Maps409(mock UpdatePassword(..., 1000) 必须收到 // user.UpdateTime=1000)钉死。所以这里只补 400/409 分支隔离。 // --------------------------------------------------------------------------- func insertToctouUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username, plainPwd string) (int64, func()) { t.Helper() now := time.Now().Unix() hashed, err := bcrypt.GenerateFromPassword([]byte(plainPwd), bcrypt.DefaultCost) require.NoError(t, err) res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: string(hashed), Nickname: "toctou", Avatar: sql.NullString{}, Email: username + "@test.com", Phone: "13800000000", Remark: "", DeptId: 0, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() cleanup := func() { testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id) } return id, cleanup } // TC-1042: H-R11-1 E2E —— 400 vs 409 分支隔离:旧密码失配必须 400,绝不能误落 409 func TestChangePassword_E2E_SecondCallWithOldPwd_Maps400(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) svcCtx.TokenOpLimiter = nil oldPwd := "Oldpass123" username := "toctou_seq_" + testutil.UniqueId() userId, cleanup := insertToctouUser(t, ctx, svcCtx, username, oldPwd) t.Cleanup(cleanup) lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: userId, Username: username, Status: 1}) require.NoError(t, NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: "NewpassX_11", }), "首改必须成功") err := NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: "NewpassY_22", }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "H-R11-1:旧密码已失配应 400'原密码错误';不得因 ErrUpdateConflict 映射被误回 409") assert.Contains(t, ce.Error(), "原密码错误") // DB 终态:Password 是首改成功的 NewpassX_11,tokenVersion 恰好 1(而不是 2)。 got, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(got.Password), []byte("NewpassX_11"))) assert.Equal(t, int64(1), got.TokenVersion, "H-R11-1:首改成功递增 1;第二次因 400 未进入 UpdatePassword,tokenVersion 必须仍是 1") } // TC-1043: H-R11-1 —— UpdatePassword 签名护栏(mock 驱动): // 签名一旦回退(例如 username 再次被内部 FindOne 取而非外层透传),整个链路会编译失败; // 但契约层面的"必须透传外层 snapshot 的 UpdateTime"更细致:Logic 必须把 FindOne 返回的 // snapshot.UpdateTime 原样交给 UpdatePassword,不得自己算 time.Now() 或重新 FindOne。 // 这里用 mock 钉死该契约:FindOne 返回 UpdateTime=4242,UpdatePassword 必须收到 4242。 func TestChangePassword_ForwardsSnapshotUpdateTime(t *testing.T) { // 注:此契约已由既有 TestChangePassword_UpdateConflict_Maps409(UpdateTime=1000)覆盖, // 这里再以另一组数值(4242)做"反证哨兵",若 DEV 不小心硬编码常量/写死 time.Now, // 两组数值会同时失败,快速定位。 t.Run("expected=4242", func(t *testing.T) { runSnapshotForwardCase(t, 4242) }) t.Run("expected=9876543210", func(t *testing.T) { runSnapshotForwardCase(t, 9876543210) }) } func runSnapshotForwardCase(t *testing.T, expectedUpdateTime int64) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const userId = int64(17) oldPwd := "Oldpass123" newPwd := "Newpass456" hashed, err := bcrypt.GenerateFromPassword([]byte(oldPwd), bcrypt.DefaultCost) require.NoError(t, err) mockUser := mocks.NewMockSysUserModel(ctrl) mockUser.EXPECT().FindOne(gomock.Any(), userId). Return(&userModel.SysUser{ Id: userId, Username: "snap_subject", Password: string(hashed), Status: 1, UpdateTime: expectedUpdateTime, }, nil) // 合同:UpdatePassword 的第 6 个参数必须与 FindOne 返回的 UpdateTime 字面相等。 mockUser.EXPECT(). UpdatePassword(gomock.Any(), userId, "snap_subject", gomock.Any(), int64(consts.MustChangePasswordNo), expectedUpdateTime). Return(nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser}) ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId}) require.NoError(t, NewChangePasswordLogic(ctx, svcCtx).ChangePassword( &types.ChangePasswordReq{OldPassword: oldPwd, NewPassword: newPwd})) }