servertest: add user role dispatch golden data

An admin role change under autogroup:admin, mirrored as a user rename;
peers, filter and frames per node per step, and v2 device users.
This commit is contained in:
Kristoffer Dalby committed 2026-10-28 14:31:40 +00:00
1 parent b4e2c0e93f
commit a16a38f527
4 files changed
+7915 -8

No files matched your search

@@ -1,7 +1,10 @@
package servertest_test
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
@@ -13,6 +16,7 @@ import (
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -455,3 +459,90 @@ func apiv2DevicesACLTerraform(t *testing.T, srv *servertest.TestServer, baseURL,
_, ok := srv.State().GetNodeByID(id)
assert.True(t, ok, "data-source node persists across destroy")
}
// TestAPIV2TaggedDeviceUserCompat replays the setup step of the user-role
// capture, where odin and freya each join an untagged and a tag:server node
// with keys they minted, and compares each device's user and tags in
// headscale's v2 device list with SaaS's.
//
// Test data source: testdata/steps/user-role.hujson, loaded by name; its
// role steps are TestUserChangeDispatchCompat's.
func TestAPIV2TaggedDeviceUserCompat(t *testing.T) {
t.Parallel()
t.Run(userRoleID, func(t *testing.T) {
t.Parallel()
c := readStepCapture(t, userRoleID)
r := newStepReplay(t, c)
step := c.Steps[0]
require.Equal(t, userRoleSetup, step.Name)
requireUserRoleResult(t, step)
r.applyUserRole(t, step)
evaluated := map[string]bool{}
holdChecks(t, mapStreamSkipReasons, userRoleID+"/"+step.Name+"/", deviceListChecks(t, r, step), evaluated)
for k := range mapStreamSkipReasons {
if strings.HasPrefix(k, userRoleID+"/") && deviceListCheck(k) && !evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
})
}
// deviceListChecks compares, per device in the step's device list, its user,
// SaaS emails mapped to user names, and its tags.
func deviceListChecks(t *testing.T, r *stepReplay, step testcapture.Step) []stepCheck {
t.Helper()
hsDevice := func(alias string) (saasDevice, string) {
nodes := r.machineNodes(alias)
if len(nodes) != 1 {
return saasDevice{}, fmt.Sprintf("devices %d", len(nodes))
}
status, body := r.call(t, http.MethodGet, "/api/v2/tailnet/-/devices?fields=all", nil)
if status != http.StatusOK {
return saasDevice{}, fmt.Sprintf("status %d", status)
}
var list struct {
Devices []saasDevice `json:"devices"`
}
require.NoError(t, json.Unmarshal(body, &list))
for _, d := range list.Devices {
if d.ID == nodes[0].StringID() {
return d, ""
}
}
return saasDevice{}, "not listed"
}
devices := stepDevices(t, step)
checks := make([]stepCheck, 0, 2*len(devices))
for _, d := range devices {
x := deviceLabelAlias(d.ID)
user, ok := captureUserNames[d.User]
require.True(t, ok, "%s: %s's user %q is not a capture user", step.Name, x, d.User)
checks = append(checks,
stepCheck{x + "/device-user", user, func() string {
got, missing := hsDevice(x)
return cmp.Or(missing, got.User)
}},
stepCheck{x + "/device-tags", sortedStrings(d.Tags), func() string {
got, missing := hsDevice(x)
return cmp.Or(missing, sortedStrings(got.Tags))
}},
)
}
return checks
}
+235 -5
View File
@@ -3,14 +3,17 @@
// home, disco key, hostname or port, when a node goes down and up around
// policy pushes, and which Hostinfo fields peers and the node itself get;
// what a node hears of nodes it cannot see; what a router's peers hear of
// routes it announces but has no approval for; and what every node is sent
// as one policy is compiled again for node changes and a group edit.
// routes it announces but has no approval for; what every node is sent as
// one policy is compiled again for node changes and a group edit; and what
// every node is sent when an admin role change alters what the policy
// resolves.
//
// Test data source: testdata/steps/{map-delivery,hidden-peers,route-announce,
// policy-generation}.hujson, loaded by name. The disco rotation of
// policy-generation,user-role}.hujson, loaded by name. The disco rotation of
// map-delivery also pins a row of TestBuildMapRequestChangeResponse in
// ../state; policy-generation is also read by TestPolicySequenceCompat in
// ../policy/v2.
// ../policy/v2; user-role's setup step is also read by
// TestAPIV2TaggedDeviceUserCompat in apiv2_devices_test.go.
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
package servertest_test
@@ -29,6 +32,7 @@ import (
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
@@ -154,6 +158,13 @@ var mapStreamSkipReasons = map[string]string{
"policy-generation/G5/rt/enabled": withdrawnRouteEnabled,
"policy-generation/G7/rt/enabled": withdrawnRouteEnabled,
"policy-generation/G8/rt/enabled": withdrawnRouteEnabled,
"user-role/S1-reconnect/own-a/frames": reconnectAnnounced,
"user-role/S1-reconnect/own-b/frames": reconnectAnnounced,
"user-role/S2-reconnect/own-a/frames": reconnectAnnounced,
"user-role/S0/tag-a/device-user": taggedDeviceUser,
"user-role/S0/tag-b/device-user": taggedDeviceUser,
}
const (
@@ -168,6 +179,7 @@ const (
removalSentToAll = "REMOVAL_SENT_TO_ALL: headscale tells every connected node that a deleted node is gone; SaaS tells only the nodes that had it as a peer"
announcePolicyResend = "ANNOUNCE_POLICY_RESEND: under an autogroup:self or via policy, headscale sends every connected node a policy response when a router changes only routes nobody approved; SaaS sends nothing to nodes that cannot see the router"
withdrawnRouteEnabled = "WITHDRAWN_ROUTE_ENABLED: headscale's routes API keeps a route the device stopped advertising in enabledRoutes; SaaS lists only advertised routes as enabled"
taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key; headscale shows tagged-devices"
)
// mapStreamNotCompared lists, by key prefix, checks left out: SaaS's answer
@@ -196,6 +208,25 @@ var mapStreamNotCompared = []string{
"policy-generation/G7/rt-plain/filter",
"policy-generation/G7/srv/filter",
"policy-generation/G7/srv/ssh",
"user-role/S1/own-a/frames",
"user-role/S1/own-b/frames",
"user-role/S1/own-b/peers",
"user-role/S1/tag-a/frames",
"user-role/S1/tag-a/peers",
"user-role/S1/tag-a/filter",
"user-role/S1/tag-b/frames",
"user-role/S1/tag-b/peers",
"user-role/S1/tag-b/filter",
"user-role/S1-reconnect/own-b/peers",
"user-role/S1-reconnect/tag-b/peers",
"user-role/S1-reconnect/tag-b/filter",
"user-role/S2/own-a/frames",
"user-role/S2/own-b/frames",
"user-role/S2/tag-a/frames",
"user-role/S2/tag-a/peers",
"user-role/S2/tag-a/filter",
"user-role/S2/tag-b/frames",
}
// compared drops the checks mapStreamNotCompared leaves out.
@@ -955,7 +986,7 @@ func TestRouteAnnouncementCompat(t *testing.T) {
func TestMapStreamSkipEntriesNameCaptures(t *testing.T) {
t.Parallel()
requireSkipCaptures(t, mapStreamSkipReasons, "map-delivery", "hidden-peers", "route-announce", "policy-generation")
requireSkipCaptures(t, mapStreamSkipReasons, "map-delivery", "hidden-peers", "route-announce", "policy-generation", userRoleID)
}
// visibilityMark is what a client had been sent when a step began.
@@ -1343,3 +1374,202 @@ func peerOwners(nm *netmap.NetworkMap, alias func(tailcfg.NodeID) (string, bool)
return strings.Join(out, "; ")
}
// userRoleID is the capture of an admin role change that
// [TestUserChangeDispatchCompat] and [TestAPIV2TaggedDeviceUserCompat] replay.
const userRoleID = "user-role"
// userRoleSetup is the step that pushes the policy and joins the nodes; its
// frames are join traffic, not the role change's.
const userRoleSetup = "S0"
// adminSuffix marks a user's name while the replay stands in for its admin
// role; see [stepReplay.applyUserRole].
const adminSuffix = "-admin"
// TestUserChangeDispatchCompat replays an admin role change under a fixed
// policy whose autogroup:admin rule reaches two tag:server nodes: freya
// raised to admin, one tagged node's stream restarted, freya back to member
// and that stream restarted again. After each step it compares, per node,
// its peers and filter and, past the setup step, whether it was sent
// anything.
func TestUserChangeDispatchCompat(t *testing.T) {
t.Parallel()
t.Run(userRoleID, func(t *testing.T) {
t.Parallel()
c := readStepCapture(t, userRoleID)
r := newStepReplay(t, c)
saasNames := topologyAddrNames(c)
evaluated := map[string]bool{}
for _, step := range c.Steps {
requireUserRoleResult(t, step)
r.settle(t)
updates := make(map[string]int, len(r.clients))
for alias, cl := range r.clients {
updates[alias] = cl.UpdateCount()
}
r.applyUserRole(t, step)
t.Run(step.Name, func(t *testing.T) {
prefix := userRoleID + "/" + step.Name + "/"
holdChecks(t, mapStreamSkipReasons, prefix,
compared(prefix, userRoleChecks(t, r, step, saasNames, updates)), evaluated)
})
}
for k := range mapStreamSkipReasons {
if strings.HasPrefix(k, userRoleID+"/") && !deviceListCheck(k) && !evaluated[k] {
t.Errorf("skip entry %q names no check", k)
}
}
})
}
// requireUserRoleResult admits observed steps and barrier timeouts. The
// probe's barrier waits for the role change to reach the streams; a timeout
// whose note lists the links that never held is that observation, and its
// captures and frames are what SaaS showed by the barrier's end.
func requireUserRoleResult(t *testing.T, step testcapture.Step) {
t.Helper()
if step.Result == "timeout" {
require.True(t, strings.HasPrefix(step.Note, "never held:"), "%s: timeout without the links that never held: %q", step.Name, step.Note)
return
}
require.Equal(t, "observed", step.Result, "%s: the replay follows every step", step.Name)
}
// applyUserRole applies one user-role step, each action landing before the
// next. Headscale has no user roles, so a name stands in for the admin role:
// autogroup:admin becomes the tailnet owner, whom SaaS counts as an admin,
// plus each user's name with adminSuffix, and a role call renames its user
// to or from that name through the v1 API. Like the role call, the rename is
// a user change the policy resolves while its text stays the same, sent by
// headscale's own admin path. Roles held before the capture's first role
// call are not mirrored, so the setup step's checks fail if one decides
// what a node reaches.
func (r *stepReplay) applyUserRole(t *testing.T, step testcapture.Step) {
t.Helper()
for _, a := range step.Actions {
switch a.Kind {
case "policy":
require.Equal(t, http.StatusOK, a.Status, "%s: replay of a refused policy", step.Name)
r.setPolicy(t, step.Name, adminPolicy(r.c.Tailnet, string(a.Body)))
case "role":
r.roleRename(t, step.Name, a)
default:
one := step
one.Actions = []testcapture.Action{a}
require.Empty(t, r.apply(t, one), "%s: every login succeeds", step.Name)
r.waitSession(t, step.Name, a)
}
}
}
// adminPolicy replaces autogroup:admin in policy with the owner and the
// names [stepReplay.roleRename] gives admins.
func adminPolicy(owner, policy string) string {
admins := make([]string, 0, len(captureUserNames)+1)
admins = append(admins, strconv.Quote(owner))
for _, name := range slices.Sorted(maps.Values(captureUserNames)) {
admins = append(admins, strconv.Quote(name+adminSuffix+"@"))
}
return strings.ReplaceAll(policy, strconv.Quote("autogroup:admin"), strings.Join(admins, ", "))
}
// roleRename mirrors a role call on "/users/{user:x}/role": admin renames x
// to x with adminSuffix, member back to x.
func (r *stepReplay) roleRename(t *testing.T, stepName string, a testcapture.Action) {
t.Helper()
require.Equal(t, http.StatusOK, a.Status, "%s: replay of a refused role call", stepName)
rest, ok := strings.CutPrefix(a.Path, "/users/{user:")
alias, _, found := strings.Cut(rest, "}")
require.True(t, ok && found, "%s: no user label in %q", stepName, a.Path)
user, ok := r.users[alias]
require.True(t, ok, "%s: role of unknown user %s", stepName, alias)
var body struct {
Role string `json:"role"`
}
require.NoError(t, json.Unmarshal(a.Body, &body), "%s: role body", stepName)
name := alias
switch body.Role {
case "admin":
name += adminSuffix
case "member":
default:
t.Fatalf("%s: no replay for role %q", stepName, body.Role)
}
current, err := r.srv.State().GetUserByID(types.UserID(user.ID))
require.NoError(t, err)
if current.Name == name {
return
}
status, resp := r.call(t, http.MethodPost, fmt.Sprintf("/api/v1/user/%d/rename/%s", user.ID, name), nil)
require.Equal(t, http.StatusOK, status, "%s: rename %s: %s", stepName, alias, resp)
}
// userRoleChecks compares, per node SaaS captured, its peers and filter and,
// past the setup step, whether it was sent anything but a keep-alive since
// updates, each client's update count when the step began.
func userRoleChecks(
t *testing.T,
r *stepReplay,
step testcapture.Step,
saasNames map[netip.Addr]string,
updates map[string]int,
) []stepCheck {
t.Helper()
var checks []stepCheck
for _, x := range slices.Sorted(maps.Keys(step.Captures)) {
saas, cl := step.Captures[x].Netmap, r.clients[x]
require.NotNil(t, saas, "%s: no netmap of %s", step.Name, x)
require.NotNil(t, cl, "%s: %s never joined", step.Name, x)
if step.Name != userRoleSetup {
checks = append(checks, stepCheck{x + "/frames", saasDelivered(step.Frames[x]), func() string {
if cl.UpdateCount() > updates[x] {
return "frame"
}
return "silent"
}})
}
checks = append(checks,
stepCheck{x + "/peers", peerAliases(saas, r.saasAlias), func() string { return peerAliases(cl.Netmap(), r.hsAlias) }},
stepCheck{x + "/filter", renderMatches(saas, saasNames), func() string { return renderMatches(cl.Netmap(), r.addrNames()) }},
)
}
return checks
}
// deviceListCheck reports whether a user-role skip key names one of
// [TestAPIV2TaggedDeviceUserCompat]'s checks.
func deviceListCheck(key string) bool {
return strings.HasSuffix(key, "/device-user") || strings.HasSuffix(key, "/device-tags")
}
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -879,7 +879,7 @@ func (r *stepReplay) apply(t *testing.T, step testcapture.Step) map[string]error
}
case "restart", "start", "kill", "shutdown", "logout", "advertise", "set_hostname",
"break_tcp_conns", "rotate_disco_key", "cli", "disconnect_control":
"break_tcp_conns", "rotate_disco_key", "cli", "disconnect_control", "down", "up":
r.daemon(ctx, t, step.Name, a)
case "retire":
@@ -957,10 +957,10 @@ func (r *stepReplay) daemon(ctx context.Context, t *testing.T, stepName string,
r.hostinfo[a.Node].Hostname = strings.TrimPrefix(args, "set --hostname=")
r.sendHostinfo(ctx, t, a.Node)
case a.Kind == "cli" && args == "down":
case a.Kind == "down", a.Kind == "cli" && args == "down":
cl.Disconnect(t)
case a.Kind == "cli" && args == "up":
case a.Kind == "up", a.Kind == "cli" && args == "up":
// tailscaled registers again on its node key, then polls.
require.NoError(t, cl.ReloginAndPoll(ctx), "%s: up", stepName)