mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-22 06:39:08 +02:00
191 lines
7.2 KiB
Go
191 lines
7.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"net/netip"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
|
)
|
|
|
|
func TestCredentialLimiterCooldown(t *testing.T) {
|
|
l := newCredentialLimiter()
|
|
now := time.Now()
|
|
l.now = func() time.Time { return now }
|
|
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
|
|
for range credentialFailureLimit {
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "initial guesses must reach verification")
|
|
l.finish(attempt, credentialRejected)
|
|
}
|
|
_, retry := l.begin(key)
|
|
assert.Equal(t, credentialBlockDuration, retry, "five failures must start a fifteen-minute block")
|
|
now = now.Add(credentialBlockDuration - time.Second)
|
|
_, retry = l.begin(key)
|
|
assert.Equal(t, time.Second, retry, "blocked requests must not extend the deadline")
|
|
now = now.Add(time.Second)
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "the source must recover when its block expires")
|
|
l.finish(attempt, credentialAccepted)
|
|
}
|
|
|
|
func TestCredentialLimiterFailureWindowAndSuccess(t *testing.T) {
|
|
for _, outcome := range []credentialOutcome{credentialAccepted, credentialUnavailable} {
|
|
t.Run(map[credentialOutcome]string{credentialAccepted: "success", credentialUnavailable: "infrastructure error"}[outcome], func(t *testing.T) {
|
|
l := newCredentialLimiter()
|
|
now := time.Now()
|
|
l.now = func() time.Time { return now }
|
|
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
|
|
for range 4 {
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "four failures must fit the budget")
|
|
l.finish(attempt, credentialRejected)
|
|
}
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "fifth check must be allowed")
|
|
l.finish(attempt, outcome)
|
|
now = now.Add(credentialCheckInterval)
|
|
attempt, retry = l.begin(key)
|
|
require.Zero(t, retry, "success or infrastructure error must not start a block")
|
|
l.finish(attempt, credentialRejected)
|
|
now = now.Add(credentialCheckInterval)
|
|
attempt, retry = l.begin(key)
|
|
if outcome == credentialUnavailable {
|
|
assert.Greater(t, retry, time.Duration(0), "infrastructure errors must preserve earlier failures")
|
|
return
|
|
}
|
|
require.Zero(t, retry, "success must clear earlier failures")
|
|
l.finish(attempt, credentialRejected)
|
|
now = now.Add(credentialFailureWindow)
|
|
for range credentialFailureLimit {
|
|
attempt, retry = l.begin(key)
|
|
require.Zero(t, retry, "old failures must expire")
|
|
l.finish(attempt, credentialRejected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCredentialLimiterRollingWindow(t *testing.T) {
|
|
l := newCredentialLimiter()
|
|
now := time.Now()
|
|
l.now = func() time.Time { return now }
|
|
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "the first failure starts the history")
|
|
l.finish(attempt, credentialRejected)
|
|
now = now.Add(4 * time.Minute)
|
|
for range 3 {
|
|
attempt, retry = l.begin(key)
|
|
require.Zero(t, retry, "three more failures must fit the budget")
|
|
l.finish(attempt, credentialRejected)
|
|
}
|
|
now = now.Add(time.Minute + time.Second)
|
|
for range 2 {
|
|
attempt, retry = l.begin(key)
|
|
require.Zero(t, retry, "only the oldest failure must have expired")
|
|
l.finish(attempt, credentialRejected)
|
|
}
|
|
_, retry = l.begin(key)
|
|
assert.Equal(t, credentialBlockDuration, retry, "five recent failures must block even across the first window boundary")
|
|
}
|
|
|
|
func TestCredentialLimiterServiceBudget(t *testing.T) {
|
|
l := newCredentialLimiter()
|
|
now := time.Now()
|
|
l.now = func() time.Time { return now }
|
|
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
|
|
for range credentialCheckBurst {
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "initial checks must fit the service burst")
|
|
l.finish(attempt, credentialAccepted)
|
|
key.ip = key.ip.Next()
|
|
}
|
|
_, retry := l.begin(key)
|
|
assert.Equal(t, credentialCheckInterval, retry, "changing IP must not bypass the service budget")
|
|
other := key
|
|
other.service.accountID = "another-account"
|
|
attempt, retry := l.begin(other)
|
|
require.Zero(t, retry, "accounts must have separate budgets")
|
|
l.finish(attempt, credentialAccepted)
|
|
other = key
|
|
other.service.serviceID = "another-service"
|
|
attempt, retry = l.begin(other)
|
|
require.Zero(t, retry, "services must have separate budgets")
|
|
l.finish(attempt, credentialAccepted)
|
|
now = now.Add(credentialCheckInterval)
|
|
attempt, retry = l.begin(key)
|
|
require.Zero(t, retry, "one check must refill every six seconds")
|
|
l.finish(attempt, credentialAccepted)
|
|
_, retry = l.begin(key)
|
|
assert.Equal(t, credentialCheckInterval, retry, "refill must only grant one new check")
|
|
}
|
|
|
|
func TestCredentialLimiterConcurrentReservations(t *testing.T) {
|
|
l := newCredentialLimiter()
|
|
now := time.Now()
|
|
l.now = func() time.Time { return now }
|
|
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
|
|
var attempts []*credentialSource
|
|
for range credentialFailureLimit {
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "initial requests must reserve the failure budget")
|
|
attempts = append(attempts, attempt)
|
|
}
|
|
// Refill the service budget while earlier verification calls are still running.
|
|
now = now.Add(time.Minute)
|
|
var admitted atomic.Int32
|
|
var wg sync.WaitGroup
|
|
for range 100 {
|
|
wg.Go(func() {
|
|
attempt, retry := l.begin(key)
|
|
if retry == 0 {
|
|
admitted.Add(1)
|
|
l.finish(attempt, credentialRejected)
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
assert.Zero(t, admitted.Load(), "in-flight guesses must reserve the failure budget despite a refilled service budget")
|
|
for _, attempt := range attempts {
|
|
wg.Go(func() { l.finish(attempt, credentialRejected) })
|
|
}
|
|
wg.Wait()
|
|
_, retry := l.begin(key)
|
|
assert.Equal(t, credentialBlockDuration, retry, "concurrent failures must activate the block")
|
|
}
|
|
|
|
func TestCredentialLimiterCapacityAndCleanup(t *testing.T) {
|
|
for _, fullSources := range []bool{true, false} {
|
|
t.Run(map[bool]string{true: "sources", false: "services"}[fullSources], func(t *testing.T) {
|
|
l := newCredentialLimiter()
|
|
now := time.Now()
|
|
l.now = func() time.Time { return now }
|
|
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
|
|
if fullSources {
|
|
ip := netip.MustParseAddr("198.18.0.1")
|
|
for range credentialMaxSources {
|
|
l.sources[credentialSourceKey{service: key.service, ip: ip}] = &credentialSource{expiresAt: now.Add(credentialBlockDuration), blockedUntil: now.Add(credentialBlockDuration)}
|
|
ip = ip.Next()
|
|
}
|
|
} else {
|
|
for i := range credentialMaxServices {
|
|
l.services[credentialServiceKey{serviceID: key.service.serviceID, accountID: types.AccountID(strconv.Itoa(i))}] = &credentialService{lastUsed: now}
|
|
}
|
|
}
|
|
_, retry := l.begin(key)
|
|
assert.Positive(t, retry, "full state must deny new checks without evicting active entries")
|
|
now = now.Add(credentialBlockDuration)
|
|
attempt, retry := l.begin(key)
|
|
require.Zero(t, retry, "expired state must release capacity")
|
|
l.finish(attempt, credentialAccepted)
|
|
})
|
|
}
|
|
}
|