// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package controlclient

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"maps"
	"net/netip"
	"reflect"
	"strings"
	"sync/atomic"
	"testing"
	"testing/synctest"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"go4.org/mem"
	"tailscale.com/control/controlknobs"
	"tailscale.com/health"
	"tailscale.com/ipn"
	"tailscale.com/tailcfg"
	"tailscale.com/tstest"
	"tailscale.com/tstime"
	"tailscale.com/types/dnstype"
	"tailscale.com/types/key"
	"tailscale.com/types/logger"
	"tailscale.com/types/netmap"
	"tailscale.com/types/persist"
	"tailscale.com/util/eventbus/eventbustest"
	"tailscale.com/util/mak"
	"tailscale.com/util/must"
	"tailscale.com/util/usermetric"
	"tailscale.com/util/zstdframe"
	"tailscale.com/wgengine"
)

func eps(s ...string) []netip.AddrPort {
	var eps []netip.AddrPort
	for _, ep := range s {
		eps = append(eps, netip.MustParseAddrPort(ep))
	}
	return eps
}

func TestUpdatePeersStateFromResponse(t *testing.T) {
	var curTime time.Time

	online := func(v bool) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.Online = &v
		}
	}
	seenAt := func(t time.Time) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.LastSeen = &t
		}
	}
	withDERP := func(regionID int) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.HomeDERP = regionID
		}
	}
	withEP := func(ep string) func(*tailcfg.Node) {
		return func(n *tailcfg.Node) {
			n.Endpoints = []netip.AddrPort{netip.MustParseAddrPort(ep)}
		}
	}
	n := func(id tailcfg.NodeID, name string, mod ...func(*tailcfg.Node)) *tailcfg.Node {
		n := &tailcfg.Node{ID: id, Name: name}
		for _, f := range mod {
			f(n)
		}
		return n
	}
	peers := func(nv ...*tailcfg.Node) []*tailcfg.Node { return nv }
	tests := []struct {
		name      string
		mapRes    *tailcfg.MapResponse
		curTime   time.Time
		prev      []*tailcfg.Node
		want      []*tailcfg.Node
		wantStats updateStats
	}{
		{
			name: "full_peers",
			mapRes: &tailcfg.MapResponse{
				Peers: peers(n(1, "foo"), n(2, "bar")),
			},
			want: peers(n(1, "foo"), n(2, "bar")),
			wantStats: updateStats{
				allNew: true,
				added:  2,
			},
		},
		{
			name: "full_peers_ignores_deltas",
			mapRes: &tailcfg.MapResponse{
				Peers:        peers(n(1, "foo"), n(2, "bar")),
				PeersRemoved: []tailcfg.NodeID{2},
			},
			want: peers(n(1, "foo"), n(2, "bar")),
			wantStats: updateStats{
				allNew: true,
				added:  2,
			},
		},
		{
			name: "add_and_update",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				PeersChanged: peers(n(0, "zero"), n(2, "bar2"), n(3, "three")),
			},
			want: peers(n(0, "zero"), n(1, "foo"), n(2, "bar2"), n(3, "three")),
			wantStats: updateStats{
				added:   2, // added IDs 0 and 3
				changed: 1, // changed ID 2
			},
		},
		{
			name: "remove",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				PeersRemoved: []tailcfg.NodeID{1, 3, 4},
			},
			want: peers(n(2, "bar")),
			wantStats: updateStats{
				removed: 1, // ID 1
			},
		},
		{
			name: "add_and_remove",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				PeersChanged: peers(n(1, "foo2")),
				PeersRemoved: []tailcfg.NodeID{2},
			},
			want: peers(n(1, "foo2")),
			wantStats: updateStats{
				changed: 1,
				removed: 1,
			},
		},
		{
			name:   "unchanged",
			prev:   peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{},
			want:   peers(n(1, "foo"), n(2, "bar")),
		},
		{
			name: "online_change",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				OnlineChange: map[tailcfg.NodeID]bool{
					1:   true,
					404: true,
				},
			},
			want: peers(
				n(1, "foo", online(true)),
				n(2, "bar"),
			),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "online_change_offline",
			prev: peers(n(1, "foo"), n(2, "bar")),
			mapRes: &tailcfg.MapResponse{
				OnlineChange: map[tailcfg.NodeID]bool{
					1: false,
					2: true,
				},
			},
			want: peers(
				n(1, "foo", online(false)),
				n(2, "bar", online(true)),
			),
			wantStats: updateStats{changed: 2},
		},
		{
			name:    "peer_seen_at",
			prev:    peers(n(1, "foo", seenAt(time.Unix(111, 0))), n(2, "bar")),
			curTime: time.Unix(123, 0),
			mapRes: &tailcfg.MapResponse{
				PeerSeenChange: map[tailcfg.NodeID]bool{
					1: false,
					2: true,
				},
			},
			want: peers(
				n(1, "foo"),
				n(2, "bar", seenAt(time.Unix(123, 0))),
			),
			wantStats: updateStats{changed: 2},
		},
		{
			name: "ep_change_derp",
			prev: peers(n(1, "foo", withDERP(3))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:     1,
					DERPRegion: 4,
				}},
			},
			want:      peers(n(1, "foo", withDERP(4))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "ep_change_udp",
			prev: peers(n(1, "foo", withEP("1.2.3.4:111"))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:    1,
					Endpoints: eps("1.2.3.4:56"),
				}},
			},
			want:      peers(n(1, "foo", withEP("1.2.3.4:56"))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "ep_change_udp_2",
			prev: peers(n(1, "foo", withDERP(3), withEP("1.2.3.4:111"))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:    1,
					Endpoints: eps("1.2.3.4:56"),
				}},
			},
			want:      peers(n(1, "foo", withDERP(3), withEP("1.2.3.4:56"))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "ep_change_both",
			prev: peers(n(1, "foo", withDERP(3), withEP("1.2.3.4:111"))),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:     1,
					DERPRegion: 2,
					Endpoints:  eps("1.2.3.4:56"),
				}},
			},
			want:      peers(n(1, "foo", withDERP(2), withEP("1.2.3.4:56"))),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_key",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID: 1,
					Key:    new(key.NodePublicFromRaw32(mem.B(append(make([]byte, 31), 'A')))),
				}},
			}, want: peers(&tailcfg.Node{
				ID:   1,
				Name: "foo",
				Key:  key.NodePublicFromRaw32(mem.B(append(make([]byte, 31), 'A'))),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_key_signature",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:       1,
					KeySignature: []byte{3, 4},
				}},
			},
			want: peers(&tailcfg.Node{
				ID:           1,
				Name:         "foo",
				KeySignature: []byte{3, 4},
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_disco_key",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:   1,
					DiscoKey: new(key.DiscoPublicFromRaw32(mem.B(append(make([]byte, 31), 'A')))),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:       1,
				Name:     "foo",
				DiscoKey: key.DiscoPublicFromRaw32(mem.B(append(make([]byte, 31), 'A'))),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_online",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID: 1,
					Online: new(true),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:     1,
				Name:   "foo",
				Online: new(true),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_last_seen",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:   1,
					LastSeen: new(time.Unix(123, 0).UTC()),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:       1,
				Name:     "foo",
				LastSeen: new(time.Unix(123, 0).UTC()),
			}),
			wantStats: updateStats{changed: 1},
		},
		{
			name: "change_key_expiry",
			prev: peers(n(1, "foo")),
			mapRes: &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:    1,
					KeyExpiry: new(time.Unix(123, 0).UTC()),
				}},
			},
			want: peers(&tailcfg.Node{
				ID:        1,
				Name:      "foo",
				KeyExpiry: time.Unix(123, 0).UTC(),
			}),
			wantStats: updateStats{changed: 1},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if !tt.curTime.IsZero() {
				curTime = tt.curTime
				tstest.Replace(t, &clock, tstime.Clock(tstest.NewClock(tstest.ClockOpts{Start: curTime})))
			}
			ms := newTestMapSession(t, nil)
			for _, n := range tt.prev {
				mak.Set(&ms.peers, n.ID, n.View())
			}

			gotStats := ms.updatePeersStateFromResponse(tt.mapRes)
			if gotStats != tt.wantStats {
				t.Errorf("got stats = %+v; want %+v", gotStats, tt.wantStats)
			}

			var got []*tailcfg.Node
			for _, vp := range ms.sortedPeers() {
				got = append(got, vp.AsStruct())
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("wrong results\n got: %s\nwant: %s", formatNodes(got), formatNodes(tt.want))
			}
		})
	}
}

