4ccccc6fe2
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>
145 lines
4.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|