package auth import ( "context" "database/sql" "errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/crypto/bcrypt" "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" "strings" "testing" "time" ) func ctxWithUserId(userId int64) context.Context { return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: userId}) } func insertTestUser(t *testing.T, ctx context.Context, username, password string) int64 { t.Helper() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) now := time.Now().Unix() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: password, Nickname: "test", Avatar: sql.NullString{}, Email: username + "@test.com", Phone: "13800000000", Remark: "", DeptId: 0, IsSuperAdmin: 2, MustChangePassword: 1, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-0054: 正常修改 func TestChangePassword_Success(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() ctx := context.Background() oldPwd := "Oldpass123" newPwd := "Newpass456" username := testutil.UniqueId() hashed := testutil.HashPassword(oldPwd) userId := insertTestUser(t, ctx, username, hashed) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: newPwd, }) require.NoError(t, err) updated, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(updated.Password), []byte(newPwd))) } // TC-0055: mustChangePassword重置 func TestChangePassword_MustChangePasswordReset(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() ctx := context.Background() oldPwd := "Oldpass123" newPwd := "Newpass456" username := testutil.UniqueId() hashed := testutil.HashPassword(oldPwd) userId := insertTestUser(t, ctx, username, hashed) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: newPwd, }) require.NoError(t, err) updated, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), updated.MustChangePassword) } // TC-0056: 原密码错误 func TestChangePassword_WrongOldPassword(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() ctx := context.Background() username := testutil.UniqueId() hashed := testutil.HashPassword("Realpass1") userId := insertTestUser(t, ctx, username, hashed) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: "Wrongpass1", NewPassword: "Newpass456", }) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "原密码错误", codeErr.Error()) } // TC-0057: 新密码少于8字符 func TestChangePassword_NewPasswordTooShort(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: "oldpass", NewPassword: "Pas1234", }) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error()) } // TC-0058: 新密码恰好8字符(含大小写+数字) func TestChangePassword_NewPasswordExactly8Chars(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() ctx := context.Background() oldPwd := "Oldpass123" newPwd := "Abcdef1x" username := testutil.UniqueId() hashed := testutil.HashPassword(oldPwd) userId := insertTestUser(t, ctx, username, hashed) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: newPwd, }) require.NoError(t, err) updated, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(updated.Password), []byte(newPwd))) } // TC-0059: 新密码空字符串 func TestChangePassword_NewPasswordEmpty(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: "oldpass", NewPassword: "", }) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error()) } // TC-0060: 新密码超过72字符 func TestChangePassword_NewPasswordTooLong(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) longPwd := "A" + strings.Repeat("a", 71) + "1" logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: "oldpass", NewPassword: longPwd, }) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "密码长度不能超过72个字符", codeErr.Error()) } // TC-0061: 新密码恰好72字符 func TestChangePassword_NewPasswordExactly72Chars(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() ctx := context.Background() oldPwd := "Oldpass123" newPwd := "B" + strings.Repeat("b", 70) + "1" username := testutil.UniqueId() hashed := testutil.HashPassword(oldPwd) userId := insertTestUser(t, ctx, username, hashed) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: newPwd, }) require.NoError(t, err) updated, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(updated.Password), []byte(newPwd))) } // TC-0062: 新旧密码相同 func TestChangePassword_SameOldAndNew(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() ctx := context.Background() pwd := "Samepass123" username := testutil.UniqueId() hashed := testutil.HashPassword(pwd) userId := insertTestUser(t, ctx, username, hashed) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: pwd, NewPassword: pwd, }) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "新密码不能与原密码相同", codeErr.Error()) } // TC-1179: "新旧密码相同"校验必须排在 bcrypt.CompareHashAndPassword 之前。 // // 用例设计:DB 里存的 Password 哈希对应 "RealOldpass123",但请求传入 // OldPassword == NewPassword == "Samepass123"(与 DB 存的原密码不匹配)。 // - 若校验顺序正确(L-R17-4):先断 `OldPassword == NewPassword`,直接 400 "新密码不能 // 与原密码相同",UpdatePassword 不应被触达(mock EXPECT 未声明即 Times(0) 判违约)。 // - 若顺序被误回滚为"先 bcrypt 后等值判断":bcrypt 会先失败并 400 "原密码错误", // 文案不同,用例会失败。 // // 该用例同时保证"同密码短路"这条 timing 分支在 mock 层面可回归观测,不依赖真实 DB 写入。 func TestChangePassword_SameOldAndNew_ChecksBeforeBcrypt(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const userId = int64(1750) // DB 里的哈希基于 "RealOldpass123"——与 req.OldPassword 不一致。 realOld := "RealOldpass123" hashed, err := bcrypt.GenerateFromPassword([]byte(realOld), bcrypt.DefaultCost) require.NoError(t, err) mockUser := mocks.NewMockSysUserModel(ctrl) mockUser.EXPECT().FindOne(gomock.Any(), userId). Return(&userModel.SysUser{ Id: userId, Username: "l_r17_4_subject", Password: string(hashed), Status: consts.StatusEnabled, UpdateTime: 4242, }, nil) // 关键护栏:UpdatePassword 绝不应被调用——若 EXPECT 未声明的方法被调用,gomock 会 FAIL。 svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser}) svcCtx.TokenOpLimiter = nil ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId}) wrongOldButEqualNew := "Samepass123" err = NewChangePasswordLogic(ctx, svcCtx).ChangePassword(&types.ChangePasswordReq{ OldPassword: wrongOldButEqualNew, NewPassword: wrongOldButEqualNew, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "L-R17-4:OldPassword==NewPassword 必须在 bcrypt 之前被拦截") assert.Equal(t, "新密码不能与原密码相同", ce.Error(), "文案必须是'新密码不能与原密码相同',若是'原密码错误'说明顺序被误回滚") } // TC-0063: 用户不存在 func TestChangePassword_UserNotFound(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewChangePasswordLogic(ctxWithUserId(99999999), svcCtx) err := logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: "Oldpass123", NewPassword: "Newpass456", }) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 404, codeErr.Code()) assert.Equal(t, "用户不存在", codeErr.Error()) } func TestChangePassword_UpdateConflict_Maps409(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const userId = int64(777) 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: "m_r10_4_subject", Password: string(hashed), Status: consts.StatusEnabled, UpdateTime: 1000, }, nil) // 关键:强制底层返回 ErrUpdateConflict。 // 签名增加 username 与 expectedUpdateTime 两个透传参数。 mockUser.EXPECT(). UpdatePassword(gomock.Any(), userId, "m_r10_4_subject", gomock.Any(), int64(consts.MustChangePasswordNo), int64(1000)). Return(userModel.ErrUpdateConflict) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser}) ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId}) logic := NewChangePasswordLogic(ctx, svcCtx) err = logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: newPwd, }) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr), "必须是 *response.CodeError,否则会被 rest 兜成 500") assert.Equal(t, 409, codeErr.Code(), "ErrUpdateConflict 必须映射为 409 Conflict") assert.Contains(t, codeErr.Error(), "密码已被其他会话修改", "文案与业务契约对齐") } // TC-1016: 非 ErrUpdateConflict 的原生错误仍应透传(500 由 rest 兜底), // 防止修复把所有底层错误都误吞为 409。 func TestChangePassword_GenericUpdateError_StillPropagates(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const userId = int64(778) 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: "m_r10_4_subject2", Password: string(hashed), Status: consts.StatusEnabled, UpdateTime: 2000, }, nil) genericErr := errors.New("driver: bad connection") mockUser.EXPECT(). UpdatePassword(gomock.Any(), userId, "m_r10_4_subject2", gomock.Any(), int64(consts.MustChangePasswordNo), int64(2000)). Return(genericErr) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser}) ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId}) logic := NewChangePasswordLogic(ctx, svcCtx) err = logic.ChangePassword(&types.ChangePasswordReq{ OldPassword: oldPwd, NewPassword: newPwd, }) require.Error(t, err) assert.ErrorIs(t, err, genericErr, "只把 ErrUpdateConflict 映射 409,其余错误原样透传(由 rest 兜 500)") var codeErr *response.CodeError assert.False(t, errors.As(err, &codeErr), "非冲突错误不得伪装成 CodeError") } 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: 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(), "旧密码已失配应 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, "首改成功递增 1;第二次因 400 未进入 UpdatePassword,tokenVersion 必须仍是 1") } // TC-1043: 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})) }