package core import ( "context" "sync" "sync/atomic" "testing" "time" "vpnclient/internal/config" ) type fakeEngine struct { mu sync.Mutex running bool stopDelay time.Duration stops atomic.Int32 } func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive } func (e *fakeEngine) Start(context.Context, config.Profile, string) error { e.mu.Lock() defer e.mu.Unlock() e.running = true return nil } func (e *fakeEngine) Stop() error { e.stops.Add(1) if e.stopDelay > 0 { time.Sleep(e.stopDelay) } e.mu.Lock() e.running = false e.mu.Unlock() return nil } func (e *fakeEngine) Running() bool { e.mu.Lock() defer e.mu.Unlock() return e.running } func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true } func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true } func TestDisconnectWaitsBeforeReconnect(t *testing.T) { cfg := &config.Config{ Active: "t", SystemProxy: false, BinDir: t.TempDir(), Profiles: []config.Profile{{ Name: "t", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com", Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"}, }}, } m, err := NewManager(t.TempDir()+"/c.json", cfg, nil) if err != nil { t.Fatal(err) } eng := &fakeEngine{stopDelay: 200 * time.Millisecond} m.mu.Lock() m.engine = eng m.profile = &cfg.Profiles[0] m.mu.Unlock() start := time.Now() _ = m.Disconnect() // Connect must wait for Stop to finish even if Disconnect returned early... // our Disconnect waits up to 3s, so with 200ms it should complete. if time.Since(start) < 150*time.Millisecond { t.Fatalf("disconnect returned too fast") } if eng.Running() { t.Fatal("engine still running") } // Simulate slow stop that outlives Disconnect timeout. eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond} m.mu.Lock() m.engine = eng2 m.profile = &cfg.Profiles[0] m.mu.Unlock() // Force short path: call the wait mechanism like Connect does. m.mu.Lock() engine := m.engine m.engine = nil m.profile = nil m.lastStop.Add(1) m.mu.Unlock() go func() { defer m.lastStop.Done() _ = engine.Stop() }() waitStart := time.Now() m.waitEngineStopped() if time.Since(waitStart) < 400*time.Millisecond { t.Fatalf("waitEngineStopped returned before stop finished") } if eng2.Running() { t.Fatal("engine2 still running after wait") } } func TestConfigReturnsClone(t *testing.T) { cfg := &config.Config{ Active: "a", Profiles: []config.Profile{{ Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x", }}, } m, err := NewManager(t.TempDir()+"/c.json", cfg, nil) if err != nil { t.Fatal(err) } snap := m.Config() snap.Active = "mutated" snap.Profiles[0].Proxy = "leaked" if m.cfg.Active != "a" { t.Fatal("Config() leaked mutable Active") } if m.cfg.Profiles[0].Proxy != "https://x" { t.Fatal("Config() leaked mutable Profile") } }