Files
panel/controller/cmd/controller/loginlimiter_test.go
T
dbledeez 4ccccc6fe2 panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:43:35 -07:00

145 lines
4.0 KiB
Go

package main
import (
"testing"
"time"
)
func newTestLimiter() (*loginLimiter, *time.Time) {
base := time.Unix(1_700_000_000, 0)
cur := base
l := newLoginLimiter(3, 10*time.Minute, 15*time.Minute)
l.now = func() time.Time { return cur }
return l, &cur
}
func TestLimiter_AllowsUntilThreshold(t *testing.T) {
l, _ := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
// failLimit = 3. First 2 failures still leave the account allowed.
for i := 0; i < 2; i++ {
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatalf("attempt %d should be allowed", i)
}
l.RecordFailure(ip, email)
}
// 3rd failure crosses the threshold → now locked.
l.RecordFailure(ip, email)
ok, retry := l.Allowed(ip, email)
if ok {
t.Fatal("expected lockout after threshold")
}
if retry <= 0 || retry > 15*time.Minute {
t.Fatalf("unexpected retry-after: %v", retry)
}
}
func TestLimiter_ResetOnSuccess(t *testing.T) {
l, _ := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
l.RecordFailure(ip, email)
l.RecordFailure(ip, email)
// A success clears the counter; further failures start from zero.
l.Reset(ip, email)
l.RecordFailure(ip, email)
l.RecordFailure(ip, email)
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatal("counter should have reset on success; must still be allowed")
}
}
func TestLimiter_LockoutExpires(t *testing.T) {
l, cur := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
for i := 0; i < 3; i++ {
l.RecordFailure(ip, email)
}
if ok, _ := l.Allowed(ip, email); ok {
t.Fatal("should be locked")
}
// Advance past the lockout window.
*cur = cur.Add(16 * time.Minute)
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatal("lockout should have expired")
}
}
func TestLimiter_WindowRollover(t *testing.T) {
l, cur := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
// 2 fails, then let the fail window elapse — the count should reset so
// two more fails don't trip the lockout.
l.RecordFailure(ip, email)
l.RecordFailure(ip, email)
*cur = cur.Add(11 * time.Minute) // past the 10-min window
l.RecordFailure(ip, email)
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatal("window should have rolled over; not locked yet")
}
}
func TestLimiter_EmailKeyAcrossIPs(t *testing.T) {
l, _ := newTestLimiter()
email := "victim@b.com"
// Attacker rotates IP each attempt but hammers one email. The email key
// must still trip the lockout independently of IP.
l.RecordFailure("1.1.1.1", email)
l.RecordFailure("2.2.2.2", email)
l.RecordFailure("3.3.3.3", email)
if ok, _ := l.Allowed("4.4.4.4", email); ok {
t.Fatal("email-keyed lockout should hold across differing IPs")
}
}
func TestLimiter_IPKeyAcrossEmails(t *testing.T) {
l, _ := newTestLimiter()
ip := "9.9.9.9"
// Attacker rotates email but from one IP. The IP key trips the lockout.
l.RecordFailure(ip, "a@b.com")
l.RecordFailure(ip, "c@d.com")
l.RecordFailure(ip, "e@f.com")
if ok, _ := l.Allowed(ip, "z@z.com"); ok {
t.Fatal("IP-keyed lockout should hold across differing emails")
}
}
func TestLimiter_NilSafe(t *testing.T) {
var l *loginLimiter
if ok, _ := l.Allowed("1.2.3.4", "a@b.com"); !ok {
t.Fatal("nil limiter must allow")
}
l.RecordFailure("1.2.3.4", "a@b.com") // must not panic
l.Reset("1.2.3.4", "a@b.com") // must not panic
}
func TestNormalizeIP(t *testing.T) {
cases := map[string]string{
"1.2.3.4:5678": "1.2.3.4",
"1.2.3.4": "1.2.3.4",
"[::1]:8080": "::1",
"": "",
}
for in, want := range cases {
if got := normalizeIP(in); got != want {
t.Errorf("normalizeIP(%q) = %q, want %q", in, got, want)
}
}
}
func TestHTTPBindIsNonLoopback(t *testing.T) {
cases := map[string]bool{
":8080": true,
"0.0.0.0:8080": true,
"::": true,
"127.0.0.1:8080": false,
"[::1]:8080": false,
"localhost:8080": true, // hostname → conservatively exposed
"192.168.1.5:8080": true,
}
for addr, want := range cases {
if got := httpBindIsNonLoopback(addr); got != want {
t.Errorf("httpBindIsNonLoopback(%q) = %v, want %v", addr, got, want)
}
}
}