func formatNodes(nodes []*tailcfg.Node) string {
	var sb strings.Builder
	for i, n := range nodes {
		if i > 0 {
			sb.WriteString(", ")
		}
		fmt.Fprintf(&sb, "(%d, %q", n.ID, n.Name)

		if n.Online != nil {
			fmt.Fprintf(&sb, ", online=%v", *n.Online)
		}
		if n.LastSeen != nil {
			fmt.Fprintf(&sb, ", lastSeen=%v", n.LastSeen.Unix())
		}
		if n.Key != (key.NodePublic{}) {
			fmt.Fprintf(&sb, ", key=%v", n.Key.String())
		}
		if n.Expired {
			fmt.Fprintf(&sb, ", expired=true")
		}
		sb.WriteString(")")
	}
	return sb.String()
}

func newTestMapSession(t testing.TB, nu NetmapUpdater) *mapSession {
	ms := newMapSession(key.NewNode(), nu, new(controlknobs.Knobs))
	t.Cleanup(ms.Close)
	ms.logf = t.Logf
	return ms
}

func (ms *mapSession) netmapForResponse(res *tailcfg.MapResponse) *netmap.NetworkMap {
	ms.updateStateFromResponse(res)
	return ms.netmap()
}

func TestNetmapForResponse(t *testing.T) {
	t.Run("implicit_packetfilter", func(t *testing.T) {
		somePacketFilter := []tailcfg.FilterRule{
			{
				SrcIPs: []string{"*"},
				DstPorts: []tailcfg.NetPortRange{
					{IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
				},
			},
		}
		ms := newTestMapSession(t, nil)
		nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: somePacketFilter,
		})
		if len(nm1.PacketFilter) == 0 {
			t.Fatalf("zero length PacketFilter")
		}
		nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: nil, // testing that the server can omit this.
		})
		if len(nm1.PacketFilter) == 0 {
			t.Fatalf("zero length PacketFilter in 2nd netmap")
		}
		if !reflect.DeepEqual(nm1.PacketFilter, nm2.PacketFilter) {
			t.Error("packet filters differ")
		}
	})
	t.Run("implicit_dnsconfig", func(t *testing.T) {
		someDNSConfig := &tailcfg.DNSConfig{Domains: []string{"foo", "bar"}}
		ms := newTestMapSession(t, nil)
		nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:      new(tailcfg.Node),
			DNSConfig: someDNSConfig,
		})
		if !reflect.DeepEqual(nm1.DNS, *someDNSConfig) {
			t.Fatalf("1st DNS wrong")
		}
		nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:      new(tailcfg.Node),
			DNSConfig: nil, // implicit
		})
		if !reflect.DeepEqual(nm2.DNS, *someDNSConfig) {
			t.Fatalf("2nd DNS wrong")
		}
	})
	t.Run("collect_services", func(t *testing.T) {
		ms := newTestMapSession(t, nil)
		var nm *netmap.NetworkMap
		wantCollect := func(v bool) {
			t.Helper()
			if nm.CollectServices != v {
				t.Errorf("netmap.CollectServices = %v; want %v", nm.CollectServices, v)
			}
		}

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node: new(tailcfg.Node),
		})
		wantCollect(false)

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:            new(tailcfg.Node),
			CollectServices: "false",
		})
		wantCollect(false)

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:            new(tailcfg.Node),
			CollectServices: "true",
		})
		wantCollect(true)

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:            new(tailcfg.Node),
			CollectServices: "",
		})
		wantCollect(true)
	})
	t.Run("implicit_domain", func(t *testing.T) {
		ms := newTestMapSession(t, nil)
		var nm *netmap.NetworkMap
		want := func(v string) {
			t.Helper()
			if nm.Domain != v {
				t.Errorf("netmap.Domain = %q; want %q", nm.Domain, v)
			}
		}
		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node:   new(tailcfg.Node),
			Domain: "foo.com",
		})
		want("foo.com")

		nm = ms.netmapForResponse(&tailcfg.MapResponse{
			Node: new(tailcfg.Node),
		})
		want("foo.com")
	})
	t.Run("implicit_node", func(t *testing.T) {
		someNode := &tailcfg.Node{
			Name: "foo",
		}
		wantNode := (&tailcfg.Node{
			Name:                 "foo",
			ComputedName:         "foo",
			ComputedNameWithHost: "foo",
		}).View()
		ms := newTestMapSession(t, nil)
		mapRes := &tailcfg.MapResponse{
			Node: someNode,
		}
		initDisplayNames(mapRes.Node.View(), mapRes)
		ms.updateStateFromResponse(mapRes)
		nm1 := ms.netmap()
		if !nm1.SelfNode.Valid() {
			t.Fatal("nil Node in 1st netmap")
		}
		if !reflect.DeepEqual(nm1.SelfNode, wantNode) {
			j, _ := json.Marshal(nm1.SelfNode)
			t.Errorf("Node mismatch in 1st netmap; got: %s", j)
		}

		ms.updateStateFromResponse(&tailcfg.MapResponse{})
		nm2 := ms.netmap()
		if !nm2.SelfNode.Valid() {
			t.Fatal("nil Node in 1st netmap")
		}
		if !reflect.DeepEqual(nm2.SelfNode, wantNode) {
			j, _ := json.Marshal(nm2.SelfNode)
			t.Errorf("Node mismatch in 2nd netmap; got: %s", j)
		}
	})
	t.Run("named_packetfilter", func(t *testing.T) {
		pfA := []tailcfg.FilterRule{
			{
				SrcIPs: []string{"10.0.0.1"},
				DstPorts: []tailcfg.NetPortRange{
					{IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
				},
			},
		}
		pfB := []tailcfg.FilterRule{
			{
				SrcIPs: []string{"10.0.0.2"},
				DstPorts: []tailcfg.NetPortRange{
					{IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
				},
			},
		}
		ms := newTestMapSession(t, nil)

		// Mix of old & new style (PacketFilter and PacketFilters).
		nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: pfA,
			PacketFilters: map[string][]tailcfg.FilterRule{
				"pf-b": pfB,
			},
		})
		if got, want := len(nm1.PacketFilter), 2; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if got, want := first(nm1.PacketFilter[0].Srcs).String(), "10.0.0.1/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}
		if got, want := first(nm1.PacketFilter[1].Srcs).String(), "10.0.0.2/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}

		// No-op change. Remember the old stuff.
		nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:          new(tailcfg.Node),
			PacketFilter:  nil,
			PacketFilters: nil,
		})
		if got, want := len(nm2.PacketFilter), 2; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if !reflect.DeepEqual(nm1.PacketFilter, nm2.PacketFilter) {
			t.Error("packet filters differ")
		}

		// New style only, with clear.
		nm3 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: nil,
			PacketFilters: map[string][]tailcfg.FilterRule{
				"*":    nil,
				"pf-b": pfB,
			},
		})
		if got, want := len(nm3.PacketFilter), 1; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if got, want := first(nm3.PacketFilter[0].Srcs).String(), "10.0.0.2/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}

		// New style only, adding pfA back, not as the legacy "base" layer:.
		nm4 := ms.netmapForResponse(&tailcfg.MapResponse{
			Node:         new(tailcfg.Node),
			PacketFilter: nil,
			PacketFilters: map[string][]tailcfg.FilterRule{
				"pf-a": pfA,
			},
		})
		if got, want := len(nm4.PacketFilter), 2; got != want {
			t.Fatalf("PacketFilter length = %v; want %v", got, want)
		}
		if got, want := first(nm4.PacketFilter[0].Srcs).String(), "10.0.0.1/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}
		if got, want := first(nm4.PacketFilter[1].Srcs).String(), "10.0.0.2/32"; got != want {
			t.Fatalf("PacketFilter[0].Srcs = %v; want %v", got, want)
		}
	})
}

