Smail/backend/services/verification/completion_test.go
smail 6de0400cb4
Some checks are pending
Security Checks / security (push) Waiting to run
验证码使用成功后立即释放发送冷却并保留累计限额
2026-09-18 23:11:46 +08:00

200 lines
6.5 KiB
Go

package verification
import (
"errors"
"fmt"
"sync"
"testing"
"time"
)
func completionService(t *testing.T, persistent bool) (*Service, *string, *time.Time) {
t.Helper()
store, err := NewStore(64)
if err != nil {
t.Fatal(err)
}
var code string
service, err := NewService(store, func(_ Challenge, value string) error {
code = value
return nil
}, nil)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
service.now = func() time.Time { return now }
store.now = service.now
if persistent {
engine := newRateLimitEngine(t)
t.Cleanup(func() { _ = engine.Close() })
limiter, err := NewPersistentRateLimiter(engine)
if err != nil {
t.Fatal(err)
}
service.SetRateLimitStore(limiter)
}
return service, &code, &now
}
func completionRequest(purpose Purpose) IssueRequest {
return IssueRequest{Purpose: purpose, Account: "owner", Email: "owner@example.com",
UserID: 7, ResourceID: 9, BrowserTransaction: "browser", Environment: "production", CredentialVersion: 2}
}
func TestCompletionReleasesAllPurposeCooldownsAndPreservesQuota(t *testing.T) {
for _, persistent := range []bool{false, true} {
t.Run(fmt.Sprint(persistent), func(t *testing.T) {
service, code, _ := completionService(t, persistent)
purposes := []Purpose{PurposeLogin, PurposePasswordChange, PurposePasswordRecover, PurposeExternalEmailBind, PurposeExternalEmailChange}
for _, purpose := range purposes {
request := completionRequest(purpose)
challenge, err := service.Issue(request, "192.0.2.1")
if err != nil {
t.Fatalf("下一业务被已完成的验证阻塞:%s: %v", purpose, err)
}
if _, err := service.Issue(request, "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("未使用时应保留发送冷却:%v", err)
}
verify := func(value string) error {
return service.VerifyBoundWithContext(challenge.ID, request.Account, request.Email, value, purpose, request.ResourceID, "192.0.2.99", request.BrowserTransaction)
}
if err := verify("invalid"); !errors.Is(err, ErrInvalidChallenge) {
t.Fatal(err)
}
if _, err := service.Issue(request, "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("错误验证码释放了冷却:%v", err)
}
if err := verify(*code); err != nil {
t.Fatal(err)
}
if service.Store.Size() != 0 {
t.Fatal("验证码使用后仍有待验证状态")
}
if err := verify(*code); !errors.Is(err, ErrInvalidChallenge) {
t.Fatalf("已使用验证码被重放:%v", err)
}
}
if _, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("成功验证不应清除累计配额:%v", err)
}
})
}
}
func TestCompletionDoesNotReleaseNewerRequest(t *testing.T) {
for _, persistent := range []bool{false, true} {
t.Run(fmt.Sprint(persistent), func(t *testing.T) {
service, code, now := completionService(t, persistent)
first, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
firstCode := *code
*now = now.Add(issueInterval)
second, err := service.Issue(completionRequest(PurposePasswordChange), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
secondCode := *code
if err := service.VerifyBoundWithContext(first.ID, first.Account, first.Email, firstCode, first.Purpose, 9, "192.0.2.1", "browser"); err != nil {
t.Fatal(err)
}
if _, err := service.Issue(completionRequest(PurposeExternalEmailChange), "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("旧验证码清除了新请求的冷却:%v", err)
}
if err := service.VerifyBoundWithContext(second.ID, second.Account, second.Email, secondCode, second.Purpose, 9, "192.0.2.1", "browser"); err != nil {
t.Fatal(err)
}
if _, err := service.Issue(completionRequest(PurposeExternalEmailChange), "192.0.2.1"); err != nil {
t.Fatal(err)
}
})
}
}
func TestCompletionConcurrentVerificationAndPersistentRestart(t *testing.T) {
service, code, now := completionService(t, true)
challenge, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
results := make(chan error, 2)
var group sync.WaitGroup
for i := 0; i < 2; i++ {
group.Add(1)
go func() {
defer group.Done()
results <- service.VerifyBoundWithContext(challenge.ID, challenge.Account, challenge.Email, *code, challenge.Purpose, 9, "192.0.2.1", "browser")
}()
}
group.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
}
}
if success != 1 {
t.Fatalf("并发验证成功次数:%d", success)
}
restarted, _, _ := completionService(t, false)
restarted.now = func() time.Time { return *now }
restarted.SetRateLimitStore(service.RateLimits)
if _, err := restarted.Issue(completionRequest(PurposePasswordChange), "192.0.2.1"); err != nil {
t.Fatalf("重启后已释放的冷却仍阻塞:%v", err)
}
}
func TestCompletionKeepsDailyLimit(t *testing.T) {
for _, persistent := range []bool{false, true} {
t.Run(fmt.Sprint(persistent), func(t *testing.T) {
service, code, now := completionService(t, persistent)
for i := 0; i < maxPerDay; i++ {
if i == maxPerHour {
*now = now.Add(issueWindow)
}
challenge, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1")
if err != nil {
t.Fatal(err)
}
if err := service.VerifyBoundWithContext(challenge.ID, challenge.Account, challenge.Email, *code, challenge.Purpose, 9, "192.0.2.1", "browser"); err != nil {
t.Fatal(err)
}
}
*now = now.Add(issueWindow)
if _, err := service.Issue(completionRequest(PurposeLogin), "192.0.2.1"); !errors.Is(err, ErrRateLimited) {
t.Fatalf("每日配额被清除:%v", err)
}
})
}
}
func TestCompletionCleanupFailureCanRetryWithoutCompletingBusiness(t *testing.T) {
service, code, _ := completionService(t, false)
failing := true
challenge, err := service.Store.issue(completionRequest(PurposeLogin), service.Send, func() error {
if failing {
return ErrRateLimitStoreUnavailable
}
return nil
})
if err != nil {
t.Fatal(err)
}
verify := func() error {
return service.VerifyBoundWithContext(challenge.ID, challenge.Account, challenge.Email, *code, challenge.Purpose, 9, "192.0.2.1", "browser")
}
if err := verify(); !errors.Is(err, ErrServiceDown) {
t.Fatalf("清理失败不应返回业务成功:%v", err)
}
failing = false
if err := verify(); err != nil {
t.Fatalf("清理恢复后无法重试:%v", err)
}
if err := verify(); !errors.Is(err, ErrInvalidChallenge) {
t.Fatalf("重试成功后仍可重放:%v", err)
}
}