package xray import ( "sync/atomic" "testing" "github.com/cedar2025/xboard-node/internal/model" "github.com/xtls/xray-core/common/buf" "github.com/xtls/xray-core/transport" ) func newTestDispatcher() *LimitDispatcher { return &LimitDispatcher{ limitedIPs: make(map[string]map[string]int), } } type nopReader struct{} func (nopReader) ReadMultiBuffer() (buf.MultiBuffer, error) { return nil, nil } func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) { ld := newTestDispatcher() users := []model.UserSpec{ {ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0}, {ID: 2, UUID: "uuid-2", DeviceLimit: 0, SpeedLimit: 10}, } emailToUID := make(map[string]int) deviceLimits := make(map[string]int) speedLimits := make(map[string]int) for _, u := range users { email := userEmail(u.ID) emailToUID[email] = u.ID if u.DeviceLimit > 0 { deviceLimits[email] = u.DeviceLimit } if u.SpeedLimit > 0 { speedLimits[email] = u.SpeedLimit } } ld.UpdateLimits(emailToUID, deviceLimits, speedLimits) email1 := userEmail(1) // First IP should be allowed if ld.checkDeviceLimit(email1, "1.1.1.1", true) { t.Error("first IP should be allowed") } // Second IP should be allowed (limit=2) if ld.checkDeviceLimit(email1, "2.2.2.2", true) { t.Error("second IP should be allowed") } // Third unique IP should be rejected if !ld.checkDeviceLimit(email1, "3.3.3.3", true) { t.Error("third IP should be rejected (limit=2)") } // Same IP as first should be allowed (already connected) if ld.checkDeviceLimit(email1, "1.1.1.1", true) { t.Error("same IP should always be allowed") } // User 2 has no device limit — should always be allowed email2 := userEmail(2) for i := 0; i < 10; i++ { ip := "10.0.0." + string(rune('0'+i)) if ld.checkDeviceLimit(email2, ip, true) { t.Errorf("user with no device limit should always be allowed (ip=%s)", ip) } } } func TestLimitDispatcher_DelConn(t *testing.T) { ld := newTestDispatcher() email := userEmail(1) deviceLimits := map[string]int{email: 2} ld.UpdateLimits(map[string]int{email: 1}, deviceLimits, nil) // Add 2 IPs ld.checkDeviceLimit(email, "1.1.1.1", true) ld.checkDeviceLimit(email, "2.2.2.2", true) // Third should be rejected if !ld.checkDeviceLimit(email, "3.3.3.3", true) { t.Error("third IP should be rejected") } // Remove first IP ld.delConn(email, "1.1.1.1") // Now third IP should be allowed if ld.checkDeviceLimit(email, "3.3.3.3", true) { t.Error("after deleting one IP, new IP should be allowed") } } func TestLimitDispatcher_GetConnectionState(t *testing.T) { ld := newTestDispatcher() email1 := userEmail(1) email2 := userEmail(2) ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, nil) ic1 := &ipCounter{} r1 := &atomic.Int64{} r1.Store(1) ic1.ips.Store("1.1.1.1", r1) r2 := &atomic.Int64{} r2.Store(1) ic1.ips.Store("2.2.2.2", r2) ld.unlimitedIPs.Store(email1, ic1) ic2 := &ipCounter{} r3 := &atomic.Int64{} r3.Store(1) ic2.ips.Store("3.3.3.3", r3) ld.unlimitedIPs.Store(email2, ic2) ld.connCount.Store(5) aliveIPs, connCount := ld.GetConnectionState() if connCount != 5 { t.Errorf("expected connCount=5, got %d", connCount) } if len(aliveIPs[1]) != 2 { t.Errorf("user 1 IPs: got %d, want 2", len(aliveIPs[1])) } if len(aliveIPs[2]) != 1 { t.Errorf("user 2 IPs: got %d, want 1", len(aliveIPs[2])) } } func TestLimitDispatcher_ResetConns(t *testing.T) { ld := newTestDispatcher() ld.mu.Lock() ld.limitedIPs["user@1"] = map[string]int{"1.1.1.1": 1} ld.mu.Unlock() ld.connCount.Store(3) ld.ResetConns() ld.mu.RLock() ipCount := len(ld.limitedIPs) ld.mu.RUnlock() if ipCount != 0 { t.Error("limitedIPs should be empty after reset") } if ld.connCount.Load() != 0 { t.Error("connCount should be 0 after reset") } } func TestLimitDispatcher_UnlimitedUserFastPath(t *testing.T) { ld := newTestDispatcher() email := userEmail(1) // No device limit set for this user ld.UpdateLimits(map[string]int{email: 1}, nil, nil) // Should use fast path (sync.Map), no lock needed for i := 0; i < 100; i++ { ip := "10.0.0." + string(rune('0'+i%10)) if ld.checkDeviceLimit(email, ip, true) { t.Errorf("unlimited user should always be allowed (ip=%s)", ip) } } // Verify IPs are tracked in unlimitedIPs v, ok := ld.unlimitedIPs.Load(email) if !ok { t.Error("unlimited user should have entry in unlimitedIPs") } ic := v.(*ipCounter) ips := ic.aliveIPs() if len(ips) == 0 { t.Error("should have tracked some IPs") } } func TestLimitDispatcher_TrackLinkPreservesReader(t *testing.T) { ld := newTestDispatcher() email := userEmail(1) ld.UpdateLimits(map[string]int{email: 1}, map[string]int{email: 1}, nil) origReader := nopReader{} origWriter := &closeTrackingWriter{Writer: buf.Discard, onClose: func() {}} link := &transport.Link{Reader: origReader, Writer: origWriter} ld.trackLink(link, email, "1.1.1.1", true) if link.Reader != origReader { t.Fatal("trackLink must not replace link.Reader") } if link.Writer == origWriter { t.Fatal("trackLink should wrap link.Writer for lifecycle callbacks") } } func TestLimitDispatcher_CloseTrackingWriterReleasesConn(t *testing.T) { ld := newTestDispatcher() email := userEmail(1) ld.UpdateLimits(map[string]int{email: 1}, map[string]int{email: 1}, nil) if ld.checkDeviceLimit(email, "1.1.1.1", true) { t.Fatal("first connection should be allowed") } link := &transport.Link{Reader: nopReader{}, Writer: buf.Discard} ld.trackLink(link, email, "1.1.1.1", true) if got := ld.connCount.Load(); got != 1 { t.Fatalf("expected connCount=1 after tracking, got %d", got) } cw, ok := link.Writer.(*closeTrackingWriter) if !ok { t.Fatal("expected closeTrackingWriter wrapper") } if err := cw.Close(); err != nil { t.Fatalf("closeTrackingWriter.Close() error = %v", err) } if got := ld.connCount.Load(); got != 0 { t.Fatalf("expected connCount=0 after close, got %d", got) } if ld.checkDeviceLimit(email, "2.2.2.2", true) { t.Fatal("device slot should be released after writer close") } }