func TestUpdateDiscoForNode(t *testing.T) {
	tests := []struct {
		name            string
		initialOnline   bool
		initialLastSeen time.Time
		updateDiscoKey  bool
		updateOnline    bool
		updateLastSeen  time.Time
		wantUpdate      bool
		wantKeyChanged  bool
	}{
		{
			name:            "newer_key_not_online",
			initialOnline:   true,
			initialLastSeen: time.Unix(1, 0),
			updateDiscoKey:  true,
			updateOnline:    false,
			updateLastSeen:  time.Now(),
			wantUpdate:      true,
			wantKeyChanged:  true,
		},
		{
			name:            "newer_key_online",
			initialOnline:   true,
			initialLastSeen: time.Unix(1, 0),
			updateDiscoKey:  true,
			updateOnline:    true,
			updateLastSeen:  time.Now(),
			wantUpdate:      true,
			wantKeyChanged:  true,
		},
		{
			name:            "older_key_not_online",
			initialOnline:   false,
			initialLastSeen: time.Now(),
			updateDiscoKey:  true,
			updateOnline:    false,
			updateLastSeen:  time.Unix(1, 0),
			wantUpdate:      false,
			wantKeyChanged:  false,
		},
		{
			name:            "older_key_online",
			initialOnline:   false,
			initialLastSeen: time.Now(),
			updateDiscoKey:  true,
			updateOnline:    true,
			updateLastSeen:  time.Unix(1, 0),
			wantUpdate:      true,
			wantKeyChanged:  true,
		},
		{
			name:            "same_newer_key_not_online",
			initialOnline:   true,
			initialLastSeen: time.Unix(1, 0),
			updateDiscoKey:  false,
			updateOnline:    false,
			updateLastSeen:  time.Now(),
			wantUpdate:      false,
			wantKeyChanged:  false,
		},
		{
			name:            "same_newer_key_online",
			initialOnline:   true,
			initialLastSeen: time.Unix(1, 0),
			updateDiscoKey:  false,
			updateOnline:    true,
			updateLastSeen:  time.Now(),
			wantUpdate:      false,
			wantKeyChanged:  false,
		},
		{
			name:            "same_older_key_not_online",
			initialOnline:   false,
			initialLastSeen: time.Now(),
			updateDiscoKey:  false,
			updateOnline:    false,
			updateLastSeen:  time.Unix(1, 0),
			wantUpdate:      false,
			wantKeyChanged:  false,
		},
		{
			name:            "same_older_key_online",
			initialOnline:   false,
			initialLastSeen: time.Now(),
			updateDiscoKey:  false,
			updateOnline:    true,
			updateLastSeen:  time.Unix(1, 0),
			wantUpdate:      true,
			wantKeyChanged:  false,
		},
		{
			name:           "no_initial_last_seen",
			initialOnline:  false,
			updateDiscoKey: true,
			updateOnline:   false,
			updateLastSeen: time.Now(),
			wantUpdate:     true,
			wantKeyChanged: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			synctest.Test(t, func(*testing.T) {
				nu := &rememberLastNetmapUpdater{
					done: make(chan any, 1),
				}
				ms := newTestMapSession(t, nu)
				defer ms.Close()

				oldKey := key.NewDisco()

				// Insert existing node
				node := tailcfg.Node{
					ID:       1,
					Key:      key.NewNode().Public(),
					DiscoKey: oldKey.Public(),
					Online:   &tt.initialOnline,
				}
				if !tt.initialLastSeen.IsZero() {
					node.LastSeen = &tt.initialLastSeen
				}

				if nm := ms.netmapForResponse(&tailcfg.MapResponse{
					Peers: []*tailcfg.Node{&node},
				}); len(nm.Peers) != 1 {
					t.Fatalf("node not inserted")
				}

				newKey := oldKey.Public()
				if tt.updateDiscoKey {
					newKey = key.NewDisco().Public()
				}
				ms.updateDiscoForNode(node.ID, node.Key, newKey, tt.updateLastSeen, tt.updateOnline)

				// We have an early escape that would not trigger the netmap updater.
				synctest.Wait()
				select {
				case <-nu.done:
					if !tt.wantUpdate {
						t.Errorf("did not expect update, got: %v", nu.last)
					}
				default:
					if tt.wantUpdate {
						t.Errorf("expected update, did not get any")
					}
				}

				peer, ok := ms.peers[node.ID]
				if !ok {
					t.Fatal("node not found")
				}

				keyChanged := peer.DiscoKey().Compare(oldKey.Public()) != 0
				if keyChanged != tt.wantKeyChanged {
					t.Errorf("Disco key update: %t, wanted update: %t", keyChanged, tt.wantKeyChanged)
				}
			})
		})
	}
}

func TestUpdateDiscoForNodeCallback(t *testing.T) {
	t.Run("key_wired_through_to_updater", func(t *testing.T) {
		nu := &rememberLastNetmapUpdater{
			done: make(chan any, 1),
		}
		ms := newTestMapSession(t, nu)

		oldKey := key.NewDisco()

		// Insert existing node
		node := tailcfg.Node{
			ID:       1,
			Key:      key.NewNode().Public(),
			DiscoKey: oldKey.Public(),
			Online:   new(false),
			LastSeen: new(time.Unix(1, 0)),
		}

		if nm := ms.netmapForResponse(&tailcfg.MapResponse{
			Peers: []*tailcfg.Node{&node},
		}); len(nm.Peers) != 1 {
			t.Fatalf("node not inserted")
		}

		newKey := key.NewDisco()
		ms.updateDiscoForNode(node.ID, node.Key, newKey.Public(), time.Now(), false)
		<-nu.done

		if nu.lastTSMPKey != node.Key || nu.lastTSMPDisco != newKey.Public() {
			t.Fatalf("expected [%s]=%s, got [%s]=%s", node.Key, newKey.Public(),
				nu.lastTSMPKey, nu.lastTSMPDisco)
		}
	})
	// Even though key stays in list of update, the updater only triggers on TSMP.
	t.Run("key_not_wired_through_to_updater", func(t *testing.T) {
		nu := &rememberLastNetmapUpdater{
			done: make(chan any, 1),
		}
		ms := newTestMapSession(t, nu)

		oldKey := key.NewDisco()

		// Insert existing node
		node := tailcfg.Node{
			ID:       1,
			Key:      key.NewNode().Public(),
			DiscoKey: oldKey.Public(),
			Online:   new(false),
			LastSeen: new(time.Unix(1, 0)),
		}

		if nm := ms.netmapForResponse(&tailcfg.MapResponse{
			Peers: []*tailcfg.Node{&node},
		}); len(nm.Peers) != 1 {
			t.Fatalf("node not inserted")
		}

		newKey := key.NewDisco().Public()
		resp := &tailcfg.MapResponse{
			PeersChangedPatch: []*tailcfg.PeerChange{{
				NodeID:   node.ID,
				Key:      &node.Key,
				LastSeen: new(time.Now()),
				Online:   new(true),
				DiscoKey: &newKey,
			}},
		}
		// Not TSMP Path, just regular injection path.
		ms.HandleNonKeepAliveMapResponse(t.Context(), resp)
		<-nu.done

		if !nu.lastTSMPKey.IsZero() || !nu.lastTSMPDisco.IsZero() {
			t.Fatalf("expected zero keys, got [%s]=%s",
				nu.lastTSMPKey, nu.lastTSMPDisco)
		}
	})

	t.Run("test_deadlock", func(t *testing.T) {
		nu := &rememberLastNetmapUpdater{
			done: make(chan any, 1),
		}
		ms := newTestMapSession(t, nu)
		// Very barebones onDebug func that will let us exercise sleep command
		// from control and potentially induce deadlocks.
		ms.onDebug = func(ctx context.Context, d *tailcfg.Debug) error {
			time.Sleep(time.Duration(d.SleepSeconds * float64(time.Second)))
			return nil
		}

		oldKey := key.NewDisco()

		// Insert existing node
		node := tailcfg.Node{
			ID:       1,
			Key:      key.NewNode().Public(),
			DiscoKey: oldKey.Public(),
			Online:   new(false),
			LastSeen: new(time.Unix(1, 0)),
		}

		if nm := ms.netmapForResponse(&tailcfg.MapResponse{
			Peers: []*tailcfg.Node{&node},
		}); len(nm.Peers) != 1 {
			t.Fatalf("node not inserted")
		}

		sleep1 := &tailcfg.MapResponse{
			Debug: &tailcfg.Debug{
				SleepSeconds: 1.0,
			},
		}
		ms.HandleNonKeepAliveMapResponse(t.Context(), sleep1)

		// Resembles the disco key advert subscriber running in a separate context.
		go func() {
			newKey := key.NewDisco()
			ms.updateDiscoForNode(node.ID, node.Key, newKey.Public(), time.Now(), false)
		}()

		ms.Close()

		<-nu.done
	})
}

