diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index 73a0680cc..89176919a 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -66,6 +66,13 @@ var viaCompatTests = []struct { {"via-grant-v62", "via exit steering plus a member rule to autogroup:internet on one port"}, } +// viaCompatSkipReasons lists checks where headscale differs from SaaS, +// keyed by check alone where every capture differs the same way. A listed +// check that matches fails, so a fix must delete its entry. +var viaCompatSkipReasons = map[string]string{ + "tagged-self": "TAGGED_SELF_PROFILE: SaaS gives a tagged node a profile of its own, headscale the shared tagged-devices one", +} + // TestViaGrantMapCompat loads golden captures from Tailscale SaaS and // compares headscale's [tailcfg.MapResponse] structure against the captured [netmap.NetworkMap]. // @@ -476,6 +483,75 @@ func compareNetmap( } comparePacketFilter(t, got, want, clients, saasAddrs) + compareUserProfiles(t, got, want.Netmap, clients) +} + +// compareUserProfiles compares the profile the viewer holds for itself and +// for each peer in clients. Profile IDs differ between SaaS and headscale, so +// a profile is its login, with SaaS emails mapped to user names, and its +// display name. +func compareUserProfiles( + t require.TestingT, + got, want *netmap.NetworkMap, + clients map[string]*servertest.TestClient, +) { + if h, ok := t.(interface{ Helper() }); ok { + h.Helper() + } + + gotProfiles := nodeProfiles(got, clients) + + for name, wantProfile := range nodeProfiles(want, clients) { + check := "peer" + if name == "" { + check = "self" + if want.SelfNode.Tags().Len() > 0 { + check = "tagged-self" + } + } + + if reason, listed := viaCompatSkipReasons[check]; listed { + if gotProfiles[name] == wantProfile { + t.Errorf("node %q: %s profile matches SaaS; delete viaCompatSkipReasons[%q] (%s)", + name, check, check, reason) + } + + continue + } + + assert.Equalf(t, wantProfile, gotProfiles[name], "node %q: %s profile mismatch", name, check) + } +} + +// nodeProfiles maps "" (self) and each peer in clients to its user profile. +func nodeProfiles( + nm *netmap.NetworkMap, + clients map[string]*servertest.TestClient, +) map[string]string { + profile := func(id tailcfg.UserID) string { + p, ok := nm.UserProfiles[id] + if !ok { + return "no profile" + } + + login := p.LoginName() + if name, ok := captureUserNames[login]; ok { + login = name + } + + return login + " / " + p.DisplayName() + } + + out := map[string]string{"": profile(nm.SelfNode.User())} + + for _, peer := range nm.Peers { + name := extractHostname(peer.Name()) + if _, isOurs := clients[name]; isOurs { + out[name] = profile(peer.User()) + } + } + + return out } // comparePacketFilter compares PacketFilter rules as a set of (source, diff --git a/hscontrol/servertest/via_ha_compat_test.go b/hscontrol/servertest/via_ha_compat_test.go index 357335564..573eda696 100644 --- a/hscontrol/servertest/via_ha_compat_test.go +++ b/hscontrol/servertest/via_ha_compat_test.go @@ -337,6 +337,8 @@ func compareCaptureNetmap( len(want.PacketFilterRules), gotLen, ) } + + compareUserProfiles(t, nm, want.Netmap, clients) } type capturePeerSummary struct {