mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-26 09:52:14 +03:00
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.
121 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|