func TestUpdateDiscoForNodeCallbackWithFullNetmap(t *testing.T) {
	now := time.Now()
	oldTime := time.Unix(1, 0)

	tests := []struct {
		name            string
		initialOnline   bool
		initialLastSeen time.Time
		updateOnline    bool
		updateLastSeen  time.Time
		expectNewDisco  bool
	}{
		{
			name:            "disco-key-newer-lastSeen",
			initialOnline:   false,
			initialLastSeen: oldTime,
			updateOnline:    false,
			updateLastSeen:  now,
			expectNewDisco:  true,
		},
		{
			name:            "disco-key-older-lastSeen",
			initialOnline:   false,
			initialLastSeen: now,
			updateOnline:    false,
			updateLastSeen:  oldTime,
			expectNewDisco:  false,
		},
		{
			name:            "disco-key-newer-lastSeen-going-offline",
			initialOnline:   true,
			initialLastSeen: oldTime,
			updateOnline:    false,
			updateLastSeen:  now,
			expectNewDisco:  true,
		},
		{
			name:            "online-flip-newer-lastSeen",
			initialOnline:   false,
			initialLastSeen: oldTime,
			updateOnline:    true,
			updateLastSeen:  now,
			expectNewDisco:  true,
		},
		{
			name:            "local-lastseen-preserved-after-first-reconnect",
			initialOnline:   false,
			initialLastSeen: now,
			updateOnline:    false,
			updateLastSeen:  now,
			expectNewDisco:  false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			nu := &rememberLastNetmapUpdater{
				done: make(chan any, 1),
			}
			ms := newTestMapSession(t, nu)

			oldKey := key.NewDisco()

			// Initial node
			node := tailcfg.Node{
				ID:       1,
				Key:      key.NewNode().Public(),
				DiscoKey: oldKey.Public(),
				Online:   new(tt.initialOnline),
				LastSeen: new(tt.initialLastSeen),
				Name:     "host.network.ts.net",
			}

			if nm := ms.netmapForResponse(&tailcfg.MapResponse{
				Peers: []*tailcfg.Node{&node},
			}); len(nm.Peers) != 1 {
				t.Fatalf("node not inserted")
			}

			newKey := key.NewDisco()

			// Updated node
			newNode := tailcfg.Node{
				ID:       1,
				Key:      node.Key,
				DiscoKey: newKey.Public(),
				Online:   new(tt.updateOnline),
				LastSeen: new(tt.updateLastSeen),
				Name:     "host.network.ts.net",
			}

			ms.HandleNonKeepAliveMapResponse(t.Context(), &tailcfg.MapResponse{
				Node: &newNode,
				Peers: []*tailcfg.Node{
					&newNode,
				},
			})
			<-nu.done

			newMap := nu.last
			if n := len(newMap.Peers); n != 1 {
				t.Fatalf("netmap not right length, got %d, expected %d", n, 1)
			}

			peer := newMap.Peers[0]

			expectedDisco := oldKey.Public()
			if tt.expectNewDisco {
				expectedDisco = newKey.Public()
			}

			if peer.Key() != node.Key || peer.DiscoKey() != expectedDisco {
				t.Fatalf("expected [%s]=%s, got [%s]=%s",
					node.Key, expectedDisco,
					peer.Key(), peer.DiscoKey(),
				)
			}
		})
	}
}

func first[T any](s []T) T {
	if len(s) == 0 {
		var zero T
		return zero
	}
	return s[0]
}

func TestDeltaDERPMap(t *testing.T) {
	regions1 := map[int]*tailcfg.DERPRegion{
		1: {
			RegionID: 1,
			Nodes: []*tailcfg.DERPNode{{
				Name:     "derp1a",
				RegionID: 1,
				HostName: "derp1a" + tailcfg.DotInvalid,
				IPv4:     "169.254.169.254",
				IPv6:     "none",
			}},
		},
	}

	// As above, but with a changed IPv4 addr
	regions2 := map[int]*tailcfg.DERPRegion{1: regions1[1].Clone()}
	regions2[1].Nodes[0].IPv4 = "127.0.0.1"

	type step struct {
		got  *tailcfg.DERPMap
		want *tailcfg.DERPMap
	}
	tests := []struct {
		name  string
		steps []step
	}{
		{
			name: "nothing-to-nothing",
			steps: []step{
				{nil, nil},
				{nil, nil},
			},
		},
		{
			name: "regions-sticky",
			steps: []step{
				{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
				{&tailcfg.DERPMap{}, &tailcfg.DERPMap{Regions: regions1}},
			},
		},
		{
			name: "regions-change",
			steps: []step{
				{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
				{&tailcfg.DERPMap{Regions: regions2}, &tailcfg.DERPMap{Regions: regions2}},
			},
		},
		{
			name: "home-params",
			steps: []step{
				// Send a DERP map
				{&tailcfg.DERPMap{Regions: regions1}, &tailcfg.DERPMap{Regions: regions1}},
				// Send home params, want to still have the same regions
				{
					&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
				},
			},
		},
		{
			name: "home-params-sub-fields",
			steps: []step{
				// Send a DERP map with home params
				{
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
				},
				// Sending a struct with a 'HomeParams' field but nil RegionScore doesn't change home params...
				{
					&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: nil}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{1: 0.5},
					}},
				},
				// ... but sending one with a non-nil and empty RegionScore field zeroes that out.
				{
					&tailcfg.DERPMap{HomeParams: &tailcfg.DERPHomeParams{RegionScore: map[int]float64{}}},
					&tailcfg.DERPMap{Regions: regions1, HomeParams: &tailcfg.DERPHomeParams{
						RegionScore: map[int]float64{},
					}},
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ms := newTestMapSession(t, nil)
			for stepi, s := range tt.steps {
				nm := ms.netmapForResponse(&tailcfg.MapResponse{DERPMap: s.got})
				if !reflect.DeepEqual(nm.DERPMap, s.want) {
					t.Errorf("unexpected result at step index %v; got: %s", stepi, logger.AsJSON(nm.DERPMap))
				}
			}
		})
	}
}

