package verification import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" ) func TestReserveIssuanceEnforcesThreshold(t *testing.T) { now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) store := newMemoryIssuanceStore() db := &verificationDB{limits: store} request := &Request{IssuanceLimits: []IssuanceLimit{ NewIssuanceLimit("destination", "login\x00user@example.com", 2, time.Hour, now), }} for attempt := 1; attempt <= 2; attempt++ { if err := db.reserveIssuance(context.Background(), request, now); err != nil { t.Fatalf("attempt %d was unexpectedly rejected: %v", attempt, err) } } err := db.reserveIssuance(context.Background(), request, now) if !errors.Is(err, ErrIssuanceRateLimited) { t.Fatalf("expected rate-limit error, got %v", err) } details, ok := Throttle(err) if !ok || details.ScopeKind != "destination" || details.RetryAfter != time.Hour { t.Fatalf("unexpected throttle details: %+v, ok=%v", details, ok) } nextWindow := &Request{IssuanceLimits: []IssuanceLimit{ NewIssuanceLimit("destination", "login\x00user@example.com", 2, time.Hour, now.Add(time.Hour)), }} if err := db.reserveIssuance(context.Background(), nextWindow, now.Add(time.Hour)); err != nil { t.Fatalf("first request in the next window was rejected: %v", err) } } func TestCreateWithResultDoesNotMutateTokensWhenIssuanceDenied(t *testing.T) { now := time.Now().UTC() store := newMemoryIssuanceStore() db := newTestVerificationDB(t).WithIssuanceLimits(store) accountRef := bson.NewObjectID() limit := NewIssuanceLimit("destination", "login\x00user@example.com", 1, time.Hour, now) first, err := db.CreateWithResult(context.Background(), NewOTPRequest( accountRef, model.PurposeLogin, "user@example.com", ).WithIdempotencyKey("issuance-first").WithIssuanceLimits(limit)) if err != nil || !first.Created { t.Fatalf("first token creation failed: result=%+v err=%v", first, err) } _, err = db.CreateWithResult(context.Background(), NewOTPRequest( accountRef, model.PurposeLogin, "user@example.com", ).WithIdempotencyKey("issuance-second").WithIssuanceLimits(limit)) if !errors.Is(err, ErrIssuanceRateLimited) { t.Fatalf("expected second token creation to be rate-limited, got %v", err) } repo, ok := db.Repository.(*memoryTokenRepository) if !ok { t.Fatalf("unexpected verification repository type %T", db.Repository) } snapshot := repo.snapshot() if len(snapshot.data) != 1 { t.Fatalf("rate-limited request changed token count: %d", len(snapshot.data)) } for _, token := range snapshot.data { if token.UsedAt != nil { t.Fatal("rate-limited request invalidated the existing token") } } } func TestReserveIssuanceIsSharedAcrossInstances(t *testing.T) { now := time.Date(2026, 8, 13, 12, 15, 0, 0, time.UTC) store := newMemoryIssuanceStore() dbs := []*verificationDB{{limits: store}, {limits: store}} request := &Request{IssuanceLimits: []IssuanceLimit{ NewIssuanceLimit("ip", "login\x00192.0.2.10", 8, time.Hour, now), }} var allowed atomic.Int32 var denied atomic.Int32 var wg sync.WaitGroup for attempt := 0; attempt < 40; attempt++ { wg.Add(1) go func(db *verificationDB) { defer wg.Done() err := db.reserveIssuance(context.Background(), request, now) switch { case err == nil: allowed.Add(1) case errors.Is(err, ErrIssuanceRateLimited): denied.Add(1) default: t.Errorf("unexpected issuance error: %v", err) } }(dbs[attempt%len(dbs)]) } wg.Wait() if got := allowed.Load(); got != 8 { t.Fatalf("expected exactly 8 allowed reservations, got %d", got) } if got := denied.Load(); got != 32 { t.Fatalf("expected 32 denied reservations, got %d", got) } } func TestReserveIssuanceFailsClosedWithoutLeakingStorageError(t *testing.T) { now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) cause := merrors.Define("restricted verification destination") db := &verificationDB{limits: &memoryIssuanceStore{err: cause}} request := &Request{IssuanceLimits: []IssuanceLimit{ NewIssuanceLimit("destination", "login\x00secret@example.com", 2, time.Hour, now), }} err := db.reserveIssuance(context.Background(), request, now) if !errors.Is(err, merrors.ErrServiceUnavailable) || !errors.Is(err, cause) { t.Fatalf("expected opaque service-unavailable error preserving cause, got %v", err) } if got := err.Error(); got != "serviceUnavailable: failed to reserve verification issuance capacity" { t.Fatalf("storage error leaked or changed: %q", got) } } type memoryIssuanceStore struct { mu sync.Mutex counts map[string]int err error } func newMemoryIssuanceStore() *memoryIssuanceStore { return &memoryIssuanceStore{counts: make(map[string]int)} } func (s *memoryIssuanceStore) Current(_ context.Context, scopeKind, scopeHash string, windowStart time.Time) (int, error) { s.mu.Lock() defer s.mu.Unlock() if s.err != nil { return 0, s.err } return s.counts[issuanceStoreKey(scopeKind, scopeHash, windowStart)], nil } func (s *memoryIssuanceStore) Increment(_ context.Context, scopeKind, scopeHash string, windowStart, _ time.Time) (int, error) { s.mu.Lock() defer s.mu.Unlock() if s.err != nil { return 0, s.err } key := issuanceStoreKey(scopeKind, scopeHash, windowStart) s.counts[key]++ return s.counts[key], nil } func issuanceStoreKey(scopeKind, scopeHash string, windowStart time.Time) string { return scopeKind + "\x00" + scopeHash + "\x00" + windowStart.UTC().Format(time.RFC3339Nano) }