package business import ( "context" "errors" "golang.org/x/crypto/bcrypt" "sync" "testing" "time" ) type resetRepoFake struct { PasswordResetRepo mu sync.Mutex user *ResetUser code *ResetCode tokens map[string]*ResetToken password string } func (r *resetRepoFake) UserByName(context.Context, string) (*ResetUser, error) { if r.user == nil { return nil, nil } u := *r.user return &u, nil } func (r *resetRepoFake) UserByID(context.Context, uint) (*ResetUser, error) { return r.UserByName(context.Background(), "") } func (r *resetRepoFake) transaction(ctx context.Context, fn func(context.Context) error) error { r.mu.Lock() defer r.mu.Unlock() beforeCode := r.code beforeTokens := map[string]*ResetToken{} for key, value := range r.tokens { copy := *value beforeTokens[key] = © } beforePassword := r.password err := fn(ctx) if err != nil { r.code, r.tokens, r.password = beforeCode, beforeTokens, beforePassword } return err } func (r *resetRepoFake) WithCodeLock(ctx context.Context, id uint, fn func(context.Context) error) error { return r.transaction(ctx, func(ctx context.Context) error { if r.code == nil { r.code = &ResetCode{UserID: id} } return fn(ctx) }) } func (r *resetRepoFake) WithTokenLock(ctx context.Context, _ string, fn func(context.Context) error) error { return r.transaction(ctx, fn) } func (r *resetRepoFake) Code(context.Context, uint) (*ResetCode, error) { copy := *r.code return ©, nil } func (r *resetRepoFake) SaveCode(_ context.Context, c *ResetCode) error { copy := *c r.code = © return nil } func (r *resetRepoFake) CreateToken(_ context.Context, t *ResetToken) error { copy := *t r.tokens[t.Hash] = © return nil } func (r *resetRepoFake) Token(_ context.Context, key string) (*ResetToken, error) { if t := r.tokens[key]; t != nil { copy := *t return ©, nil } return nil, nil } func (r *resetRepoFake) SaveToken(_ context.Context, t *ResetToken) error { copy := *t r.tokens[t.Hash] = © return nil } func (r *resetRepoFake) DeleteToken(_ context.Context, key string) error { delete(r.tokens, key) return nil } func (r *resetRepoFake) UpdatePassword(_ context.Context, _ uint, hash string) error { r.password = hash return nil } type resetSMSFake struct { code string calls int err error } func (s *resetSMSFake) SendCode(_ context.Context, _ string, code string) error { s.code = code s.calls++ return s.err } func resetFixture() (*PasswordResetUsecase, *resetRepoFake, *resetSMSFake) { r := &resetRepoFake{user: &ResetUser{ID: 7, Username: "business", Phone: "13800138000", Enabled: true}, tokens: map[string]*ResetToken{}} s := &resetSMSFake{} return NewPasswordResetUsecase(r, s), r, s } func TestPasswordResetConsumesOnlyCorrectCodeAndOneToken(t *testing.T) { uc, repo, sms := resetFixture() ctx := context.Background() phone, err := uc.SendCode(ctx, "business") if err != nil || phone != "138****8000" { t.Fatalf("发码失败: %s %v", phone, err) } if _, err := uc.SendCode(ctx, "business"); err != ErrResetRate || sms.calls != 1 { t.Fatal("未执行 60 秒发码限制") } wrong := "000000" if sms.code == wrong { wrong = "111111" } if _, err := uc.VerifyCode(ctx, "business", wrong); err != ErrResetCode { t.Fatal("错误验证码被接受") } token, err := uc.VerifyCode(ctx, "business", sms.code) if err != nil { t.Fatal(err) } if repo.code.CodeHash != "" || len(token) != 64 { t.Fatal("匹配验证码未消费或未签发凭证") } if _, err := uc.VerifyCode(ctx, "business", sms.code); err != ErrResetCode { t.Fatal("验证码可重放") } if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != nil { t.Fatal(err) } if bcrypt.CompareHashAndPassword([]byte(repo.password), []byte("newpass1")) != nil { t.Fatal("未保存新密码 hash") } if err := uc.Reset(ctx, token, "newpass2", "newpass2"); err != ErrResetToken { t.Fatal("重置凭证可重放") } } func TestPasswordResetFailuresCommitAndRevokeAfterFive(t *testing.T) { uc, repo, sms := resetFixture() ctx := context.Background() uc.SendCode(ctx, "business") token, err := uc.VerifyCode(ctx, "business", sms.code) if err != nil { t.Fatal(err) } for i := 0; i < 5; i++ { if err := uc.Reset(ctx, token, "short", "short"); err != ErrResetLength { t.Fatal(err) } } if len(repo.tokens) != 0 { t.Fatal("业务校验失败后计数被回滚,凭证未吊销") } if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != ErrResetToken { t.Fatal("失败达到五次后仍允许重置") } } func TestPasswordResetRechecksStatusAndExpiration(t *testing.T) { uc, repo, sms := resetFixture() ctx := context.Background() uc.SendCode(ctx, "business") token, err := uc.VerifyCode(ctx, "business", sms.code) if err != nil { t.Fatal(err) } repo.user.Status = 3 if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != ErrResetAccount || repo.password != "" || len(repo.tokens) != 0 { t.Fatal("发码后停用或待审核的账号仍可重置") } repo.tokens[secretHash("expired")] = &ResetToken{Hash: secretHash("expired"), UserID: 7, ExpiresAt: time.Now().Add(-time.Second)} if err := uc.Reset(ctx, "expired", "newpass1", "newpass1"); err != ErrResetToken { t.Fatal("过期凭证仍可用") } if MaskPhone("not-a-phone") != "****" { t.Fatal("非标准手机号泄露明文") } } func TestPasswordResetConcurrentVerificationIssuesOneToken(t *testing.T) { uc, repo, sms := resetFixture() ctx := context.Background() if _, err := uc.SendCode(ctx, "business"); err != nil { t.Fatal(err) } results := make(chan error, 2) for i := 0; i < 2; i++ { go func() { _, err := uc.VerifyCode(ctx, "business", sms.code); results <- err }() } a, b := <-results, <-results if !((a == nil && b == ErrResetCode) || (b == nil && a == ErrResetCode)) || len(repo.tokens) != 1 { t.Fatalf("并发消费不原子: %v %v", a, b) } } func TestFailedSMSDoesNotIssueUsableCode(t *testing.T) { uc, repo, sms := resetFixture() sms.err = errors.New("provider failed") if _, err := uc.SendCode(context.Background(), "business"); err != ErrResetSMS { t.Fatal(err) } if repo.code.CodeHash != "" || repo.code.ExpiresAt != nil { t.Fatal("短信未发送却写入了可用验证码") } }