func TestPeerChangeDiff(t *testing.T) {
	tests := []struct {
		name      string
		a, b      *tailcfg.Node
		want      *tailcfg.PeerChange // nil means want ok=false, unless wantEqual is set
		wantEqual bool                // means test wants (nil, true)
	}{
		{
			name:      "eq",
			a:         &tailcfg.Node{ID: 1},
			b:         &tailcfg.Node{ID: 1},
			wantEqual: true,
		},
		{
			name: "patch-derp",
			a:    &tailcfg.Node{ID: 1, HomeDERP: 1},
			b:    &tailcfg.Node{ID: 1, HomeDERP: 2},
			want: &tailcfg.PeerChange{NodeID: 1, DERPRegion: 2},
		},
		{
			name: "patch-endpoints",
			a:    &tailcfg.Node{ID: 1, Endpoints: eps("10.0.0.1:1")},
			b:    &tailcfg.Node{ID: 1, Endpoints: eps("10.0.0.2:2")},
			want: &tailcfg.PeerChange{NodeID: 1, Endpoints: eps("10.0.0.2:2")},
		},
		{
			name: "patch-cap",
			a:    &tailcfg.Node{ID: 1, Cap: 1},
			b:    &tailcfg.Node{ID: 1, Cap: 2},
			want: &tailcfg.PeerChange{NodeID: 1, Cap: 2},
		},
		{
			name: "patch-lastseen",
			a:    &tailcfg.Node{ID: 1, LastSeen: new(time.Unix(1, 0))},
			b:    &tailcfg.Node{ID: 1, LastSeen: new(time.Unix(2, 0))},
			want: &tailcfg.PeerChange{NodeID: 1, LastSeen: new(time.Unix(2, 0))},
		},
		{
			name: "patch-online-to-true",
			a:    &tailcfg.Node{ID: 1, Online: new(false)},
			b:    &tailcfg.Node{ID: 1, Online: new(true)},
			want: &tailcfg.PeerChange{NodeID: 1, Online: new(true)},
		},
		{
			name: "patch-online-to-false",
			a:    &tailcfg.Node{ID: 1, Online: new(true)},
			b:    &tailcfg.Node{ID: 1, Online: new(false)},
			want: &tailcfg.PeerChange{NodeID: 1, Online: new(false)},
		},
		{
			name: "mix-patchable-and-not",
			a:    &tailcfg.Node{ID: 1, Cap: 1},
			b:    &tailcfg.Node{ID: 1, Cap: 2, StableID: "foo"},
			want: nil,
		},
		{
			name: "miss-change-stableid",
			a:    &tailcfg.Node{ID: 1},
			b:    &tailcfg.Node{ID: 1, StableID: "diff"},
			want: nil,
		},
		{
			name: "miss-change-id",
			a:    &tailcfg.Node{ID: 1},
			b:    &tailcfg.Node{ID: 2},
			want: nil,
		},
		{
			name: "miss-change-name",
			a:    &tailcfg.Node{ID: 1, Name: "foo"},
			b:    &tailcfg.Node{ID: 1, Name: "bar"},
			want: nil,
		},
		{
			name: "miss-change-user",
			a:    &tailcfg.Node{ID: 1, User: 1},
			b:    &tailcfg.Node{ID: 1, User: 2},
			want: nil,
		},
		{
			name: "miss-change-masq-v4",
			a:    &tailcfg.Node{ID: 1, SelfNodeV4MasqAddrForThisPeer: new(netip.MustParseAddr("100.64.0.1"))},
			b:    &tailcfg.Node{ID: 1, SelfNodeV4MasqAddrForThisPeer: new(netip.MustParseAddr("100.64.0.2"))},
			want: nil,
		},
		{
			name: "miss-change-masq-v6",
			a:    &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: new(netip.MustParseAddr("2001::3456"))},
			b:    &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: new(netip.MustParseAddr("2001::3006"))},
			want: nil,
		},
		{
			name: "patch-capmap-add-value-to-existing-key",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}},
		},
		{
			name: "patch-capmap-add-new-key",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}},
		},
		{
			name: "patch-capmap-remove-key",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}},
		},
		{
			name: "patch-capmap-remove-as-nil",
			a:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:    &tailcfg.Node{ID: 1},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}},
		},
		{
			name: "patch-capmap-add-key-to-empty-map",
			a:    &tailcfg.Node{ID: 1},
			b:    &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
		},
		{
			name:      "patch-capmap-no-change",
			a:         &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			b:         &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
			wantEqual: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			pc, ok := peerChangeDiff(tt.a.View(), tt.b, nil)
			if tt.wantEqual {
				if !ok || pc != nil {
					t.Errorf("got (%p, %v); want (nil, true); pc=%v", pc, ok, logger.AsJSON(pc))
				}
				return
			}
			if (pc != nil) != ok {
				t.Fatalf("inconsistent ok=%v, pc=%p", ok, pc)
			}
			if !reflect.DeepEqual(pc, tt.want) {
				t.Errorf("mismatch\n got: %v\nwant: %v\n", logger.AsJSON(pc), logger.AsJSON(tt.want))
			}
		})
	}
}

func TestPeerChangeDiffAllocs(t *testing.T) {
	a := &tailcfg.Node{ID: 1}
	b := &tailcfg.Node{ID: 1}
	n := testing.AllocsPerRun(10000, func() {
		diff, ok := peerChangeDiff(a.View(), b, nil)
		if !ok || diff != nil {
			t.Fatalf("unexpected result: (%s, %v)", logger.AsJSON(diff), ok)
		}
	})
	if n != 0 {
		t.Errorf("allocs = %v; want 0", int(n))
	}
}

type countingNetmapUpdater struct {
	full atomic.Int64
}

func (nu *countingNetmapUpdater) UpdateFullNetmap(nm *netmap.NetworkMap) {
	nu.full.Add(1)
}

type countingDeltaNetmapUpdater struct {
	countingNetmapUpdater
	delta atomic.Int64
}

func (nu *countingDeltaNetmapUpdater) UpdateNetmapDelta([]netmap.NodeMutation) bool {
	nu.delta.Add(1)
	return true
}

func TestExistingPeerReplacementHandledIncrementally(t *testing.T) {
	nu := &countingDeltaNetmapUpdater{}
	ms := newTestMapSession(t, nu)
	ctx := t.Context()

	peer := &tailcfg.Node{
		ID:         1,
		StableID:   "peer",
		Name:       "peer.example.ts.net.",
		Key:        key.NewNode().Public(),
		DiscoKey:   key.NewDisco().Public(),
		Addresses:  []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
		AllowedIPs: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
		Hostinfo:   (&tailcfg.Hostinfo{}).View(),
	}
	if err := ms.handleNonKeepAliveMapResponse(ctx, &tailcfg.MapResponse{
		Node:  &tailcfg.Node{Name: "self.example.ts.net."},
		Peers: []*tailcfg.Node{peer},
	}, false); err != nil {
		t.Fatal(err)
	}
	if got := nu.full.Load(); got != 1 {
		t.Fatalf("full updates after initial response = %d; want 1", got)
	}

	replacement := peer.Clone()
	replacement.AllowedIPs = append(replacement.AllowedIPs, netip.MustParsePrefix("100.64.0.2/32"))
	if err := ms.handleNonKeepAliveMapResponse(ctx, &tailcfg.MapResponse{
		PeersChanged: []*tailcfg.Node{replacement},
	}, false); err != nil {
		t.Fatal(err)
	}
	if got := nu.full.Load(); got != 1 {
		t.Errorf("full updates after route-changing peer replacement = %d; want 1", got)
	}
	if got := nu.delta.Load(); got != 1 {
		t.Errorf("delta updates after route-changing peer replacement = %d; want 1", got)
	}
}

