Files
3x-ui/internal/web/controller/login_limiter_test.go
Sanaei f4e79e70ea chore: refresh dependencies, fix Linux tool tasks, modernize Go idioms
Frontend deps: @hookform/resolvers 5.4.0 -> 5.4.3 and react-hook-form
7.82.0 -> 7.83.0. The @typeschema/valibot override is what makes this
installable at all. Resolvers 5.4.3 re-declares 25 optional peers for its
validator matrix, and npm resolves them into the ideal tree even though none
are used here; two of them contradict, since resolvers wants valibot ^1 while
@typeschema/main -> @typeschema/valibot pins valibot ^0.39. Both target the
same node_modules/valibot, so a plain npm update dies with ERESOLVE. The
override settles that one edge and nothing extra lands in node_modules.

Backend deps: telego 1.10.0 -> 1.11.1 (Telegram Bot API v10.2, additive
only), klauspost/compress 1.19.1, plus the indirect bumps that came with them.

VS Code tasks: the golangci-lint and modernize tasks assumed Windows PATH
semantics, where PATH is a persistent user variable that every process
inherits, so ~/go/bin was always visible. On Linux that directory is exported
from ~/.bashrc, which the non-interactive `bash -c` behind a task never
sources, and both tasks failed with exit 127. Adds linux/osx option blocks
that prepend the Go bin directories and leaves the Windows path untouched,
plus tasks to install the two tools; those are split because go install
rejects packages from different modules in one invocation.

Go sources: modernize -fix output, covering range-over-int, slices.Backward,
maps.Copy, strings.CutPrefix and strings.SplitSeq. Behaviour is unchanged.
2026-07-25 16:08:09 +02:00

121 lines
3.8 KiB
Go

package controller
import (
"strconv"
"strings"
"testing"
"time"
)
func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
for i := range loginLimitMaxRecords + 100 {
limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
}
limiter.mu.Lock()
n := len(limiter.attempts)
limiter.mu.Unlock()
if n > loginLimitMaxRecords {
t.Fatalf("attempts map grew to %d, exceeding the %d ceiling under a username flood", n, loginLimitMaxRecords)
}
}
func TestLoginLimiterEvictionSparesActiveBlocks(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
limiter.mu.Lock()
for i := range loginLimitMaxRecords - 1 {
limiter.attempts["victim-"+strconv.Itoa(i)] = &loginLimitRecord{blockedUntil: now.Add(10 * time.Minute)}
}
limiter.attempts["filler"] = &loginLimitRecord{failures: []time.Time{now}}
limiter.mu.Unlock()
if _, blocked := limiter.registerFailure("9.9.9.9", "newcomer"); blocked {
t.Fatal("the eviction-triggering failure itself should not be blocked yet")
}
limiter.mu.Lock()
defer limiter.mu.Unlock()
survivors := 0
for key, record := range limiter.attempts {
if strings.HasPrefix(key, "victim-") && now.Before(record.blockedUntil) {
survivors++
}
}
if survivors != loginLimitMaxRecords-1 {
t.Fatalf("eviction under a full map dropped an actively-blocked record: %d/%d victims survived", survivors, loginLimitMaxRecords-1)
}
}
func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for i := range 4 {
if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked {
t.Fatalf("failure %d should not block yet", i+1)
}
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
t.Fatalf("failure %d should still allow login attempts", i+1)
}
}
blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN")
if !blocked {
t.Fatal("fifth failure should start cooldown")
}
if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) {
t.Fatalf("blocked until %s, want %s", blockedUntil, want)
}
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
t.Fatal("login should be blocked during cooldown")
}
now = blockedUntil
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
t.Fatal("login should be allowed after cooldown")
}
}
func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for range 4 {
limiter.registerFailure("192.0.2.10", "admin")
}
now = now.Add(6 * time.Minute)
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
t.Fatal("old failures should be pruned outside the rolling window")
}
limiter.registerSuccess("192.0.2.10", "admin")
for i := range 4 {
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
t.Fatalf("success should reset previous failures; failure %d blocked", i+1)
}
}
}
func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for range 5 {
limiter.registerFailure("192.0.2.10", "admin")
}
if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
t.Fatal("different IP should not be blocked")
}
if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok {
t.Fatal("different username should not be blocked")
}
}