package subsonic
import (
"context"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("authLimiter", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
})
acquire := func(l *authLimiter, key string) (*authSlot, bool) {
GinkgoHelper()
return l.acquire(ctx, key)
}
It("blocks a key after the configured number of failures", func() {
l := newAuthLimiter(2, time.Minute)
for range 2 {
slot, ok := acquire(l, "k")
Expect(ok).To(BeTrue())
slot.release(true)
}
_, ok := acquire(l, "k")
Expect(ok).To(BeFalse())
})
It("never counts successful checks", func() {
l := newAuthLimiter(2, time.Minute)
for range 50 {
slot, ok := acquire(l, "k")
Expect(ok).To(BeTrue())
slot.release(false)
}
})
It("keeps keys independent", func() {
l := newAuthLimiter(1, time.Minute)
slot, _ := acquire(l, "a")
slot.release(true)
_, ok := acquire(l, "a")
Expect(ok).To(BeFalse())
_, ok = acquire(l, "b")
Expect(ok).To(BeTrue())
})
It("waits for an in-flight check instead of failing the request", func() {
l := newAuthLimiter(1, time.Minute)
held, ok := acquire(l, "k")
Expect(ok).To(BeTrue())
waiting := make(chan bool, 1)
go func() {
slot, ok := l.acquire(ctx, "k")
slot.release(false)
waiting <- ok
}()
Consistently(waiting, 50*time.Millisecond).ShouldNot(Receive())
held.release(false)
Eventually(waiting).Should(Receive(BeTrue()))
})
It("stops waiting when the request is canceled", func() {
l := newAuthLimiter(1, time.Minute)
held, _ := acquire(l, "k")
DeferCleanup(func() { held.release(false) })
canceled, cancel := context.WithCancel(context.Background())
cancel()
_, ok := l.acquire(canceled, "k")
Expect(ok).To(BeFalse())
})
It("does not let concurrent guesses overshoot the limit", func() {
l := newAuthLimiter(5, time.Minute)
hold := make(chan struct{})
var checks atomic.Int32
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
slot, ok := l.acquire(ctx, "k")
if !ok {
return
}
checks.Add(1)
<-hold
slot.release(true)
})
}
Eventually(checks.Load).Should(Equal(int32(5)))
Consistently(checks.Load, 100*time.Millisecond).Should(Equal(int32(5)))
close(hold)
wg.Wait()
_, ok := acquire(l, "k")
Expect(ok).To(BeFalse())
})
It("allows everything when the limit is disabled", func() {
l := newAuthLimiter(0, time.Minute)
for range 10 {
slot, ok := acquire(l, "k")
Expect(ok).To(BeTrue())
slot.release(true)
}
})
})
func TestAuthLimiterWindow(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
ctx := context.Background()
l := newAuthLimiter(1, 20*time.Second)
for _, key := range []string{"a", "b"} {
slot, ok := l.acquire(ctx, key)
g.Expect(ok).To(BeTrue())
slot.release(true)
}
_, ok := l.acquire(ctx, "a")
g.Expect(ok).To(BeFalse())
time.Sleep(20 * time.Second)
slot, ok := l.acquire(ctx, "a")
g.Expect(ok).To(BeTrue())
slot.release(false)
g.Expect(l.keys).To(HaveLen(1), "expired keys must be dropped")
})
}