// tests (*mapSession).patchifyPeersChanged; smaller tests are in TestPeerChangeDiff
func TestPatchifyPeersChanged(t *testing.T) {
	hi := (&tailcfg.Hostinfo{}).View()
	tests := []struct {
		name string
		mr0  *tailcfg.MapResponse // initial
		mr1  *tailcfg.MapResponse // incremental
		want *tailcfg.MapResponse // what the incremental one should've been mutated to
	}{
		{
			name: "change_one_endpoint",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, Endpoints: eps("10.0.0.1:1111"), Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{
				PeersChanged: nil,
				PeersChangedPatch: []*tailcfg.PeerChange{
					{NodeID: 1, Endpoints: eps("10.0.0.1:1111")},
				},
			},
		},
		{
			name: "change_some",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, HomeDERP: 1, Hostinfo: hi},
					{ID: 2, HomeDERP: 2, Hostinfo: hi},
					{ID: 3, HomeDERP: 3, Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, HomeDERP: 11, Hostinfo: hi},
					{ID: 2, StableID: "other-change", Hostinfo: hi},
					{ID: 3, HomeDERP: 33, Hostinfo: hi},
					{ID: 4, HomeDERP: 4, Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 2, StableID: "other-change", Hostinfo: hi},
					{ID: 4, HomeDERP: 4, Hostinfo: hi},
				},
				PeersChangedPatch: []*tailcfg.PeerChange{
					{NodeID: 1, DERPRegion: 11},
					{NodeID: 3, DERPRegion: 33},
				},
			},
		},
		{
			name: "change_exitnodednsresolvers",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns2.exmaple.com"}}, Hostinfo: hi},
				},
			},
		},
		{
			name: "same_exitnoderesolvers",
			mr0: &tailcfg.MapResponse{
				Node: &tailcfg.Node{Name: "foo.bar.ts.net."},
				Peers: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
				},
			},
			mr1: &tailcfg.MapResponse{
				PeersChanged: []*tailcfg.Node{
					{ID: 1, ExitNodeDNSResolvers: []*dnstype.Resolver{{Addr: "dns.exmaple.com"}}, Hostinfo: hi},
				},
			},
			want: &tailcfg.MapResponse{},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			nu := &countingNetmapUpdater{}
			ms := newTestMapSession(t, nu)
			ms.updateStateFromResponse(tt.mr0)
			mr1 := new(tailcfg.MapResponse)
			must.Do(json.Unmarshal(must.Get(json.Marshal(tt.mr1)), mr1))
			ms.patchifyPeersChanged(mr1)
			opts := []cmp.Option{
				cmp.Comparer(func(a, b netip.AddrPort) bool { return a == b }),
			}
			if diff := cmp.Diff(tt.want, mr1, opts...); diff != "" {
				t.Errorf("wrong result (-want +got):\n%s", diff)
			}
		})
	}
}

func TestUpgradeNode(t *testing.T) {
	a1 := netip.MustParsePrefix("0.0.0.1/32")
	a2 := netip.MustParsePrefix("0.0.0.2/32")
	a3 := netip.MustParsePrefix("0.0.0.3/32")
	a4 := netip.MustParsePrefix("0.0.0.4/32")

	tests := []struct {
		name string
		in   *tailcfg.Node
		want *tailcfg.Node
		also func(t *testing.T, got *tailcfg.Node) // optional
	}{
		{
			name: "nil",
			in:   nil,
			want: nil,
		},
		{
			name: "empty",
			in:   new(tailcfg.Node),
			want: new(tailcfg.Node),
		},
		{
			name: "derp-both",
			in:   &tailcfg.Node{HomeDERP: 1, LegacyDERPString: tailcfg.DerpMagicIP + ":2"},
			want: &tailcfg.Node{HomeDERP: 1},
		},
		{
			name: "derp-str-only",
			in:   &tailcfg.Node{LegacyDERPString: tailcfg.DerpMagicIP + ":2"},
			want: &tailcfg.Node{HomeDERP: 2},
		},
		{
			name: "derp-int-only",
			in:   &tailcfg.Node{HomeDERP: 2},
			want: &tailcfg.Node{HomeDERP: 2},
		},
		{
			name: "implicit-allowed-ips-all-set",
			in:   &tailcfg.Node{Addresses: []netip.Prefix{a1, a2}, AllowedIPs: []netip.Prefix{a3, a4}},
			want: &tailcfg.Node{Addresses: []netip.Prefix{a1, a2}, AllowedIPs: []netip.Prefix{a3, a4}},
		},
		{
			name: "implicit-allowed-ips-only-address-set",
			in:   &tailcfg.Node{Addresses: []netip.Prefix{a1, a2}},
			want: &tailcfg.Node{Addresses: []netip.Prefix{a1, a2}, AllowedIPs: []netip.Prefix{a1, a2}},
			also: func(t *testing.T, got *tailcfg.Node) {
				if t.Failed() {
					return
				}
				if &got.Addresses[0] == &got.AllowedIPs[0] {
					t.Error("Addresses and AllowIPs alias the same memory")
				}
			},
		},
		{
			name: "implicit-allowed-ips-set-empty-slice",
			in:   &tailcfg.Node{Addresses: []netip.Prefix{a1, a2}, AllowedIPs: []netip.Prefix{}},
			want: &tailcfg.Node{Addresses: []netip.Prefix{a1, a2}, AllowedIPs: []netip.Prefix{}},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var got *tailcfg.Node
			if tt.in != nil {
				got = new(*tt.in) // shallow clone
			}
			upgradeNode(got)
			if diff := cmp.Diff(tt.want, got); diff != "" {
				t.Errorf("wrong result (-want +got):\n%s", diff)
			}
			if tt.also != nil {
				tt.also(t, got)
			}
		})
	}
}

func BenchmarkMapSessionDelta(b *testing.B) {
	for _, size := range []int{10, 100, 1_000, 10_000} {
		b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
			ctx := context.Background()
			nu := &countingNetmapUpdater{}
			ms := newTestMapSession(b, nu)
			// Disable log output for benchmarks to avoid races
			ms.logf = func(string, ...any) {}
			res := &tailcfg.MapResponse{
				Node: &tailcfg.Node{
					ID:   1,
					Name: "foo.bar.ts.net.",
				},
			}
			for i := range size {
				res.Peers = append(res.Peers, &tailcfg.Node{
					ID:         tailcfg.NodeID(i + 2),
					Name:       fmt.Sprintf("peer%d.bar.ts.net.", i),
					HomeDERP:   10,
					Addresses:  []netip.Prefix{netip.MustParsePrefix("100.100.2.3/32"), netip.MustParsePrefix("fd7a:115c:a1e0::123/128")},
					AllowedIPs: []netip.Prefix{netip.MustParsePrefix("100.100.2.3/32"), netip.MustParsePrefix("fd7a:115c:a1e0::123/128")},
					Endpoints:  eps("192.168.1.2:345", "192.168.1.3:678"),
					Hostinfo: (&tailcfg.Hostinfo{
						OS:       "fooOS",
						Hostname: "MyHostname",
						Services: []tailcfg.Service{
							{Proto: "peerapi4", Port: 1234},
							{Proto: "peerapi6", Port: 1234},
							{Proto: "peerapi-dns-proxy", Port: 1},
						},
					}).View(),
					LastSeen: new(time.Unix(int64(i), 0)),
				})
			}
			ms.HandleNonKeepAliveMapResponse(ctx, res)

			b.ResetTimer()
			b.ReportAllocs()

			// Now for the core of the benchmark loop, just toggle
			// a single node's online status.
			for i := range b.N {
				if err := ms.HandleNonKeepAliveMapResponse(ctx, &tailcfg.MapResponse{
					OnlineChange: map[tailcfg.NodeID]bool{
						2: i%2 == 0,
					},
				}); err != nil {
					b.Fatal(err)
				}
			}
		})
	}
}

