diff --git a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go index 4538e65c..c0d94000 100644 --- a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go @@ -1506,6 +1506,76 @@ func TestRoutesCompatPeerAllowedIPs(t *testing.T) { ) } +// TestRoutesCompatPeerCapMap validates the peer-view CapMap against SaaS +// for route scenarios, whose policies carry no nodeAttrs. SaaS stamps +// suggest-exit-node on approved exit peers regardless of policy; the +// Apple 1.102+ GUI hides the exit-node list without it (issue #3415). +func TestRoutesCompatPeerCapMap(t *testing.T) { + t.Parallel() + + files, err := filepath.Glob( + filepath.Join("testdata", "routes_results", "routes-*.hujson"), + ) + require.NoError(t, err, "failed to glob test files") + require.NotEmpty(t, files) + + issue3212, err := filepath.Glob( + filepath.Join("testdata", "issue_3212", "routes-*.hujson"), + ) + require.NoError(t, err, "failed to glob issue_3212 files") + require.NotEmpty(t, issue3212) + + files = append(files, issue3212...) + + for _, file := range files { + tf := loadRoutesTestFile(t, file) + if tf.Error { + continue + } + + t.Run(tf.TestID, func(t *testing.T) { + t.Parallel() + + users, nodes := buildRoutesUsersAndNodes(t, tf.Topology) + policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy) + + pm, err := NewPolicyManager(policyJSON, users, nodes.ViewSlice()) + require.NoError(t, err, "%s: failed to create policy manager", tf.TestID) + + capMaps := pm.NodeCapMaps() + + for viewerName, capture := range tf.Captures { + if capture.Netmap == nil { + continue + } + + for _, nmPeer := range capture.Netmap.Peers { + peerName, _, _ := strings.Cut(nmPeer.Name(), ".") + + peer := findNodeByGivenName(nodes, peerName) + if peer == nil { + continue + } + + got := stripUnmodelledTailnetStateCaps( + PeerCapMap(peer.View(), capMaps[peer.ID]), + ) + want := stripUnmodelledTailnetStateCaps( + capMapFromView(nmPeer.CapMap()), + ) + + if diff := cmp.Diff(want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf( + "%s/%s/peer=%s: Peer.CapMap mismatch (-tailscale +headscale):\n%s", + tf.TestID, viewerName, peerName, diff, + ) + } + } + } + }) + } +} + // testRoutesError verifies that an invalid policy produces the expected error. func testRoutesError(t *testing.T, tf *testcapture.Capture) { t.Helper()