package tailscale import ( "context" "fmt" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" ) // blockingWatch is a watchFn that blocks until the context is cancelled, used // by tests that exercise direct manager calls rather than the watch loop. func blockingWatch(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { <-ctx.Done() return nil, ctx.Err() } // mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel. type mockWatcher struct { events []ipn.Notify idx int err error done chan struct{} ctx context.Context mu sync.Mutex closed bool } func newMockWatcher(ctx context.Context, events []ipn.Notify, err error) *mockWatcher { return &mockWatcher{ events: events, err: err, done: make(chan struct{}), ctx: ctx, } } func (w *mockWatcher) Next() (ipn.Notify, error) { w.mu.Lock() if w.idx < len(w.events) { n := w.events[w.idx] w.idx++ w.mu.Unlock() return n, nil } if w.err != nil { err := w.err w.mu.Unlock() return ipn.Notify{}, err } w.mu.Unlock() select { case <-w.done: return ipn.Notify{}, fmt.Errorf("watcher closed") case <-w.ctx.Done(): return ipn.Notify{}, w.ctx.Err() } } func (w *mockWatcher) Close() error { w.mu.Lock() defer w.mu.Unlock() if !w.closed { w.closed = true close(w.done) } return nil } // mockClient implements tailscaleClient for testing. type mockClient struct { watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) statusFn func(ctx context.Context) (*ipnstate.Status, error) getPrefsFn func(ctx context.Context) (*ipn.Prefs, error) editPrefsFn func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) } func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { return c.watchFn(ctx, mask) } func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) { return c.statusFn(ctx) } func (c *mockClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) { if c.getPrefsFn != nil { return c.getPrefsFn(ctx) } return &ipn.Prefs{}, nil } func (c *mockClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { if c.editPrefsFn != nil { return c.editPrefsFn(ctx, mp) } return &ipn.Prefs{}, nil } func runningStatus() *ipnstate.Status { return &ipnstate.Status{ Version: "1.94.2", BackendState: "Running", MagicDNSSuffix: "example.ts.net", CurrentTailnet: &ipnstate.TailnetStatus{ Name: "user@example.com", MagicDNSSuffix: "example.ts.net", }, Self: &ipnstate.PeerStatus{ HostName: "cachyos", DNSName: "cachyos.example.ts.net.", OS: "linux", Online: true, }, } } func TestWatchLoop_StateChange(t *testing.T) { stateVal := ipn.Running var watchCount int32 client := &mockClient{ watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { watchCount++ if watchCount == 1 { return newMockWatcher(ctx, []ipn.Notify{{State: &stateVal}}, fmt.Errorf("done"), ), nil } return newMockWatcher(ctx, nil, nil), nil }, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, } m := newManager(client) defer m.Close() require.Eventually(t, func() bool { s := m.GetState() return s.Connected && s.BackendState == "Running" && s.Self.Hostname == "cachyos" }, 2*time.Second, 10*time.Millisecond) } func TestWatchLoop_CoalescesNotifies(t *testing.T) { stateVal := ipn.Running var statusCalls atomic.Int32 notifies := make([]ipn.Notify, 0, 20) for range 20 { notifies = append(notifies, ipn.Notify{State: &stateVal}) } client := &mockClient{ watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { return newMockWatcher(ctx, notifies, nil), nil }, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { statusCalls.Add(1) return runningStatus(), nil }, } m := newManager(client) defer m.Close() // Wait for the debounce window to expire plus margin so the burst settles. time.Sleep(debounceWindow + 100*time.Millisecond) calls := statusCalls.Load() assert.Less(t, int(calls), 5, "20 rapid notifies should coalesce to a small number of Status RPCs, got %d", calls) assert.Greater(t, int(calls), 0, "expected at least one Status RPC") } func TestWatchLoop_Reconnect(t *testing.T) { watchCalled := make(chan struct{}, 4) client := &mockClient{ watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { select { case watchCalled <- struct{}{}: default: } if len(watchCalled) <= 1 { return nil, fmt.Errorf("connection refused") } return newMockWatcher(ctx, nil, nil), nil }, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, } m := newManager(client) defer m.Close() require.Eventually(t, func() bool { state := m.GetState() return state.BackendState == "Unreachable" }, 2*time.Second, 10*time.Millisecond) require.Eventually(t, func() bool { return len(watchCalled) >= 2 }, 3*time.Second, 50*time.Millisecond) } func TestManager_Subscribe(t *testing.T) { client := &mockClient{ watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { <-ctx.Done() return nil, ctx.Err() }, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, } m := newManager(client) defer m.Close() ch := m.Subscribe("test-1") assert.NotNil(t, ch) ch2 := m.Subscribe("test-2") assert.NotNil(t, ch2) m.Unsubscribe("test-1") m.Unsubscribe("test-2") } func TestManager_Close(t *testing.T) { client := &mockClient{ watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { <-ctx.Done() return nil, ctx.Err() }, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, } m := newManager(client) ch := m.Subscribe("test") assert.NotNil(t, ch) assert.NotPanics(t, func() { m.Close() }) } func TestManager_Availability(t *testing.T) { var watchAttempts atomic.Int32 client := &mockClient{ watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { n := watchAttempts.Add(1) if n == 1 { return nil, fmt.Errorf("tailscaled socket not found") } return newMockWatcher(ctx, nil, nil), nil }, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, } m := newManager(client) defer m.Close() cbFired := make(chan bool, 1) m.SetAvailabilityCallback(func(b bool) { select { case cbFired <- b: default: } }) assert.False(t, m.IsAvailable()) require.Eventually(t, func() bool { return m.IsAvailable() }, 3*time.Second, 50*time.Millisecond) select { case b := <-cbFired: assert.True(t, b) case <-time.After(time.Second): t.Fatal("availability callback did not fire") } } func TestManager_RefreshState(t *testing.T) { client := &mockClient{ watchFn: func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { <-ctx.Done() return nil, ctx.Err() }, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, } m := newManager(client) defer m.Close() m.RefreshState() state := m.GetState() assert.True(t, state.Connected) assert.Equal(t, "cachyos", state.Self.Hostname) } func TestManager_RefreshState_MergesPrefs(t *testing.T) { client := &mockClient{ watchFn: blockingWatch, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, getPrefsFn: func(ctx context.Context) (*ipn.Prefs, error) { return &ipn.Prefs{ExitNodeAllowLANAccess: true}, nil }, } m := newManager(client) defer m.Close() m.RefreshState() assert.True(t, m.GetState().ExitNodeAllowLANAccess) } func TestManager_Actions_EditPrefs(t *testing.T) { var captured *ipn.MaskedPrefs client := &mockClient{ watchFn: blockingWatch, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { captured = mp return &ipn.Prefs{}, nil }, } m := newManager(client) defer m.Close() require.NoError(t, m.Connect()) require.NotNil(t, captured) assert.True(t, captured.WantRunningSet) assert.True(t, captured.WantRunning) require.NoError(t, m.Disconnect()) assert.True(t, captured.WantRunningSet) assert.False(t, captured.WantRunning) require.NoError(t, m.SetExitNode("nABC123")) assert.True(t, captured.ExitNodeIDSet) assert.Equal(t, tailcfg.StableNodeID("nABC123"), captured.ExitNodeID) // ExitNodeIPSet must also be set so a stale legacy ExitNodeIP cannot // override the ID-based selection (mirrors `tailscale set --exit-node`). assert.True(t, captured.ExitNodeIPSet) require.NoError(t, m.SetExitNode("")) assert.True(t, captured.ExitNodeIDSet) assert.Equal(t, tailcfg.StableNodeID(""), captured.ExitNodeID) // Clearing must zero both the ID and any legacy IP-based exit node. assert.True(t, captured.ExitNodeIPSet) require.NoError(t, m.SetAllowLANAccess(true)) assert.True(t, captured.ExitNodeAllowLANAccessSet) assert.True(t, captured.ExitNodeAllowLANAccess) } func TestManager_Actions_PropagateError(t *testing.T) { client := &mockClient{ watchFn: blockingWatch, statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil }, editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { return nil, fmt.Errorf("backend rejected edit") }, } m := newManager(client) defer m.Close() assert.Error(t, m.Connect()) assert.Error(t, m.SetExitNode("nABC123")) assert.Error(t, m.SetAllowLANAccess(true)) }