// TestNetmapDisplayMessage checks that the various diff operations
// (add/update/delete/clear) for [tailcfg.DisplayMessage] in a
// [tailcfg.MapResponse] work as expected.
func TestNetmapDisplayMessage(t *testing.T) {
	type test struct {
		name         string
		initialState *tailcfg.MapResponse
		mapResponse  tailcfg.MapResponse
		wantMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
	}

	tests := []test{
		{
			name: "basic-set",
			mapResponse: tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"test-message": {
						Title:               "Testing",
						Text:                "This is a test message",
						Severity:            tailcfg.SeverityHigh,
						ImpactsConnectivity: true,
						PrimaryAction: &tailcfg.DisplayMessageAction{
							URL:   "https://www.example.com",
							Label: "Learn more",
						},
					},
				},
			},
			wantMessages: map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
				"test-message": {
					Title:               "Testing",
					Text:                "This is a test message",
					Severity:            tailcfg.SeverityHigh,
					ImpactsConnectivity: true,
					PrimaryAction: &tailcfg.DisplayMessageAction{
						URL:   "https://www.example.com",
						Label: "Learn more",
					},
				},
			},
		},
		{
			name: "delete-one",
			initialState: &tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-a": {
						Title: "Message A",
					},
					"message-b": {
						Title: "Message B",
					},
				},
			},
			mapResponse: tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-a": nil,
				},
			},
			wantMessages: map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
				"message-b": {
					Title: "Message B",
				},
			},
		},
		{
			name: "update-one",
			initialState: &tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-a": {
						Title: "Message A",
					},
					"message-b": {
						Title: "Message B",
					},
				},
			},
			mapResponse: tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-a": {
						Title: "Message A updated",
					},
				},
			},
			wantMessages: map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
				"message-a": {
					Title: "Message A updated",
				},
				"message-b": {
					Title: "Message B",
				},
			},
		},
		{
			name: "add-one",
			initialState: &tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-a": {
						Title: "Message A",
					},
				},
			},
			mapResponse: tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-b": {
						Title: "Message B",
					},
				},
			},
			wantMessages: map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
				"message-a": {
					Title: "Message A",
				},
				"message-b": {
					Title: "Message B",
				},
			},
		},
		{
			name: "delete-all",
			initialState: &tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-a": {
						Title: "Message A",
					},
					"message-b": {
						Title: "Message B",
					},
				},
			},
			mapResponse: tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"*": nil,
				},
			},
			wantMessages: map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{},
		},
		{
			name: "delete-all-and-add",
			initialState: &tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"message-a": {
						Title: "Message A",
					},
					"message-b": {
						Title: "Message B",
					},
				},
			},
			mapResponse: tailcfg.MapResponse{
				DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
					"*": nil,
					"message-c": {
						Title: "Message C",
					},
				},
			},
			wantMessages: map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
				"message-c": {
					Title: "Message C",
				},
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			ms := newTestMapSession(t, nil)

			if test.initialState != nil {
				ms.netmapForResponse(test.initialState)
			}

			nm := ms.netmapForResponse(&test.mapResponse)

			if diff := cmp.Diff(test.wantMessages, nm.DisplayMessages, cmpopts.EquateEmpty()); diff != "" {
				t.Errorf("unexpected warnings (-want +got):\n%s", diff)
			}
		})
	}
}

// TestNetmapHealthIntegration checks that we get the expected health warnings
// from processing a [tailcfg.MapResponse] containing health messages and passing the
// [netmap.NetworkMap] to a [health.Tracker].
func TestNetmapHealthIntegration(t *testing.T) {
	ms := newTestMapSession(t, nil)
	ht := health.NewTracker(eventbustest.NewBus(t))

	ht.SetIPNState("NeedsLogin", true)
	ht.GotStreamedMapResponse()

	nm := ms.netmapForResponse(&tailcfg.MapResponse{
		Health: []string{
			"Test message",
			"Another message",
		},
	})
	ht.SetControlHealth(nm.DisplayMessages)

	want := map[health.WarnableCode]health.UnhealthyState{
		"control-health.health-c0719e9a8d5d838d861dc6f675c899d2b309a3a65bb9fe6b11e5afcbf9a2c0b1": {
			WarnableCode: "control-health.health-c0719e9a8d5d838d861dc6f675c899d2b309a3a65bb9fe6b11e5afcbf9a2c0b1",
			Title:        "Coordination server reports an issue",
			Severity:     health.SeverityMedium,
			Text:         "The coordination server is reporting a health issue: Test message",
		},
		"control-health.health-1dc7017a73a3c55c0d6a8423e3813c7ab6562d9d3064c2ec6ac7822f61b1db9c": {
			WarnableCode: "control-health.health-1dc7017a73a3c55c0d6a8423e3813c7ab6562d9d3064c2ec6ac7822f61b1db9c",
			Title:        "Coordination server reports an issue",
			Severity:     health.SeverityMedium,
			Text:         "The coordination server is reporting a health issue: Another message",
		},
	}

	got := maps.Clone(ht.CurrentState().Warnings)
	for k := range got {
		if !strings.HasPrefix(string(k), "control-health") {
			delete(got, k)
		}
	}

	if d := cmp.Diff(want, got, cmpopts.IgnoreFields(health.UnhealthyState{}, "ETag")); d != "" {
		t.Fatalf("CurrentStatus().Warnings[\"control-health*\"] different than expected (-want +got)\n%s", d)
	}
}

// TestNetmapDisplayMessageIntegration checks that we get the expected health
// warnings from processing a [tailcfg.MapResponse] that contains DisplayMessages and
// passing the [netmap.NetworkMap] to a [health.Tracker].
func TestNetmapDisplayMessageIntegration(t *testing.T) {
	ms := newTestMapSession(t, nil)
	ht := health.NewTracker(eventbustest.NewBus(t))

	ht.SetIPNState("NeedsLogin", true)
	ht.GotStreamedMapResponse()
	baseWarnings := ht.CurrentState().Warnings

	nm := ms.netmapForResponse(&tailcfg.MapResponse{
		DisplayMessages: map[tailcfg.DisplayMessageID]*tailcfg.DisplayMessage{
			"test-message": {
				Title:               "Testing",
				Text:                "This is a test message",
				Severity:            tailcfg.SeverityHigh,
				ImpactsConnectivity: true,
				PrimaryAction: &tailcfg.DisplayMessageAction{
					URL:   "https://www.example.com",
					Label: "Learn more",
				},
			},
		},
	})
	ht.SetControlHealth(nm.DisplayMessages)

	state := ht.CurrentState()

	// Ignore warnings that aren't from the netmap
	for k := range baseWarnings {
		delete(state.Warnings, k)
	}

	want := map[health.WarnableCode]health.UnhealthyState{
		"control-health.test-message": {
			WarnableCode:        "control-health.test-message",
			Title:               "Testing",
			Text:                "This is a test message",
			Severity:            health.SeverityHigh,
			ImpactsConnectivity: true,
			PrimaryAction: &health.UnhealthyStateAction{
				URL:   "https://www.example.com",
				Label: "Learn more",
			},
		},
	}

	if diff := cmp.Diff(want, state.Warnings, cmpopts.IgnoreFields(health.UnhealthyState{}, "ETag")); diff != "" {
		t.Errorf("unexpected message contents (-want +got):\n%s", diff)
	}
}

