package service import ( "context" "errors" "testing" "github.com/cedar2025/xboard-node/internal/cert" "github.com/cedar2025/xboard-node/internal/config" "github.com/cedar2025/xboard-node/internal/kernel" "github.com/cedar2025/xboard-node/internal/limiter" "github.com/cedar2025/xboard-node/internal/model" "golang.org/x/time/rate" ) type fakeKernel struct { running bool startErr error updateErr error addErr error startCalls int updateCalls int addCalls int removeCalls int onUpdateUsers func([]model.UserSpec) onAddUsers func([]model.UserSpec) onRemoveUsers func([]model.UserSpec) speedLimitFunc func(string) *rate.Limiter deviceLimitFunc func(string) (int, bool) } func (f *fakeKernel) Name() string { return "fake" } func (f *fakeKernel) Protocols() []string { return []string{"vless"} } func (f *fakeKernel) Capabilities() kernel.Capabilities { return kernel.Capabilities{} } func (f *fakeKernel) Start(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { _, _, _, _ = nodeConfig, users, certFile, keyFile f.startCalls++ if f.startErr != nil { return f.startErr } f.running = true return nil } func (f *fakeKernel) Stop() { f.running = false } func (f *fakeKernel) IsRunning() bool { return f.running } func (f *fakeKernel) Reload(nodeConfig *model.NodeSpec, users []model.UserSpec, certFile, keyFile string) error { _, _, _, _ = nodeConfig, users, certFile, keyFile return nil } func (f *fakeKernel) AddUsers(users []model.UserSpec) (int, error) { f.addCalls++ if f.onAddUsers != nil { f.onAddUsers(users) } if f.addErr != nil { return 0, f.addErr } return len(users), nil } func (f *fakeKernel) RemoveUsers(users []model.UserSpec) (int, error) { f.removeCalls++ if f.onRemoveUsers != nil { f.onRemoveUsers(users) } return len(users), nil } func (f *fakeKernel) UpdateUsers(users []model.UserSpec) (int, int, error) { f.updateCalls++ if f.onUpdateUsers != nil { f.onUpdateUsers(users) } if f.updateErr != nil { return 0, 0, f.updateErr } return len(users), 0, nil } func (f *fakeKernel) GetUserTraffic(ctx context.Context) (map[int][2]int64, map[int]map[string]bool, int, error) { _ = ctx return nil, nil, 0, nil } func (f *fakeKernel) CloseConnection(ctx context.Context, connID string) error { _, _ = ctx, connID return nil } func (f *fakeKernel) CloseUserConnections(ctx context.Context, uuid string) error { _, _ = ctx, uuid return nil } func (f *fakeKernel) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) { f.speedLimitFunc = fn } func (f *fakeKernel) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) { f.deviceLimitFunc = fn } func (f *fakeKernel) UpdateGlobalDevices(users map[int][]string) { _ = users } func (f *fakeKernel) ClearGlobalDevices() {} func newTestService(k *fakeKernel) *Service { s := &Service{ kernel: k, limiter: limiter.New(), speedTracker: limiter.NewSpeedTracker(limiter.New()), cert: cert.NewManager(config.CertConfig{}), } // Use a shared limiter instance for device + speed bookkeeping. s.limiter = limiter.New() s.speedTracker = limiter.NewSpeedTracker(s.limiter) k.SetSpeedLimitFunc(s.speedTracker.GetLimiter) k.SetDeviceLimitFunc(s.limiter.GetDeviceLimitByUUID) return s } func TestApplyUserUpdatePreparesLimiterBeforeKernelUpdate(t *testing.T) { k := &fakeKernel{running: true} s := newTestService(k) s.lastConfig = &model.NodeSpec{Protocol: "vless"} oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} s.updateUserState(oldUsers) newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}} k.onUpdateUsers = func(users []model.UserSpec) { if len(users) != 1 || users[0].UUID != "uuid-new" { t.Fatalf("unexpected users passed to UpdateUsers: %#v", users) } if got := k.speedLimitFunc("uuid-new"); got == nil { t.Fatal("expected new user's limiter to be visible before kernel UpdateUsers") } } s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers)) if got := k.updateCalls; got != 1 { t.Fatalf("UpdateUsers call count = %d, want 1", got) } if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" { t.Fatalf("lastUsers = %#v, want new users", s.lastUsers) } if s.speedTracker.GetLimiter("uuid-new") == nil { t.Fatal("expected limiter for new user after successful update") } } func TestApplyUserUpdateRestoresStateWhenKernelAndRestartFail(t *testing.T) { k := &fakeKernel{ running: true, updateErr: errors.New("update failed"), startErr: errors.New("restart failed"), } s := newTestService(k) s.lastConfig = &model.NodeSpec{Protocol: "vless"} oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} s.updateUserState(oldUsers) oldHash := s.lastUserHash newUsers := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}} s.applyUserUpdate(context.Background(), newUsers, computeUserHash(newUsers)) if got := k.startCalls; got != 1 { t.Fatalf("Start call count = %d, want 1", got) } if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-old" { t.Fatalf("lastUsers = %#v, want restored old users", s.lastUsers) } if s.lastUserHash != oldHash { t.Fatalf("lastUserHash = %q, want %q", s.lastUserHash, oldHash) } if s.speedTracker.GetLimiter("uuid-old") == nil { t.Fatal("expected old limiter to be restored after rollback") } if s.speedTracker.GetLimiter("uuid-new") != nil { t.Fatal("expected new limiter to be removed after rollback") } } func TestApplyUserDeltaAddPreparesLimiterBeforeKernelUpdate(t *testing.T) { k := &fakeKernel{running: true} s := newTestService(k) s.lastConfig = &model.NodeSpec{Protocol: "vless"} oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} s.updateUserState(oldUsers) delta := []model.UserSpec{{ID: 2, UUID: "uuid-new", SpeedLimit: 8}} k.onAddUsers = func(users []model.UserSpec) { if len(users) != 1 || users[0].UUID != "uuid-new" { t.Fatalf("unexpected users passed to AddUsers: %#v", users) } if got := k.speedLimitFunc("uuid-new"); got == nil { t.Fatal("expected delta user's limiter to be visible before kernel AddUsers") } } s.applyUserDelta(context.Background(), "add", delta) if got := k.addCalls; got != 1 { t.Fatalf("AddUsers call count = %d, want 1", got) } if s.speedTracker.GetLimiter("uuid-new") == nil { t.Fatal("expected limiter for delta-added user after successful update") } } func TestApplyUserDeltaAddRemovesOldUUIDBeforeAdd(t *testing.T) { k := &fakeKernel{running: true} s := newTestService(k) s.lastConfig = &model.NodeSpec{Protocol: "vless"} oldUsers := []model.UserSpec{{ID: 1, UUID: "uuid-old", SpeedLimit: 4}} s.updateUserState(oldUsers) delta := []model.UserSpec{{ID: 1, UUID: "uuid-new", SpeedLimit: 8}} removalsBeforeAdd := 0 k.onRemoveUsers = func(users []model.UserSpec) { removalsBeforeAdd = k.addCalls if len(users) != 1 || users[0].UUID != "uuid-old" { t.Fatalf("unexpected users passed to RemoveUsers: %#v", users) } } k.onAddUsers = func(users []model.UserSpec) { if len(users) != 1 || users[0].UUID != "uuid-new" { t.Fatalf("unexpected users passed to AddUsers: %#v", users) } if k.removeCalls != 1 || removalsBeforeAdd != 0 { t.Fatalf("expected RemoveUsers before AddUsers, removeCalls=%d removalsBeforeAdd=%d", k.removeCalls, removalsBeforeAdd) } if got := k.speedLimitFunc("uuid-new"); got == nil { t.Fatal("expected replacement user's limiter to be visible before kernel AddUsers") } } s.applyUserDelta(context.Background(), "add", delta) if len(s.lastUsers) != 1 || s.lastUsers[0].UUID != "uuid-new" { t.Fatalf("lastUsers = %#v, want replacement user", s.lastUsers) } if s.speedTracker.GetLimiter("uuid-old") != nil { t.Fatal("expected old UUID limiter to be removed after replacement") } if s.speedTracker.GetLimiter("uuid-new") == nil { t.Fatal("expected new UUID limiter to be installed after replacement") } }