compat tests: switch to anonymized norse-god + pokemon names

tscap now anonymizes captures at write time (see kradalby/tscap
package anonymize): SaaS user identifiers become odin/thor/freya and
node hostnames become original-151 pokemon names. Update the four
policy v2 compat test setups to construct types.User and types.Node
objects with the new names so the in-test topology matches what the
captured testdata files contain.

The convertPolicyUserEmails / convertSaaSEmail helpers become
passthroughs because tscap already produces the final email form.

Updates #3157
Updates #3169
This commit is contained in:
Kristoffer Dalby
2026-04-08 08:21:31 +00:00
parent e30de58627
commit 76c7cb3342
4 changed files with 110 additions and 140 deletions
@@ -39,55 +39,56 @@ func ptrAddr(s string) *netip.Addr {
}
// setupACLCompatUsers returns the 3 test users for ACL compatibility tests.
// Email addresses use @example.com domain, matching the converted Tailscale
// policy format (Tailscale uses @passkey and @dalby.cc).
// Names and emails match the anonymized identifiers tscap writes into the
// capture files (see github.com/kradalby/tscap/anonymize): users get
// norse-god names and nodes get original-151 pokémon names.
func setupACLCompatUsers() types.Users {
return types.Users{
{Model: gorm.Model{ID: 1}, Name: "kratail2tid", Email: "kratail2tid@example.com"},
{Model: gorm.Model{ID: 2}, Name: "kristoffer", Email: "kristoffer@example.com"},
{Model: gorm.Model{ID: 3}, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.com"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
// setupACLCompatNodes returns the 8 test nodes for ACL compatibility tests.
// Uses the same topology as the grants compat tests.
// Node GivenNames match tscap's anonymized pokémon naming.
func setupACLCompatNodes(users types.Users) types.Nodes {
return types.Nodes{
{
ID: 1, GivenName: "user1",
ID: 1, GivenName: "bulbasaur",
User: &users[0], UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"), IPv6: ptrAddr("fd7a:115c:a1e0::2d01:c747"),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 2, GivenName: "user-kris",
ID: 2, GivenName: "ivysaur",
User: &users[1], UserID: &users[1].ID,
IPv4: ptrAddr("100.110.121.96"), IPv6: ptrAddr("fd7a:115c:a1e0::1737:7960"),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 3, GivenName: "user-mon",
ID: 3, GivenName: "venusaur",
User: &users[2], UserID: &users[2].ID,
IPv4: ptrAddr("100.103.90.82"), IPv6: ptrAddr("fd7a:115c:a1e0::9e37:5a52"),
Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 4, GivenName: "tagged-server",
ID: 4, GivenName: "beedrill",
IPv4: ptrAddr("100.108.74.26"), IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
Tags: []string{"tag:server"}, Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 5, GivenName: "tagged-prod",
ID: 5, GivenName: "kakuna",
IPv4: ptrAddr("100.103.8.15"), IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
Tags: []string{"tag:prod"}, Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 6, GivenName: "tagged-client",
ID: 6, GivenName: "weedle",
IPv4: ptrAddr("100.83.200.69"), IPv6: ptrAddr("fd7a:115c:a1e0::c537:c845"),
Tags: []string{"tag:client"}, Hostinfo: &tailcfg.Hostinfo{},
},
{
ID: 7, GivenName: "subnet-router",
ID: 7, GivenName: "squirtle",
IPv4: ptrAddr("100.92.142.61"), IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
Tags: []string{"tag:router"},
Hostinfo: &tailcfg.Hostinfo{
@@ -96,7 +97,7 @@ func setupACLCompatNodes(users types.Users) types.Nodes {
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
},
{
ID: 8, GivenName: "exit-node",
ID: 8, GivenName: "charmander",
IPv4: ptrAddr("100.85.66.106"), IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
Tags: []string{"tag:exit"}, Hostinfo: &tailcfg.Hostinfo{},
},
@@ -36,23 +36,26 @@ import (
// setupGrantsCompatUsers returns the 3 test users for grants compatibility tests.
// Email addresses use @example.com domain, matching the converted Tailscale policy format.
// Users get norse-god names; nodes get original-151 pokémon names — matching
// the anonymized identifiers tscap writes into the capture files
// (see github.com/kradalby/tscap/anonymize).
func setupGrantsCompatUsers() types.Users {
return types.Users{
{Model: gorm.Model{ID: 1}, Name: "kratail2tid", Email: "kratail2tid@example.com"},
{Model: gorm.Model{ID: 2}, Name: "kristoffer", Email: "kristoffer@example.com"},
{Model: gorm.Model{ID: 3}, Name: "monitorpasskeykradalby", Email: "monitorpasskeykradalby@example.com"},
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.com"},
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
}
}
// setupGrantsCompatNodes returns the 8 test nodes for grants compatibility tests.
// setupGrantsCompatNodes returns the 15 test nodes for grants compatibility tests.
// The node configuration matches the Tailscale test environment:
// - 3 user-owned nodes (user1, user-kris, user-mon)
// - 5 tagged nodes (tagged-server, tagged-prod, tagged-client, subnet-router, exit-node)
// - 3 user-owned nodes (bulbasaur, ivysaur, venusaur)
// - 12 tagged nodes (beedrill, kakuna, weedle, squirtle, charmander,
// pidgey, pidgeotto, rattata, raticate, spearow, fearow, blastoise)
func setupGrantsCompatNodes(users types.Users) types.Nodes {
nodeUser1 := &types.Node{
nodeBulbasaur := &types.Node{
ID: 1,
GivenName: "user1",
GivenName: "bulbasaur",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"),
@@ -60,9 +63,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
Hostinfo: &tailcfg.Hostinfo{},
}
nodeUserKris := &types.Node{
nodeIvysaur := &types.Node{
ID: 2,
GivenName: "user-kris",
GivenName: "ivysaur",
User: &users[1],
UserID: &users[1].ID,
IPv4: ptrAddr("100.110.121.96"),
@@ -70,9 +73,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
Hostinfo: &tailcfg.Hostinfo{},
}
nodeUserMon := &types.Node{
nodeVenusaur := &types.Node{
ID: 3,
GivenName: "user-mon",
GivenName: "venusaur",
User: &users[2],
UserID: &users[2].ID,
IPv4: ptrAddr("100.103.90.82"),
@@ -80,36 +83,36 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
Hostinfo: &tailcfg.Hostinfo{},
}
nodeTaggedServer := &types.Node{
nodeBeedrill := &types.Node{
ID: 4,
GivenName: "tagged-server",
GivenName: "beedrill",
IPv4: ptrAddr("100.108.74.26"),
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
Tags: []string{"tag:server"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeTaggedProd := &types.Node{
nodeKakuna := &types.Node{
ID: 5,
GivenName: "tagged-prod",
GivenName: "kakuna",
IPv4: ptrAddr("100.103.8.15"),
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
Tags: []string{"tag:prod"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeTaggedClient := &types.Node{
nodeWeedle := &types.Node{
ID: 6,
GivenName: "tagged-client",
GivenName: "weedle",
IPv4: ptrAddr("100.83.200.69"),
IPv6: ptrAddr("fd7a:115c:a1e0::c537:c845"),
Tags: []string{"tag:client"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeSubnetRouter := &types.Node{
nodeSquirtle := &types.Node{
ID: 7,
GivenName: "subnet-router",
GivenName: "squirtle",
IPv4: ptrAddr("100.92.142.61"),
IPv6: ptrAddr("fd7a:115c:a1e0::3e37:8e3d"),
Tags: []string{"tag:router"},
@@ -119,9 +122,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")},
}
nodeExitNode := &types.Node{
nodeCharmander := &types.Node{
ID: 8,
GivenName: "exit-node",
GivenName: "charmander",
IPv4: ptrAddr("100.85.66.106"),
IPv6: ptrAddr("fd7a:115c:a1e0::7c37:426a"),
Tags: []string{"tag:exit"},
@@ -139,9 +142,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
// --- New nodes for expanded via grant topology ---
nodeExitA := &types.Node{
nodePidgey := &types.Node{
ID: 9,
GivenName: "exit-a",
GivenName: "pidgey",
IPv4: ptrAddr("100.124.195.93"),
IPv6: ptrAddr("fd7a:115c:a1e0::7837:c35d"),
Tags: []string{"tag:exit-a"},
@@ -157,9 +160,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
},
}
nodeExitB := &types.Node{
nodePidgeotto := &types.Node{
ID: 10,
GivenName: "exit-b",
GivenName: "pidgeotto",
IPv4: ptrAddr("100.116.18.24"),
IPv6: ptrAddr("fd7a:115c:a1e0::ff37:1218"),
Tags: []string{"tag:exit-b"},
@@ -175,27 +178,27 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
},
}
nodeGroupA := &types.Node{
nodeRattata := &types.Node{
ID: 11,
GivenName: "group-a-client",
GivenName: "rattata",
IPv4: ptrAddr("100.107.162.14"),
IPv6: ptrAddr("fd7a:115c:a1e0::a237:a20e"),
Tags: []string{"tag:group-a"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeGroupB := &types.Node{
nodeRaticate := &types.Node{
ID: 12,
GivenName: "group-b-client",
GivenName: "raticate",
IPv4: ptrAddr("100.77.135.18"),
IPv6: ptrAddr("fd7a:115c:a1e0::4b37:8712"),
Tags: []string{"tag:group-b"},
Hostinfo: &tailcfg.Hostinfo{},
}
nodeRouterA := &types.Node{
nodeSpearow := &types.Node{
ID: 13,
GivenName: "router-a",
GivenName: "spearow",
IPv4: ptrAddr("100.109.43.124"),
IPv6: ptrAddr("fd7a:115c:a1e0::a537:2b7c"),
Tags: []string{"tag:router-a"},
@@ -205,9 +208,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.44.0.0/16")},
}
nodeRouterB := &types.Node{
nodeFearow := &types.Node{
ID: 14,
GivenName: "router-b",
GivenName: "fearow",
IPv4: ptrAddr("100.65.172.123"),
IPv6: ptrAddr("fd7a:115c:a1e0::5a37:ac7c"),
Tags: []string{"tag:router-b"},
@@ -217,9 +220,9 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.55.0.0/16")},
}
nodeMultiExitRouter := &types.Node{
nodeBlastoise := &types.Node{
ID: 15,
GivenName: "multi-exit-router",
GivenName: "blastoise",
IPv4: ptrAddr("100.105.127.107"),
IPv6: ptrAddr("fd7a:115c:a1e0::9537:7f6b"),
Tags: []string{"tag:exit", "tag:router"},
@@ -238,21 +241,21 @@ func setupGrantsCompatNodes(users types.Users) types.Nodes {
}
return types.Nodes{
nodeUser1,
nodeUserKris,
nodeUserMon,
nodeTaggedServer,
nodeTaggedProd,
nodeTaggedClient,
nodeSubnetRouter,
nodeExitNode,
nodeExitA,
nodeExitB,
nodeGroupA,
nodeGroupB,
nodeRouterA,
nodeRouterB,
nodeMultiExitRouter,
nodeBulbasaur,
nodeIvysaur,
nodeVenusaur,
nodeBeedrill,
nodeKakuna,
nodeWeedle,
nodeSquirtle,
nodeCharmander,
nodePidgey,
nodePidgeotto,
nodeRattata,
nodeRaticate,
nodeSpearow,
nodeFearow,
nodeBlastoise,
}
}
@@ -267,22 +270,12 @@ func findGrantsNode(nodes types.Nodes, name string) *types.Node {
return nil
}
// convertPolicyUserEmails converts Tailscale SaaS user email formats to
// headscale-compatible @example.com format in the raw policy JSON.
//
// Tailscale uses provider-specific email formats:
// - kratail2tid@passkey (passkey auth)
// - kristoffer@dalby.cc (email auth)
// - monitorpasskeykradalby@passkey (passkey auth)
//
// Headscale resolves users by Email field, so we convert all to @example.com.
// 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
// FullPolicy is already in its final form and this is a passthrough.
func convertPolicyUserEmails(policyJSON []byte) []byte {
s := string(policyJSON)
s = strings.ReplaceAll(s, "kratail2tid@passkey", "kratail2tid@example.com")
s = strings.ReplaceAll(s, "kristoffer@dalby.cc", "kristoffer@example.com")
s = strings.ReplaceAll(s, "monitorpasskeykradalby@passkey", "monitorpasskeykradalby@example.com")
return []byte(s)
return policyJSON
}
// loadGrantTestFile loads and parses a single grant capture HuJSON file.
@@ -351,11 +344,12 @@ func TestGrantsCompat(t *testing.T) {
// Determine which node set to use based on the test's topology.
// Tests captured with the expanded 15-node topology (V26+) have
// nodes like exit-a, 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 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["exit-a"]; !hasNewNodes {
if _, hasNewNodes := tf.Captures["pidgey"]; !hasNewNodes {
nodes = allNodes[:8]
}
@@ -504,7 +498,7 @@ func testGrantSuccess(
for nodeName, capture := range tf.Captures {
t.Run(nodeName, func(t *testing.T) {
// Check if this node was offline during capture.
// tagged-prod was frequently offline (132 of 188 success tests).
// kakuna (tag:prod) was frequently offline (132 of 188 success tests).
// When offline, packet_filter_rules is null and topology shows
// hostname="unknown" with empty tags.
captureIsNull := len(capture.PacketFilterRules) == 0 ||
@@ -57,21 +57,11 @@ func loadRoutesTestFile(t *testing.T, path string) *testcapture.Capture {
return c
}
// convertSaaSEmail rewrites a Tailscale SaaS user email to its
// headscale-equivalent @example.com form, matching the convention used by
// convertPolicyUserEmails. The kristoffer@dalby.cc and *@passkey domains
// are normalized to @example.com so that policy resolution finds the user
// objects we set up in the test.
// convertSaaSEmail used to map SaaS-side emails to @example.com placeholders.
// tscap now anonymizes captures at write time (norse-god names + pokémon
// hostnames), so the captured topology emails are already in their final
// form and this is a passthrough.
func convertSaaSEmail(email string) string {
switch email {
case "kratail2tid@passkey":
return "kratail2tid@example.com"
case "kristoffer@dalby.cc":
return "kristoffer@example.com"
case "monitorpasskeykradalby@passkey":
return "monitorpasskeykradalby@example.com"
}
return email
}
@@ -19,7 +19,6 @@ package v2
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@@ -33,43 +32,41 @@ import (
// setupSSHDataCompatUsers returns the 3 test users for SSH data-driven
// compatibility tests. The user configuration matches the Tailscale test
// environment with email domains preserved for localpart matching:
// - kratail2tid@example.com (converted from @passkey)
// - kristoffer@dalby.cc (kept as-is — different domain for localpart exclusion)
// - monitorpasskeykradalby@example.com (converted from @passkey)
// compatibility tests. Users get norse-god names; nodes get original-151
// pokémon names — matching the anonymized identifiers tscap writes into
// the capture files (see github.com/kradalby/tscap/anonymize).
func setupSSHDataCompatUsers() types.Users {
return types.Users{
{
Model: gorm.Model{ID: 1},
Name: "kratail2tid",
Email: "kratail2tid@example.com",
Name: "odin",
Email: "odin@example.com",
},
{
Model: gorm.Model{ID: 2},
Name: "kristoffer",
Email: "kristoffer@dalby.cc",
Name: "thor",
Email: "thor@example.com",
},
{
Model: gorm.Model{ID: 3},
Name: "monitorpasskeykradalby",
Email: "monitorpasskeykradalby@example.com",
Name: "freya",
Email: "freya@example.com",
},
}
}
// setupSSHDataCompatNodes returns the 5 test nodes for SSH data-driven
// compatibility tests. Node GivenNames match the keys in the JSON files:
// - user1 (owned by kratail2tid)
// - user-kris (owned by kristoffer)
// - user-mon (owned by monitorpasskeykradalby)
// - tagged-server (tag:server)
// - tagged-prod (tag:prod)
// setupSSHDataCompatNodes returns the test nodes for SSH data-driven
// compatibility tests. Node GivenNames match the anonymized pokémon names:
// - bulbasaur (owned by odin)
// - ivysaur (owned by thor)
// - venusaur (owned by freya)
// - beedrill (tag:server)
// - kakuna (tag:prod)
func setupSSHDataCompatNodes(users types.Users) types.Nodes {
return types.Nodes{
&types.Node{
ID: 1,
GivenName: "user1",
GivenName: "bulbasaur",
User: &users[0],
UserID: &users[0].ID,
IPv4: ptrAddr("100.90.199.68"),
@@ -78,7 +75,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
},
&types.Node{
ID: 2,
GivenName: "user-kris",
GivenName: "ivysaur",
User: &users[1],
UserID: &users[1].ID,
IPv4: ptrAddr("100.110.121.96"),
@@ -87,7 +84,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
},
&types.Node{
ID: 3,
GivenName: "user-mon",
GivenName: "venusaur",
User: &users[2],
UserID: &users[2].ID,
IPv4: ptrAddr("100.103.90.82"),
@@ -96,7 +93,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
},
&types.Node{
ID: 4,
GivenName: "tagged-server",
GivenName: "beedrill",
IPv4: ptrAddr("100.108.74.26"),
IPv6: ptrAddr("fd7a:115c:a1e0::b901:4a87"),
Tags: []string{"tag:server"},
@@ -104,7 +101,7 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
},
&types.Node{
ID: 5,
GivenName: "tagged-prod",
GivenName: "kakuna",
IPv4: ptrAddr("100.103.8.15"),
IPv6: ptrAddr("fd7a:115c:a1e0::5b37:80f"),
Tags: []string{"tag:prod"},
@@ -113,19 +110,6 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes {
}
}
// convertSSHPolicyEmails converts Tailscale SaaS email domains to
// headscale-compatible format in the raw policy JSON.
//
// The @passkey domain is converted to @example.com. The @dalby.cc domain
// is kept as-is to preserve localpart matching semantics (kristoffer should
// NOT match localpart:*@example.com, just as it doesn't match
// localpart:*@passkey in Tailscale SaaS).
func convertSSHPolicyEmails(s string) string {
s = strings.ReplaceAll(s, "@passkey", "@example.com")
return s
}
// loadSSHTestFile loads and parses a single SSH capture HuJSON file.
func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
t.Helper()
@@ -196,10 +180,11 @@ func TestSSHDataCompat(t *testing.T) {
return
}
// Use the captured full policy verbatim (with email-domain conversion).
policyJSON := convertSSHPolicyEmails(string(tf.Input.FullPolicy))
// Use the captured full policy verbatim. Anonymization in
// tscap already rewrites SaaS emails to @example.com.
policyJSON := tf.Input.FullPolicy
pol, err := unmarshalPolicy([]byte(policyJSON))
pol, err := unmarshalPolicy(policyJSON)
require.NoError(
t,
err,