Files
kra-new/internal/biz/business/password_reset_test.go
T

216 lines
6.2 KiB
Go

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] = &copy
}
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 &copy, nil
}
func (r *resetRepoFake) SaveCode(_ context.Context, c *ResetCode) error {
copy := *c
r.code = &copy
return nil
}
func (r *resetRepoFake) CreateToken(_ context.Context, t *ResetToken) error {
copy := *t
r.tokens[t.Hash] = &copy
return nil
}
func (r *resetRepoFake) Token(_ context.Context, key string) (*ResetToken, error) {
if t := r.tokens[key]; t != nil {
copy := *t
return &copy, nil
}
return nil, nil
}
func (r *resetRepoFake) SaveToken(_ context.Context, t *ResetToken) error {
copy := *t
r.tokens[t.Hash] = &copy
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("短信未发送却写入了可用验证码")
}
}