| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 |
- 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-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}))
- }
|