mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
The redeem failure counter used INCR followed by Expire(24h) on every attempt, so each failed attempt refreshed the key TTL and the window never actually closed for a user who kept retrying within 24h. - Count attempts in a fixed 10-minute window via a small Lua script (INCR, then PEXPIRE only when the key is created or has no TTL), which is atomic and also repairs keys that lost their TTL. - Move the counter to the redeem:ratelimit:v2: key space so pre-existing 24h counters do not seed the new window. - Rename the service threshold to redeemMaxFailedAttempts (30) to reflect that it bounds attempts per window, not per hour. Public redeem behavior is unchanged apart from the window semantics: the check fails open when the counter read fails, and a failed increment never masks the underlying domain error.
177 lines
4.8 KiB
Go
177 lines
4.8 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// redeemRateLimitCacheStub 实现 RedeemCache,记录各方法调用次数并允许注入错误。
|
|
type redeemRateLimitCacheStub struct {
|
|
count int
|
|
getErr error
|
|
incrementErr error
|
|
getCalls int
|
|
incrementCalls int
|
|
acquireCalls int
|
|
releaseCalls int
|
|
}
|
|
|
|
func (c *redeemRateLimitCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) {
|
|
c.getCalls++
|
|
return c.count, c.getErr
|
|
}
|
|
|
|
func (c *redeemRateLimitCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error {
|
|
c.incrementCalls++
|
|
if c.incrementErr != nil {
|
|
return c.incrementErr
|
|
}
|
|
c.count++
|
|
return nil
|
|
}
|
|
|
|
func (c *redeemRateLimitCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) {
|
|
c.acquireCalls++
|
|
return true, nil
|
|
}
|
|
|
|
func (c *redeemRateLimitCacheStub) ReleaseRedeemLock(context.Context, string) error {
|
|
c.releaseCalls++
|
|
return nil
|
|
}
|
|
|
|
func TestPublicRedeemAllowsLastAttemptBeforeLimit(t *testing.T) {
|
|
cache := &redeemRateLimitCacheStub{count: redeemMaxFailedAttempts - 1}
|
|
redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}
|
|
svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
|
|
|
|
result, err := svc.Redeem(context.Background(), 42, "MISSING")
|
|
|
|
require.Nil(t, result)
|
|
require.ErrorIs(t, err, ErrRedeemCodeNotFound)
|
|
require.Equal(t, redeemMaxFailedAttempts, cache.count)
|
|
require.Equal(t, 1, cache.getCalls)
|
|
require.Equal(t, 1, cache.incrementCalls)
|
|
require.Equal(t, 1, cache.acquireCalls)
|
|
require.Equal(t, 1, cache.releaseCalls)
|
|
}
|
|
|
|
func TestPublicRedeemCountsOnlyConfiguredDomainFailures(t *testing.T) {
|
|
past := time.Now().Add(-time.Minute)
|
|
tests := []struct {
|
|
name string
|
|
code RedeemCode
|
|
input string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "not found",
|
|
code: RedeemCode{Code: "OTHER"},
|
|
input: "MISSING",
|
|
wantErr: ErrRedeemCodeNotFound,
|
|
},
|
|
{
|
|
name: "expired",
|
|
code: RedeemCode{
|
|
ID: 1, Code: "EXPIRED", Type: RedeemTypeBalance, Value: 10,
|
|
Status: StatusUnused, ExpiresAt: &past,
|
|
},
|
|
input: "EXPIRED",
|
|
wantErr: ErrRedeemCodeExpired,
|
|
},
|
|
{
|
|
name: "used",
|
|
code: RedeemCode{
|
|
ID: 2, Code: "USED", Type: RedeemTypeBalance, Value: 10,
|
|
Status: StatusUsed,
|
|
},
|
|
input: "USED",
|
|
wantErr: ErrRedeemCodeUsed,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cache := &redeemRateLimitCacheStub{}
|
|
svc := &RedeemService{
|
|
redeemRepo: &redeemRejectRepo{code: tt.code},
|
|
cache: cache,
|
|
}
|
|
|
|
result, err := svc.Redeem(context.Background(), 42, tt.input)
|
|
|
|
require.Nil(t, result)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
require.Equal(t, 1, cache.getCalls)
|
|
require.Equal(t, 1, cache.incrementCalls)
|
|
require.Equal(t, 1, cache.count)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicRedeemRateLimitFailsOpenWhenCounterReadFails(t *testing.T) {
|
|
cache := &redeemRateLimitCacheStub{
|
|
count: redeemMaxFailedAttempts,
|
|
getErr: errors.New("redis unavailable"),
|
|
}
|
|
svc := &RedeemService{
|
|
redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}},
|
|
cache: cache,
|
|
}
|
|
|
|
result, err := svc.Redeem(context.Background(), 42, "MISSING")
|
|
|
|
require.Nil(t, result)
|
|
require.ErrorIs(t, err, ErrRedeemCodeNotFound)
|
|
require.Equal(t, 1, cache.getCalls)
|
|
require.Equal(t, 1, cache.incrementCalls)
|
|
}
|
|
|
|
func TestPublicRedeemCounterWriteFailurePreservesDomainError(t *testing.T) {
|
|
cache := &redeemRateLimitCacheStub{incrementErr: errors.New("redis unavailable")}
|
|
svc := &RedeemService{
|
|
redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}},
|
|
cache: cache,
|
|
}
|
|
|
|
result, err := svc.Redeem(context.Background(), 42, "MISSING")
|
|
|
|
require.Nil(t, result)
|
|
require.ErrorIs(t, err, ErrRedeemCodeNotFound)
|
|
require.Equal(t, 1, cache.incrementCalls)
|
|
require.Zero(t, cache.count)
|
|
}
|
|
|
|
func TestSuccessfulPublicRedeemDoesNotMutateFailureCounter(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := newPaymentConfigServiceTestClient(t)
|
|
userID := int64(42)
|
|
code := &RedeemCode{
|
|
ID: 101, Code: "PUBLIC-SUCCESS", Type: RedeemTypeBalance, Value: 10, Status: StatusUnused,
|
|
}
|
|
redeemRepo := &paymentOrderLifecycleRedeemRepo{
|
|
codesByCode: map[string]*RedeemCode{code.Code: code},
|
|
}
|
|
userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}}
|
|
userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil }
|
|
cache := &redeemRateLimitCacheStub{count: 7}
|
|
svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil)
|
|
|
|
result, err := svc.Redeem(ctx, userID, code.Code)
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, 1, cache.getCalls)
|
|
require.Zero(t, cache.incrementCalls)
|
|
require.Equal(t, 7, cache.count)
|
|
require.Equal(t, 1, cache.acquireCalls)
|
|
require.Equal(t, 1, cache.releaseCalls)
|
|
require.Len(t, redeemRepo.useCalls, 1)
|
|
}
|