func TestNetmapForMapResponseForDebug(t *testing.T) {
	mr := &tailcfg.MapResponse{
		Node: &tailcfg.Node{
			ID:   1,
			Name: "foo.bar.ts.net.",
		},
		Peers: []*tailcfg.Node{
			{ID: 2, Name: "peer1.bar.ts.net.", HomeDERP: 1},
			{ID: 3, Name: "peer2.bar.ts.net.", HomeDERP: 1},
		},
	}
	ms := newTestMapSession(t, nil)
	nm1 := ms.netmapForResponse(mr)

	prefs := &ipn.Prefs{Persist: &persist.Persist{PrivateNodeKey: ms.privateNodeKey}}
	nm2, err := NetmapFromMapResponseForDebug(t.Context(), prefs.View().Persist(), mr)
	if err != nil {
		t.Fatal(err)
	}
	if !reflect.DeepEqual(nm1, nm2) {
		t.Errorf("mismatch\nnm1: %s\nnm2: %s\n", logger.AsJSON(nm1), logger.AsJSON(nm2))
	}
}

func TestLearnZstdOfKeepAlive(t *testing.T) {
	keepAliveMsgZstd := (func() []byte {
		msg := must.Get(json.Marshal(tailcfg.MapResponse{
			KeepAlive: true,
		}))
		return zstdframe.AppendEncode(nil, msg, zstdframe.FastestCompression)
	})()

	sess := newTestMapSession(t, nil)

	// The first time we see a zstd keep-alive message, we learn how
	// the server encodes that.
	var mr tailcfg.MapResponse
	must.Do(sess.decodeMsg(keepAliveMsgZstd, &mr))
	if !mr.KeepAlive {
		t.Fatal("mr.KeepAlive false; want true")
	}
	if !bytes.Equal(sess.keepAliveZ, keepAliveMsgZstd) {
		t.Fatalf("sess.keepAlive = %q; want %q", sess.keepAliveZ, keepAliveMsgZstd)
	}
	if got, want := sess.ztdDecodesForTest, 1; got != want {
		t.Fatalf("got %d zstd decodes; want %d", got, want)
	}

	// The second time on the session where we see that message, we
	// decode it without needing to decompress.
	var mr2 tailcfg.MapResponse
	must.Do(sess.decodeMsg(keepAliveMsgZstd, &mr2))
	if !mr2.KeepAlive {
		t.Fatal("mr2.KeepAlive false; want true")
	}
	if got, want := sess.ztdDecodesForTest, 1; got != want {
		t.Fatalf("got %d zstd decodes; want %d", got, want)
	}
}

func TestPathDiscokeyerImplementations(t *testing.T) {
	bus := eventbustest.NewBus(t)
	ht := health.NewTracker(bus)
	reg := new(usermetric.Registry)
	e, err := wgengine.NewFakeUserspaceEngine(t.Logf, 0, ht, reg, bus)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(e.Close)
	if _, ok := e.(patchDiscoKeyer); !ok {
		t.Error("wgengine.userspaceEngine must implement patchDiscoKeyer")
	}
}

func TestPeerIDAndKeyByTailscaleIP(t *testing.T) {
	peerKey1 := key.NewNode().Public()
	peerKey2 := key.NewNode().Public()

	peer1 := &tailcfg.Node{
		ID:        1,
		Key:       peerKey1,
		Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
	}
	peer2 := &tailcfg.Node{
		ID:  2,
		Key: peerKey2,
		Addresses: []netip.Prefix{
			netip.MustParsePrefix("100.64.0.2/32"),
			netip.MustParsePrefix("fd7a:115c::2/128"),
		},
	}

	ms := newTestMapSession(t, nil)
	ms.updateStateFromResponse(&tailcfg.MapResponse{
		Node:  new(tailcfg.Node),
		Peers: []*tailcfg.Node{peer1, peer2},
	})

	t.Run("known_ip_peer1", func(t *testing.T) {
		gotID, gotKey, ok := ms.PeerIDAndKeyByTailscaleIP(netip.MustParseAddr("100.64.0.1"))
		if !ok {
			t.Fatal("PeerIDAndKeyByTailscaleIP returned ok=false, want true")
		}
		if gotID != peer1.ID {
			t.Errorf("NodeID = %v, want %v", gotID, peer1.ID)
		}
		if gotKey != peerKey1 {
			t.Errorf("NodePublic = %v, want %v", gotKey, peerKey1)
		}
	})

	t.Run("known_ip_peer2_v6", func(t *testing.T) {
		gotID, gotKey, ok := ms.PeerIDAndKeyByTailscaleIP(netip.MustParseAddr("fd7a:115c::2"))
		if !ok {
			t.Fatal("PeerIDAndKeyByTailscaleIP returned ok=false, want true")
		}
		if gotID != peer2.ID {
			t.Errorf("NodeID = %v, want %v", gotID, peer2.ID)
		}
		if gotKey != peerKey2 {
			t.Errorf("NodePublic = %v, want %v", gotKey, peerKey2)
		}
	})

	t.Run("unknown_ip", func(t *testing.T) {
		gotID, gotKey, ok := ms.PeerIDAndKeyByTailscaleIP(netip.MustParseAddr("100.64.0.99"))
		if ok {
			t.Errorf("PeerIDAndKeyByTailscaleIP returned ok=true for unknown IP, got id=%v key=%v", gotID, gotKey)
		}
	})
}

func TestRemoveUnwantedDiscoUpdates(t *testing.T) {
	tests := []struct {
		name           string
		viaTSMP        bool
		existingOnline bool
		sameKey        bool
		newerLastSeen  bool
		wantAccepted   bool
	}{
		{
			name:           "tsmp_online_peer_same_key",
			viaTSMP:        true,
			existingOnline: true,
			sameKey:        true,
			newerLastSeen:  true,
			wantAccepted:   false,
		},
		{
			name:           "not_tsmp_online_peer_same_key",
			viaTSMP:        false,
			existingOnline: true,
			sameKey:        true,
			newerLastSeen:  true,
			wantAccepted:   true,
		},
		{
			name:           "tsmp_offline_peer_same_key",
			viaTSMP:        true,
			existingOnline: false,
			sameKey:        true,
			newerLastSeen:  true,
			wantAccepted:   true,
		},
		{
			name:           "tsmp_online_peer_diff_key",
			viaTSMP:        true,
			existingOnline: true,
			sameKey:        false,
			newerLastSeen:  true,
			wantAccepted:   true,
		},
		{
			name:           "tsmp_online_peer_same_key_old_lastseen",
			viaTSMP:        true,
			existingOnline: true,
			sameKey:        true,
			newerLastSeen:  false,
			wantAccepted:   false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ms := newTestMapSession(t, &rememberLastNetmapUpdater{done: make(chan any, 1)})

			existingKey := key.NewDisco().Public()
			existingOnline := tt.existingOnline
			initialLastSeen := time.Unix(1, 0)

			ms.updateStateFromResponse(&tailcfg.MapResponse{
				Peers: []*tailcfg.Node{{
					ID:       1,
					Key:      key.NewNode().Public(),
					DiscoKey: existingKey,
					Online:   &existingOnline,
					LastSeen: &initialLastSeen,
				}},
			})

			changeKey := existingKey
			if !tt.sameKey {
				changeKey = key.NewDisco().Public()
			}
			changeOnline := false // must be false to reach the new guard
			updateLastSeen := time.Unix(2, 0)
			if !tt.newerLastSeen {
				updateLastSeen = time.Unix(0, 0)
			}

			resp := &tailcfg.MapResponse{
				PeersChangedPatch: []*tailcfg.PeerChange{{
					NodeID:   1,
					DiscoKey: &changeKey,
					Online:   &changeOnline,
					LastSeen: &updateLastSeen,
				}},
			}

			ms.removeUnwantedDiscoUpdates(resp, tt.viaTSMP)

			got := len(resp.PeersChangedPatch) > 0
			if got != tt.wantAccepted {
				t.Errorf("accepted=%v, want %v", got, tt.wantAccepted)
			}
		})
	}
}
