policy/v2: compare peer CapMap against SaaS route captures

SaaS stamps suggest-exit-node on approved exit peers without nodeAttrs.

Updates #3415

(cherry picked from commit 0e8a3bba54)
This commit is contained in:
Kristoffer Dalby
2026-09-23 08:45:41 +00:00
committed by Kristoffer Dalby
parent 8490631f06
commit 694bafee0a
@@ -1507,6 +1507,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()