Files
sub2api/backend/internal/repository/email_cache_atomic_test.go
T
William DrakeandClaude Opus 5.5 b1ce3c6d59 fix(email): atomic verify-code attempts and hashed single-use reset tokens
- Verification code attempts (register + notify email) are now reserved via
  an atomic Redis INCR (Lua) before comparing, so concurrent wrong guesses
  cannot exceed the 5-attempt cap.
- Password reset tokens are stored as SHA-256 hashes and consumed with an
  atomic Lua compare-and-delete, so a token can only be used once even under
  concurrent requests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:26:28 -04:00

151 lines
4.6 KiB
Go

package repository
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func newMiniredisEmailCache(t *testing.T) (service.EmailCache, *miniredis.Miniredis, *redis.Client) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
return NewEmailCache(rdb), mr, rdb
}
func TestEmailCache_ConcurrentWrongCodesCannotExceedAttemptCap(t *testing.T) {
cache, _, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "user@example.com"
svc := service.NewEmailService(nil, cache)
require.NoError(t, cache.SetVerificationCode(ctx, email, &service.VerificationCodeData{
Code: "123456",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(15 * time.Minute),
}, 15*time.Minute))
const workers = 50
var invalid, maxed atomic.Int32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := svc.VerifyCode(ctx, email, "000000")
switch {
case errors.Is(err, service.ErrInvalidVerifyCode):
invalid.Add(1)
case errors.Is(err, service.ErrVerifyCodeMaxAttempts):
maxed.Add(1)
default:
t.Errorf("unexpected result: %v", err)
}
}()
}
wg.Wait()
// Only attempts 1..4 may return "invalid"; every other guess is rejected by the cap.
require.LessOrEqual(t, int(invalid.Load()), 4)
require.Equal(t, workers, int(invalid.Load()+maxed.Load()))
// Even the correct code is now rejected.
require.ErrorIs(t, svc.VerifyCode(ctx, email, "123456"), service.ErrVerifyCodeMaxAttempts)
data, err := cache.GetVerificationCode(ctx, email)
require.NoError(t, err)
require.GreaterOrEqual(t, data.Attempts, 5)
}
func TestEmailCache_AttemptsResetOnNewCodeAndTTLFollowsCode(t *testing.T) {
cache, mr, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "User@Example.com"
require.NoError(t, cache.SetVerificationCode(ctx, email, &service.VerificationCodeData{Code: "1"}, time.Minute))
n, err := cache.IncrVerificationCodeAttempts(ctx, email)
require.NoError(t, err)
require.Equal(t, 1, n)
require.Greater(t, mr.TTL(verifyCodeKey(email)+attemptsKeySuffix), time.Duration(0))
require.NoError(t, cache.SetVerificationCode(ctx, email, &service.VerificationCodeData{Code: "2"}, time.Minute))
data, err := cache.GetVerificationCode(ctx, email)
require.NoError(t, err)
require.Equal(t, 0, data.Attempts)
require.NoError(t, cache.DeleteVerificationCode(ctx, email))
_, err = cache.IncrVerificationCodeAttempts(ctx, email)
require.Error(t, err)
require.False(t, mr.Exists(verifyCodeKey(email)+attemptsKeySuffix))
}
func TestEmailCache_PasswordResetTokenHashedAndSingleUse(t *testing.T) {
cache, mr, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "reset@example.com"
svc := service.NewEmailService(nil, cache)
// Seed the token the same way SendPasswordResetEmail does (hash only).
token, err := svc.GeneratePasswordResetToken()
require.NoError(t, err)
sum := sha256.Sum256([]byte(token))
require.NoError(t, cache.SetPasswordResetToken(ctx, email, &service.PasswordResetTokenData{
Token: hex.EncodeToString(sum[:]), CreatedAt: time.Now(),
}, 30*time.Minute))
raw, err := mr.Get(passwordResetKey(email))
require.NoError(t, err)
require.False(t, strings.Contains(raw, token), "plaintext token must not be stored")
require.NoError(t, svc.VerifyPasswordResetToken(ctx, email, token))
require.ErrorIs(t, svc.ConsumePasswordResetToken(ctx, email, "wrong"), service.ErrInvalidResetToken)
const workers = 30
var ok atomic.Int32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if svc.ConsumePasswordResetToken(ctx, email, token) == nil {
ok.Add(1)
}
}()
}
wg.Wait()
require.Equal(t, int32(1), ok.Load())
require.False(t, mr.Exists(passwordResetKey(email)))
}
func TestEmailCache_ConsumePasswordResetTokenMismatchKeepsToken(t *testing.T) {
cache, mr, _ := newMiniredisEmailCache(t)
ctx := context.Background()
email := "keep@example.com"
require.NoError(t, cache.SetPasswordResetToken(ctx, email, &service.PasswordResetTokenData{Token: "abc"}, time.Minute))
ok, err := cache.ConsumePasswordResetToken(ctx, email, "xyz")
require.NoError(t, err)
require.False(t, ok)
require.True(t, mr.Exists(passwordResetKey(email)))
ok, err = cache.ConsumePasswordResetToken(ctx, email, "abc")
require.NoError(t, err)
require.True(t, ok)
ok, err = cache.ConsumePasswordResetToken(ctx, email, "abc")
require.NoError(t, err)
require.False(t, ok)
}