compat tests: build node topology per-scenario, not globally

tscap uses clean-slate mode: each scenario wipes every device from the
tailnet before logging new ones in, so node IPs change from scenario to
scenario. The previous compat tests built a single nodes slice from the
first file's topology (or from a hardcoded setup) and used it for every
scenario, which produced IP mismatches in the filter rule comparisons.

Rebuild types.Nodes per scenario from the current file's Topology. ACL
already had a build-from-topology helper; extract the equivalent for
grants (buildGrantsNodesFromCapture) and use it for SSH too. Drop the
"first 8 nodes only" shim in grants.

Pass count jumped ~2500 tests across the four compat suites.

Updates #3157
Updates #3169
This commit is contained in:
Kristoffer Dalby
2026-04-08 11:35:11 +00:00
parent b7da0c403c
commit 4005a097e9
3 changed files with 81 additions and 31 deletions
@@ -313,23 +313,6 @@ func TestACLCompat(t *testing.T) {
t.Logf("Loaded %d ACL test files", len(files))
// Build nodes from the first non-error file's topology.
// All files share the same 19-node tailnet topology.
var users types.Users
var nodes types.Nodes
for _, file := range files {
tf := loadACLTestFile(t, file)
if !tf.Error && len(tf.Topology.Nodes) > 0 {
users, nodes = buildACLUsersAndNodes(t, tf)
break
}
}
require.NotEmpty(t, nodes, "no non-error ACL file found")
for _, file := range files {
tf := loadACLTestFile(t, file)
@@ -352,6 +335,13 @@ func TestACLCompat(t *testing.T) {
return
}
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// different node IPs; using a shared topology would
// cause IP mismatches in filter rule comparisons.
users, nodes := buildACLUsersAndNodes(t, tf)
require.NotEmpty(t, nodes, "%s: topology is empty", tf.TestID)
testACLSuccess(t, tf, users, nodes)
})
}
@@ -34,7 +34,6 @@ import (
"tailscale.com/tailcfg"
)
// setupGrantsCompatUsers returns the 3 test users for grants compatibility tests.
// Users get norse-god names; nodes get original-151 pokémon names — matching
// the anonymized identifiers tscap writes into the capture files
@@ -270,6 +269,69 @@ func findGrantsNode(nodes types.Nodes, name string) *types.Node {
return nil
}
// buildGrantsNodesFromCapture constructs types.Nodes from a capture's
// topology section. Each scenario in tscap uses clean-slate mode, so
// node IPs differ between scenarios; this builds the node set with
// the IPs that were actually present during that capture.
func buildGrantsNodesFromCapture(
users types.Users,
tf *testcapture.Capture,
) types.Nodes {
nodes := make(types.Nodes, 0, len(tf.Topology.Nodes))
autoID := 1
for _, nodeDef := range tf.Topology.Nodes {
node := &types.Node{
ID: types.NodeID(autoID), //nolint:gosec
GivenName: nodeDef.Hostname,
IPv4: ptrAddr(nodeDef.IPv4),
IPv6: ptrAddr(nodeDef.IPv6),
Tags: nodeDef.Tags,
}
autoID++
hostinfo := &tailcfg.Hostinfo{}
if len(nodeDef.RoutableIPs) > 0 {
routableIPs := make([]netip.Prefix, 0, len(nodeDef.RoutableIPs))
for _, r := range nodeDef.RoutableIPs {
routableIPs = append(routableIPs, netip.MustParsePrefix(r))
}
hostinfo.RoutableIPs = routableIPs
}
node.Hostinfo = hostinfo
if len(nodeDef.ApprovedRoutes) > 0 {
approved := make([]netip.Prefix, 0, len(nodeDef.ApprovedRoutes))
for _, r := range nodeDef.ApprovedRoutes {
approved = append(approved, netip.MustParsePrefix(r))
}
node.ApprovedRoutes = approved
} else {
node.ApprovedRoutes = []netip.Prefix{}
}
// Assign user — untagged nodes look up by User field.
if len(nodeDef.Tags) == 0 && nodeDef.User != "" {
for i := range users {
if users[i].Name == nodeDef.User {
node.User = &users[i]
node.UserID = &users[i].ID
break
}
}
}
nodes = append(nodes, node)
}
return nodes
}
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
// tscap now anonymizes the policy JSON at write time (kratail2tid -> odin,
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
@@ -328,7 +390,6 @@ func TestGrantsCompat(t *testing.T) {
t.Logf("Loaded %d grant test files", len(files))
users := setupGrantsCompatUsers()
allNodes := setupGrantsCompatNodes(users)
for _, file := range files {
tf := loadGrantTestFile(t, file)
@@ -342,18 +403,13 @@ func TestGrantsCompat(t *testing.T) {
return
}
// Determine which node set to use based on the test's topology.
// Tests captured with the expanded 15-node topology (V26+) have
// nodes like pidgey (exit-a), rattata (group-a-client), etc.
// Tests from the original 8-node topology should only use the
// first 8 nodes to avoid resolving extra IPs from nodes that
// weren't present during capture.
nodes := allNodes
if _, hasNewNodes := tf.Captures["pidgey"]; !hasNewNodes {
nodes = allNodes[:8]
}
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Convert Tailscale user emails to headscale @example.com format
// Use the captured full policy verbatim (anonymization
// in tscap already rewrote SaaS emails).
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
if tf.Input.APIResponseCode == 400 || tf.Error {
@@ -156,7 +156,6 @@ func TestSSHDataCompat(t *testing.T) {
t.Logf("Loaded %d SSH test files", len(files))
users := setupSSHDataCompatUsers()
nodes := setupSSHDataCompatNodes(users)
for _, file := range files {
tf := loadSSHTestFile(t, file)
@@ -180,6 +179,11 @@ func TestSSHDataCompat(t *testing.T) {
return
}
// Build nodes per-scenario from this file's topology.
// tscap uses clean-slate mode, so each scenario has
// different node IPs.
nodes := buildGrantsNodesFromCapture(users, tf)
// Use the captured full policy verbatim. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
policyJSON := tf.Input.FullPolicy