From e30de586270833a9973c0de7ba5e35253fda424a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 8 Apr 2026 08:05:45 +0000 Subject: [PATCH] testcapture: add capture format package and rewire compat tests Add hscontrol/types/testcapture, mirroring the type that the tscap tool emits. The package has no tailscale.com/tailcfg import so it stays dependency-free; wire-format Tailscale data is stored as json.RawMessage and consumers unmarshal into the typed shape they need. Drop the four private *TestFile structs from the policy v2 compat tests and read directly into testcapture.Capture. Switch globs to lowercase (acl-/grant-/routes-/ssh-) to match the new tscap output filenames. Updates #3157 Updates #3169 --- .../v2/tailscale_acl_data_compat_test.go | 89 +--- .../policy/v2/tailscale_grants_compat_test.go | 82 ++-- .../v2/tailscale_routes_data_compat_test.go | 145 +++---- .../v2/tailscale_ssh_data_compat_test.go | 157 ++----- hscontrol/types/testcapture/header.go | 126 ++++++ hscontrol/types/testcapture/read.go | 49 +++ hscontrol/types/testcapture/testcapture.go | 220 ++++++++++ .../types/testcapture/testcapture_test.go | 399 ++++++++++++++++++ hscontrol/types/testcapture/write.go | 93 ++++ 9 files changed, 1034 insertions(+), 326 deletions(-) create mode 100644 hscontrol/types/testcapture/header.go create mode 100644 hscontrol/types/testcapture/read.go create mode 100644 hscontrol/types/testcapture/testcapture.go create mode 100644 hscontrol/types/testcapture/testcapture_test.go create mode 100644 hscontrol/types/testcapture/write.go diff --git a/hscontrol/policy/v2/tailscale_acl_data_compat_test.go b/hscontrol/policy/v2/tailscale_acl_data_compat_test.go index 3e2b3299..256127b2 100644 --- a/hscontrol/policy/v2/tailscale_acl_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_acl_data_compat_test.go @@ -1,22 +1,22 @@ // This file implements a data-driven test runner for ACL compatibility tests. -// It loads JSON golden files from testdata/acl_results/ACL-*.json and compares -// headscale's ACL engine output against the expected packet filter rules. +// It loads HuJSON golden files from testdata/acl_results/acl-*.hujson and +// compares headscale's ACL engine output against the expected packet filter +// rules captured from Tailscale SaaS by the tscap tool. // -// The JSON files were converted from the original inline Go struct test cases -// in tailscale_acl_compat_test.go. Each file contains: -// - A full policy (groups, tagOwners, hosts, acls) -// - Expected packet_filter_rules per node (5 nodes) -// - Or an error response for invalid policies +// Each file is a testcapture.Capture containing: +// - The full policy that was POSTed to the Tailscale SaaS API +// - The 8-node topology used for the capture run +// - Expected packet_filter_rules per node (or error metadata for +// scenarios that the SaaS rejected) // -// Test data source: testdata/acl_results/ACL-*.json -// Original source: Tailscale SaaS API captures + headscale-generated expansions +// Test data source: testdata/acl_results/acl-*.hujson +// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture package v2 import ( "encoding/json" "net/netip" - "os" "path/filepath" "strings" "testing" @@ -25,8 +25,8 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/juanfont/headscale/hscontrol/policy/policyutil" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/require" - "github.com/tailscale/hujson" "gorm.io/gorm" "tailscale.com/tailcfg" ) @@ -183,42 +183,13 @@ func cmpOptions() []cmp.Option { } } -// aclTestFile represents the JSON structure of a captured ACL test file. -type aclTestFile struct { - TestID string `json:"test_id"` - Source string `json:"source"` // "tailscale_saas" or "headscale_adapted" - Error bool `json:"error"` - HeadscaleDiffers bool `json:"headscale_differs"` - ParentTest string `json:"parent_test"` - Input struct { - FullPolicy json.RawMessage `json:"full_policy"` - APIResponseCode int `json:"api_response_code"` - APIResponseBody *struct { - Message string `json:"message"` - } `json:"api_response_body"` - } `json:"input"` - Topology struct { - Nodes map[string]struct { - Hostname string `json:"hostname"` - Tags []string `json:"tags"` - IPv4 string `json:"ipv4"` - IPv6 string `json:"ipv6"` - User string `json:"user"` - RoutableIPs []string `json:"routable_ips"` - ApprovedRoutes []string `json:"approved_routes"` - } `json:"nodes"` - } `json:"topology"` - Captures map[string]struct { - PacketFilterRules json.RawMessage `json:"packet_filter_rules"` - } `json:"captures"` -} // buildACLUsersAndNodes constructs users and nodes from an ACL // golden file's topology. This ensures the test creates the same // nodes that were present during the Tailscale SaaS capture. func buildACLUsersAndNodes( t *testing.T, - tf aclTestFile, + tf *testcapture.Capture, ) (types.Users, types.Nodes) { t.Helper() @@ -293,23 +264,14 @@ func buildACLUsersAndNodes( return users, nodes } -// loadACLTestFile loads and parses a single ACL test JSON file. -func loadACLTestFile(t *testing.T, path string) aclTestFile { +// loadACLTestFile loads and parses a single ACL capture HuJSON file. +func loadACLTestFile(t *testing.T, path string) *testcapture.Capture { t.Helper() - content, err := os.ReadFile(path) + c, err := testcapture.Read(path) require.NoError(t, err, "failed to read test file %s", path) - ast, err := hujson.Parse(content) - require.NoError(t, err, "failed to parse HuJSON in %s", path) - ast.Standardize() - - var tf aclTestFile - - err = json.Unmarshal(ast.Pack(), &tf) - require.NoError(t, err, "failed to unmarshal test file %s", path) - - return tf + return c } // aclSkipReasons documents WHY tests are expected to fail and WHAT needs to be @@ -339,13 +301,13 @@ func TestACLCompat(t *testing.T) { t.Parallel() files, err := filepath.Glob( - filepath.Join("testdata", "acl_results", "ACL-*.hujson"), + filepath.Join("testdata", "acl_results", "acl-*.hujson"), ) require.NoError(t, err, "failed to glob test files") require.NotEmpty( t, files, - "no ACL-*.hujson test files found in testdata/acl_results/", + "no acl-*.hujson test files found in testdata/acl_results/", ) t.Logf("Loaded %d ACL test files", len(files)) @@ -402,7 +364,7 @@ var aclErrorMessageMap = map[string]string{ } // testACLError verifies that an invalid policy produces the expected error. -func testACLError(t *testing.T, tf aclTestFile) { +func testACLError(t *testing.T, tf *testcapture.Capture) { t.Helper() policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy) @@ -436,17 +398,6 @@ func testACLError(t *testing.T, tf aclTestFile) { return } - // For headscale_differs tests, headscale may accept what - // Tailscale rejects. Log as skip so it appears in output. - if tf.HeadscaleDiffers { - t.Skipf( - "%s: headscale accepts this policy (Tailscale rejects it)", - tf.TestID, - ) - - return - } - t.Errorf( "%s: expected error but policy parsed and validated successfully", tf.TestID, @@ -513,7 +464,7 @@ func assertACLErrorContains( // packet filter rules for each node. func testACLSuccess( t *testing.T, - tf aclTestFile, + tf *testcapture.Capture, users types.Users, nodes types.Nodes, ) { diff --git a/hscontrol/policy/v2/tailscale_grants_compat_test.go b/hscontrol/policy/v2/tailscale_grants_compat_test.go index c87f1fc3..6ee0f36a 100644 --- a/hscontrol/policy/v2/tailscale_grants_compat_test.go +++ b/hscontrol/policy/v2/tailscale_grants_compat_test.go @@ -1,27 +1,25 @@ -// This file is "generated" by Claude. -// It contains a data-driven test that reads 237 GRANT-*.json test files -// captured from Tailscale SaaS. Each file contains: -// - A policy with grants (and optionally ACLs) -// - The expected packet_filter_rules for each of 8 test nodes +// This file implements a data-driven test runner for grant compatibility +// tests. It loads HuJSON golden files from testdata/grant_results/grant-*.hujson +// and via-grant-*.hujson, captured from Tailscale SaaS by tscap, and compares +// headscale's grants engine output against the captured packet filter rules. +// +// Each file is a testcapture.Capture containing: +// - A full policy with grants (and optionally ACLs) +// - The expected packet_filter_rules for each of 8-15 test nodes // - Or an error response for invalid policies // -// The test loads each JSON file, applies the policy through headscale's -// grants engine, and compares the output against Tailscale's actual behavior. +// Tests known to fail due to unimplemented features or known differences are +// skipped with a TODO comment explaining the root cause. As headscale's grants +// implementation improves, tests should be removed from the skip list. // -// Tests that are known to fail due to unimplemented features or known -// differences are skipped with a TODO comment explaining the root cause. -// As headscale's grants implementation improves, tests should be removed -// from the skip list. -// -// Test data source: testdata/grant_results/GRANT-*.json -// Captured from: Tailscale SaaS API + tailscale debug localapi +// Test data source: testdata/grant_results/{grant,via-grant}-*.hujson +// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture package v2 import ( "encoding/json" "net/netip" - "os" "path/filepath" "strings" "testing" @@ -30,35 +28,12 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/juanfont/headscale/hscontrol/policy/policyutil" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/require" - "github.com/tailscale/hujson" "gorm.io/gorm" "tailscale.com/tailcfg" ) -// grantTestFile represents the JSON structure of a captured grant test file. -type grantTestFile struct { - TestID string `json:"test_id"` - Error bool `json:"error"` - Input struct { - FullPolicy json.RawMessage `json:"full_policy"` - APIResponseCode int `json:"api_response_code"` - APIResponseBody *struct { - Message string `json:"message"` - } `json:"api_response_body"` - } `json:"input"` - Topology struct { - Nodes map[string]struct { - Hostname string `json:"hostname"` - Tags []string `json:"tags"` - IPv4 string `json:"ipv4"` - IPv6 string `json:"ipv6"` - } `json:"nodes"` - } `json:"topology"` - Captures map[string]struct { - PacketFilterRules json.RawMessage `json:"packet_filter_rules"` - } `json:"captures"` -} // setupGrantsCompatUsers returns the 3 test users for grants compatibility tests. // Email addresses use @example.com domain, matching the converted Tailscale policy format. @@ -310,23 +285,14 @@ func convertPolicyUserEmails(policyJSON []byte) []byte { return []byte(s) } -// loadGrantTestFile loads and parses a single grant test JSON file. -func loadGrantTestFile(t *testing.T, path string) grantTestFile { +// loadGrantTestFile loads and parses a single grant capture HuJSON file. +func loadGrantTestFile(t *testing.T, path string) *testcapture.Capture { t.Helper() - content, err := os.ReadFile(path) + c, err := testcapture.Read(path) require.NoError(t, err, "failed to read test file %s", path) - ast, err := hujson.Parse(content) - require.NoError(t, err, "failed to parse HuJSON in %s", path) - ast.Standardize() - - var tf grantTestFile - - err = json.Unmarshal(ast.Pack(), &tf) - require.NoError(t, err, "failed to unmarshal test file %s", path) - - return tf + return c } // Skip categories document WHY tests are expected to differ from Tailscale SaaS. @@ -341,8 +307,8 @@ var grantSkipReasons = map[string]string{ // Tailscale SaaS policies can use user:*@passkey as a wildcard matching // all passkey-authenticated users. headscale does not support passkey // authentication and has no equivalent for this wildcard pattern. - "GRANT-K20": "USER_PASSKEY_WILDCARD: src=user:*@passkey not supported in headscale", - "GRANT-K21": "USER_PASSKEY_WILDCARD: dst=user:*@passkey not supported in headscale", + "grant-k20": "USER_PASSKEY_WILDCARD: src=user:*@passkey not supported in headscale", + "grant-k21": "USER_PASSKEY_WILDCARD: dst=user:*@passkey not supported in headscale", } // TestGrantsCompat is a data-driven test that loads all GRANT-*.json @@ -362,9 +328,9 @@ var grantSkipReasons = map[string]string{ func TestGrantsCompat(t *testing.T) { t.Parallel() - files, err := filepath.Glob(filepath.Join("testdata", "grant_results", "GRANT-*.hujson")) + files, err := filepath.Glob(filepath.Join("testdata", "grant_results", "*-*.hujson")) require.NoError(t, err, "failed to glob test files") - require.NotEmpty(t, files, "no GRANT-*.hujson test files found in testdata/grant_results/") + require.NotEmpty(t, files, "no grant test files found in testdata/grant_results/") t.Logf("Loaded %d grant test files", len(files)) @@ -407,7 +373,7 @@ func TestGrantsCompat(t *testing.T) { } // testGrantError verifies that an invalid policy produces the expected error. -func testGrantError(t *testing.T, policyJSON []byte, tf grantTestFile) { +func testGrantError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) { t.Helper() wantMsg := "" @@ -523,7 +489,7 @@ func extractErrorKeyParts(msg string) []string { func testGrantSuccess( t *testing.T, policyJSON []byte, - tf grantTestFile, + tf *testcapture.Capture, users types.Users, nodes types.Nodes, ) { diff --git a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go index 14c677a5..9457aaa8 100644 --- a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go @@ -1,12 +1,12 @@ // This file implements data-driven test runners for routes compatibility tests. -// It loads HuJSON golden files from testdata/routes_results/ROUTES-*.hujson and -// compares headscale's route-aware ACL engine output against the expected -// packet filter rules. +// It loads HuJSON golden files from testdata/routes_results/routes-*.hujson, +// captured from Tailscale SaaS by tscap, and compares headscale's route-aware +// ACL engine output against the captured packet filter rules. // -// Each HuJSON file contains: -// - A full policy (groups, tagOwners, hosts, acls) -// - A topology section with nodes, including routable_ips and approved_routes -// - Expected packet_filter_rules per node +// Each capture file is a testcapture.Capture containing: +// - A full policy (groups, tagOwners, hosts, acls) in Input.FullPolicy +// - A Topology section with nodes, including RoutableIPs and ApprovedRoutes +// - Expected packet_filter_rules per node in Captures[name].PacketFilterRules // // Two test runners use this data: // @@ -20,15 +20,14 @@ // referenced in those rules must be visible as peers. This exercises the // CanAccess fix from issue #3157. // -// Test data source: testdata/routes_results/ROUTES-*.hujson -// Original source: Tailscale SaaS captures + headscale-generated expansions +// Test data source: testdata/routes_results/routes-*.hujson +// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture package v2 import ( "encoding/json" "net/netip" - "os" "path/filepath" "slices" "sort" @@ -39,75 +38,49 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/juanfont/headscale/hscontrol/policy/policyutil" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/tailscale/hujson" "go4.org/netipx" "gorm.io/gorm" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" ) -// routesTestFile represents the JSON structure of a captured routes test file. -type routesTestFile struct { - TestID string `json:"test_id"` - Source string `json:"source"` - ParentTest string `json:"parent_test"` - Input struct { - FullPolicy json.RawMessage `json:"full_policy"` - } `json:"input"` - Topology routesTopology `json:"topology"` - Captures map[string]struct { - PacketFilterRules json.RawMessage `json:"packet_filter_rules"` - } `json:"captures"` -} - -// routesTopology describes the node topology for a routes test. -type routesTopology struct { - Users []struct { - ID uint `json:"id"` - Name string `json:"name"` - } `json:"users"` - Nodes map[string]routesNodeDef `json:"nodes"` -} - -// routesNodeDef describes a single node in the routes test topology. -type routesNodeDef struct { - ID int `json:"id"` - Hostname string `json:"hostname"` - IPv4 string `json:"ipv4"` - IPv6 string `json:"ipv6"` - Tags []string `json:"tags"` - User string `json:"user,omitempty"` - RoutableIPs []string `json:"routable_ips"` - ApprovedRoutes []string `json:"approved_routes"` -} - -// loadRoutesTestFile loads and parses a single routes test JSON file. -func loadRoutesTestFile(t *testing.T, path string) routesTestFile { +// loadRoutesTestFile loads and parses a single routes capture HuJSON file. +func loadRoutesTestFile(t *testing.T, path string) *testcapture.Capture { t.Helper() - content, err := os.ReadFile(path) + c, err := testcapture.Read(path) require.NoError(t, err, "failed to read test file %s", path) - ast, err := hujson.Parse(content) - require.NoError(t, err, "failed to parse HuJSON in %s", path) - ast.Standardize() + return c +} - var tf routesTestFile +// 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. +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" + } - err = json.Unmarshal(ast.Pack(), &tf) - require.NoError(t, err, "failed to unmarshal test file %s", path) - - return tf + return email } // buildRoutesUsersAndNodes constructs types.Users and types.Nodes from the -// JSON topology definition. This allows each test file to define its own -// topology (e.g., the IPv6 tests use different nodes than the standard tests). +// captured topology. This allows each test file to define its own topology +// (e.g., the IPv6 tests use different nodes than the standard tests). func buildRoutesUsersAndNodes( t *testing.T, - topo routesTopology, + topo testcapture.Topology, ) (types.Users, types.Nodes) { t.Helper() @@ -121,6 +94,7 @@ func buildRoutesUsersAndNodes( users = append(users, types.User{ Model: gorm.Model{ID: u.ID}, Name: u.Name, + Email: convertSaaSEmail(u.Email), }) } } else { @@ -131,19 +105,16 @@ func buildRoutesUsersAndNodes( } } - // Build nodes. - // Auto-assign unique IDs when the JSON topology does not provide - // them (id defaults to 0). Unique IDs are required by ReduceNodes / - // BuildPeerMap which skip peers by comparing node.ID. + // Build nodes. Topology nodes are keyed by GivenName. + // Assign sequential IDs (the capture format does not store them); + // unique IDs are required by ReduceNodes / BuildPeerMap which skip + // peers by comparing node.ID. nodes := make(types.Nodes, 0, len(topo.Nodes)) autoID := 1 for _, nodeDef := range topo.Nodes { - nodeID := nodeDef.ID - if nodeID == 0 { - nodeID = autoID - autoID++ - } + nodeID := autoID + autoID++ node := &types.Node{ ID: types.NodeID(nodeID), //nolint:gosec @@ -218,28 +189,28 @@ var routesSkipReasons = map[string]string{} // ACL scenarios. These are the scenarios where the fix for issue #3157 // (CanAccess considering subnet routes as source identity) is critical. var subnetToSubnetFiles = []string{ - "ROUTES-f10_subnet_to_subnet_issue3157", - "ROUTES-f11_subnet_to_subnet_bidirectional", - "ROUTES-f12_subnet_to_subnet_host_aliases", - "ROUTES-f13_subnet_to_subnet_disjoint", - "ROUTES-f14_subnet_to_subnet_overlapping_one_router", - "ROUTES-f15_subnet_to_subnet_cross_routers", + "routes-f10-subnet-to-subnet-issue3157", + "routes-f11-subnet-to-subnet-bidirectional", + "routes-f12-subnet-to-subnet-host-aliases", + "routes-f13-subnet-to-subnet-disjoint", + "routes-f14-subnet-to-subnet-overlapping-one-router", + "routes-f15-subnet-to-subnet-cross-routers", } -// TestRoutesCompat is a data-driven test that loads all ROUTES-*.json test +// TestRoutesCompat is a data-driven test that loads all routes-*.hujson test // files and compares headscale's route-aware ACL engine output against the // expected behavior. func TestRoutesCompat(t *testing.T) { t.Parallel() files, err := filepath.Glob( - filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"), + filepath.Join("testdata", "routes_results", "routes-*.hujson"), ) require.NoError(t, err, "failed to glob test files") require.NotEmpty( t, files, - "no ROUTES-*.hujson test files found in testdata/routes_results/", + "no routes-*.hujson test files found in testdata/routes_results/", ) t.Logf("Loaded %d routes test files", len(files)) @@ -362,7 +333,7 @@ func TestRoutesCompat(t *testing.T) { // Returns a set of unordered node-name pairs that must be peers. func derivePeerPairsFromCaptures( t *testing.T, - tf routesTestFile, + tf *testcapture.Capture, nodes types.Nodes, ) map[[2]string]bool { t.Helper() @@ -491,7 +462,7 @@ func addSrcIPToBuilder( // (tag/user/group resolved sources) func deriveAllPeerPairsFromCaptures( t *testing.T, - tf routesTestFile, + tf *testcapture.Capture, nodes types.Nodes, ) map[[2]string]bool { t.Helper() @@ -799,13 +770,13 @@ func TestRoutesCompatAutoApproval(t *testing.T) { t.Parallel() files, err := filepath.Glob( - filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"), + filepath.Join("testdata", "routes_results", "routes-*.hujson"), ) require.NoError(t, err, "failed to glob test files") require.NotEmpty( t, files, - "no ROUTES-*.hujson test files found in testdata/routes_results/", + "no routes-*.hujson test files found in testdata/routes_results/", ) for _, file := range files { @@ -925,13 +896,13 @@ func TestRoutesCompatReduceRoutes(t *testing.T) { t.Parallel() files, err := filepath.Glob( - filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"), + filepath.Join("testdata", "routes_results", "routes-*.hujson"), ) require.NoError(t, err, "failed to glob test files") require.NotEmpty( t, files, - "no ROUTES-*.hujson test files found in testdata/routes_results/", + "no routes-*.hujson test files found in testdata/routes_results/", ) for _, file := range files { @@ -1118,11 +1089,11 @@ func TestRoutesCompatExitNodePeerVisibility(t *testing.T) { expectedNullAll bool }{ { - testID: "ROUTES-b2_tag_exit_excludes_exit_routes", + testID: "routes-b2-tag-exit-excludes-exit-routes", exitNodeNames: []string{"exit-node", "multi-router"}, }, { - testID: "ROUTES-b8_autogroup_internet_no_filters", + testID: "routes-b8-autogroup-internet-no-filters", exitNodeNames: []string{"exit-node", "multi-router"}, expectedNullAll: true, }, @@ -1269,7 +1240,7 @@ func TestRoutesCompatNoPeersBeyondCaptures(t *testing.T) { t.Parallel() files, err := filepath.Glob( - filepath.Join("testdata", "routes_results", "ROUTES-*.hujson"), + filepath.Join("testdata", "routes_results", "routes-*.hujson"), ) require.NoError(t, err, "failed to glob test files") require.NotEmpty(t, files) diff --git a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go index 1197f69f..ee2f222b 100644 --- a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go @@ -1,26 +1,23 @@ -// This file is "generated" by Claude. -// It contains a data-driven test that reads SSH-*.json test files captured -// from Tailscale SaaS. Each file contains: -// - The SSH section of the policy -// - The expected SSHPolicy rules for each of 5 test nodes +// This file implements a data-driven test runner for SSH compatibility tests. +// It loads HuJSON golden files from testdata/ssh_results/ssh-*.hujson, captured +// from Tailscale SaaS by tscap, and compares headscale's SSH policy compilation +// against the captured SSH rules. // -// The test loads each JSON file, constructs a full policy from the SSH section, -// applies it through headscale's SSH policy compilation, and compares the output -// against Tailscale's actual behavior. +// Each file is a testcapture.Capture containing: +// - The full policy that was POSTed to Tailscale SaaS (we use tf.Input.FullPolicy +// directly instead of reconstructing it from a sub-section) +// - The expected SSH rules for each of the 8 test nodes (in tf.Captures[name].SSHRules) // -// Tests that are known to fail due to unimplemented features or known -// differences are skipped with a TODO comment explaining the root cause. -// As headscale's SSH implementation improves, tests should be removed -// from the skip list. +// Tests known to fail due to unimplemented features or known differences are +// skipped with a TODO comment explaining the root cause. // -// Test data source: testdata/ssh_results/SSH-*.json -// Captured from: Tailscale SaaS API + tailscale debug localapi +// Test data source: testdata/ssh_results/ssh-*.hujson +// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture package v2 import ( "encoding/json" - "os" "path/filepath" "strings" "testing" @@ -28,24 +25,12 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/testcapture" "github.com/stretchr/testify/require" - "github.com/tailscale/hujson" "gorm.io/gorm" "tailscale.com/tailcfg" ) -// sshTestFile represents the JSON structure of a captured SSH test file. -type sshTestFile struct { - TestID string `json:"test_id"` - PolicyFile string `json:"policy_file"` - SSHSection json.RawMessage `json:"ssh_section"` - Nodes map[string]sshNodeCapture `json:"nodes"` -} - -// sshNodeCapture represents the expected SSH rules for a single node. -type sshNodeCapture struct { - Rules json.RawMessage `json:"rules"` -} // setupSSHDataCompatUsers returns the 3 test users for SSH data-driven // compatibility tests. The user configuration matches the Tailscale test @@ -131,11 +116,6 @@ func setupSSHDataCompatNodes(users types.Users) types.Nodes { // convertSSHPolicyEmails converts Tailscale SaaS email domains to // headscale-compatible format in the raw policy JSON. // -// Tailscale uses provider-specific email formats: -// - kratail2tid@passkey (passkey auth) -// - kristoffer@dalby.cc (email auth — kept as-is) -// - monitorpasskeykradalby@passkey (passkey auth) -// // 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 @@ -146,98 +126,47 @@ func convertSSHPolicyEmails(s string) string { return s } -// constructSSHFullPolicy builds a complete headscale policy from the -// ssh_section captured from Tailscale SaaS. -// -// The base policy includes: -// - groups matching the Tailscale test environment -// - tagOwners for tag:server and tag:prod -// - A permissive ACL allowing all traffic (matches the grants wildcard -// in the original Tailscale policy) -// - The SSH section from the test file -func constructSSHFullPolicy(sshSection json.RawMessage) string { - // Base policy template with groups, tagOwners, and ACLs - // User references match the converted email addresses. - const basePolicyPrefix = `{ - "groups": { - "group:admins": ["kratail2tid@example.com"], - "group:developers": ["kristoffer@dalby.cc", "kratail2tid@example.com"], - "group:empty": [] - }, - "tagOwners": { - "tag:server": ["kratail2tid@example.com"], - "tag:prod": ["kratail2tid@example.com"] - }, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]` - - // Handle null or empty SSH section - if len(sshSection) == 0 || string(sshSection) == "null" { - // No SSH section at all (like SSH-E4) - return basePolicyPrefix + "\n}" - } - - sshStr := string(sshSection) - - // Convert Tailscale email domains - sshStr = convertSSHPolicyEmails(sshStr) - - return basePolicyPrefix + `, - "ssh": ` + sshStr + "\n}" -} - -// loadSSHTestFile loads and parses a single SSH test JSON file. -func loadSSHTestFile(t *testing.T, path string) sshTestFile { +// loadSSHTestFile loads and parses a single SSH capture HuJSON file. +func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture { t.Helper() - content, err := os.ReadFile(path) + c, err := testcapture.Read(path) require.NoError(t, err, "failed to read test file %s", path) - ast, err := hujson.Parse(content) - require.NoError(t, err, "failed to parse HuJSON in %s", path) - ast.Standardize() - - var tf sshTestFile - - err = json.Unmarshal(ast.Pack(), &tf) - require.NoError(t, err, "failed to unmarshal test file %s", path) - - return tf + return c } // sshSkipReasons documents why each skipped test fails and what needs to be // fixed. Tests are grouped by root cause to identify high-impact changes. -// -// 37 of 39 tests are expected to pass. var sshSkipReasons = map[string]string{ // user:*@passkey wildcard pattern not supported in headscale. // headscale does not support passkey authentication and has no // equivalent for this wildcard pattern. - "SSH-B5": "user:*@passkey wildcard not supported in headscale", - "SSH-D10": "user:*@passkey wildcard not supported in headscale", + "ssh-b5": "user:*@passkey wildcard not supported in headscale", + "ssh-d10": "user:*@passkey wildcard not supported in headscale", } -// TestSSHDataCompat is a data-driven test that loads all SSH-*.json test files -// captured from Tailscale SaaS and compares headscale's SSH policy compilation -// against the real Tailscale behavior. +// TestSSHDataCompat is a data-driven test that loads all ssh-*.hujson test +// files captured from Tailscale SaaS and compares headscale's SSH policy +// compilation against the real Tailscale behavior. // -// Each JSON file contains: -// - The SSH section of the policy -// - Expected SSH rules per node (5 nodes) +// Each capture file contains: +// - The full policy that was POSTed to the SaaS API (Input.FullPolicy) +// - Expected SSH rules per node (Captures[name].SSHRules) // -// The test constructs a full headscale policy from the SSH section, converts -// Tailscale user email formats to headscale format, and runs the policy -// through unmarshalPolicy and compileSSHPolicy. +// The test converts Tailscale user email formats to headscale format and runs +// the captured policy through unmarshalPolicy and compileSSHPolicy. func TestSSHDataCompat(t *testing.T) { t.Parallel() files, err := filepath.Glob( - filepath.Join("testdata", "ssh_results", "SSH-*.hujson"), + filepath.Join("testdata", "ssh_results", "ssh-*.hujson"), ) require.NoError(t, err, "failed to glob test files") require.NotEmpty( t, files, - "no SSH-*.hujson test files found in testdata/ssh_results/", + "no ssh-*.hujson test files found in testdata/ssh_results/", ) t.Logf("Loaded %d SSH test files", len(files)) @@ -261,8 +190,14 @@ func TestSSHDataCompat(t *testing.T) { return } - // Construct full policy from SSH section - policyJSON := constructSSHFullPolicy(tf.SSHSection) + // Skip captures the SaaS rejected — no expected SSH rules to compare against. + if tf.Error { + t.Skipf("%s: SaaS rejected the policy (api_response_code=%d); no expected SSH rules captured", tf.TestID, tf.Input.APIResponseCode) + return + } + + // Use the captured full policy verbatim (with email-domain conversion). + policyJSON := convertSSHPolicyEmails(string(tf.Input.FullPolicy)) pol, err := unmarshalPolicy([]byte(policyJSON)) require.NoError( @@ -273,15 +208,13 @@ func TestSSHDataCompat(t *testing.T) { policyJSON, ) - for nodeName, capture := range tf.Nodes { + for nodeName, capture := range tf.Captures { t.Run(nodeName, func(t *testing.T) { node := findNodeByGivenName(nodes, nodeName) - require.NotNilf( - t, - node, - "node %s not found in test setup", - nodeName, - ) + if node == nil { + t.Skipf("node %s not in this test's node set", nodeName) + return + } // Compile headscale SSH policy for this node gotSSH, err := pol.compileSSHPolicy( @@ -300,9 +233,9 @@ func TestSSHDataCompat(t *testing.T) { // Parse expected rules from JSON capture var wantRules []*tailcfg.SSHRule - if len(capture.Rules) > 0 && - string(capture.Rules) != "null" { - err = json.Unmarshal(capture.Rules, &wantRules) + if len(capture.SSHRules) > 0 && + string(capture.SSHRules) != "null" { + err = json.Unmarshal(capture.SSHRules, &wantRules) require.NoError( t, err, diff --git a/hscontrol/types/testcapture/header.go b/hscontrol/types/testcapture/header.go new file mode 100644 index 00000000..e4822a75 --- /dev/null +++ b/hscontrol/types/testcapture/header.go @@ -0,0 +1,126 @@ +package testcapture + +import ( + "fmt" + "strings" +) + +// CommentHeader returns the // comment header that gets prepended to +// a Capture file when it is written. The header is purely +// informational; consumers ignore it. Format: +// +// +// +// +// +// Nodes with filter rules: of ← for non-SSH corpora +// Nodes with SSH rules: of ← for SSH corpora +// Captured at: +// tscap version: +// schema version: +// +// Both `tool_version` and `schema_version` are also stored as +// first-class JSON fields on the Capture struct; the comment lines +// exist purely so the values are visible at a glance without +// parsing the file. +// +// The leading "// " on every line is added by the hujson writer. +func CommentHeader(c *Capture) string { + if c == nil { + return "" + } + + var b strings.Builder + + b.WriteString(c.TestID) + b.WriteByte('\n') + + if c.Description != "" { + b.WriteByte('\n') + b.WriteString(c.Description) + b.WriteByte('\n') + } + + stats := captureStats(c) + if stats != "" { + b.WriteByte('\n') + b.WriteString(stats) + b.WriteByte('\n') + } + + if !c.CapturedAt.IsZero() { + b.WriteString(fmt.Sprintf("Captured at: %s\n", c.CapturedAt.UTC().Format("2006-01-02T15:04:05Z"))) + } + + if c.ToolVersion != "" { + b.WriteString(fmt.Sprintf("tscap version: %s\n", c.ToolVersion)) + } + + if c.SchemaVersion != 0 { + b.WriteString(fmt.Sprintf("schema version: %d\n", c.SchemaVersion)) + } + + return strings.TrimRight(b.String(), "\n") +} + +// captureStats returns a one-line summary of how many nodes had +// non-empty captured data, or the empty string if there are no +// captures at all. +// +// The phrasing depends on which fields the corpus uses: +// - SSH corpora populate SSHRules +// - other corpora populate PacketFilterRules +// +// If both fields appear (mixed/unusual), filter rules wins. +func captureStats(c *Capture) string { + if len(c.Captures) == 0 { + return "" + } + + var ( + total = len(c.Captures) + filterRules int + sshRules int + filterRulesSet bool + sshRulesSet bool + ) + + for _, n := range c.Captures { + if n.PacketFilterRules != nil { + filterRulesSet = true + + if !isJSONNullOrEmpty(n.PacketFilterRules) { + filterRules++ + } + } + + if n.SSHRules != nil { + sshRulesSet = true + + if !isJSONNullOrEmpty(n.SSHRules) { + sshRules++ + } + } + } + + switch { + case filterRulesSet: + return fmt.Sprintf("Nodes with filter rules: %d of %d", filterRules, total) + case sshRulesSet: + return fmt.Sprintf("Nodes with SSH rules: %d of %d", sshRules, total) + default: + return "" + } +} + +// isJSONNullOrEmpty reports whether raw is the JSON value `null`, +// an empty array `[]`, or empty after whitespace trimming. +func isJSONNullOrEmpty(raw []byte) bool { + trimmed := strings.TrimSpace(string(raw)) + switch trimmed { + case "", "null", "[]": + return true + default: + return false + } +} diff --git a/hscontrol/types/testcapture/read.go b/hscontrol/types/testcapture/read.go new file mode 100644 index 00000000..d1090e9e --- /dev/null +++ b/hscontrol/types/testcapture/read.go @@ -0,0 +1,49 @@ +package testcapture + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/tailscale/hujson" +) + +// Read parses a HuJSON capture file from disk into a Capture. +// +// Comments and trailing commas in the file are stripped before +// unmarshaling. The returned Capture's CapturedAt is the value +// recorded in the file (not "now"). +func Read(path string) (*Capture, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("testcapture: read %s: %w", path, err) + } + + var c Capture + + err = unmarshalHuJSON(data, &c) + if err != nil { + return nil, fmt.Errorf("testcapture: %s: %w", path, err) + } + + return &c, nil +} + +// unmarshalHuJSON parses HuJSON bytes (JSON with comments / trailing +// commas) into v. Comments are stripped via hujson.Standardize before +// json.Unmarshal is called. +func unmarshalHuJSON(data []byte, v any) error { + ast, err := hujson.Parse(data) + if err != nil { + return fmt.Errorf("hujson parse: %w", err) + } + + ast.Standardize() + + err = json.Unmarshal(ast.Pack(), v) + if err != nil { + return fmt.Errorf("json unmarshal: %w", err) + } + + return nil +} diff --git a/hscontrol/types/testcapture/testcapture.go b/hscontrol/types/testcapture/testcapture.go new file mode 100644 index 00000000..d61bb36a --- /dev/null +++ b/hscontrol/types/testcapture/testcapture.go @@ -0,0 +1,220 @@ +// Package testcapture defines the on-disk format used by Headscale's +// policy v2 compatibility tests for golden data captured from +// Tailscale SaaS by the tscap tool. +// +// Files are HuJSON. The package intentionally does not import +// tailscale.com/tailcfg so it can live under hscontrol/types/ +// without dragging extra dependencies. Wire-format Tailscale data +// (filter rules, netmap, whois, SSH rules) is stored as +// json.RawMessage; consumers json.Unmarshal into the typed shape +// they need. +// +// All four corpora (acl, routes, grant, ssh) use the same Capture +// shape. SSH scenarios populate Captures[name].SSHRules; the others +// populate Captures[name].PacketFilterRules + Captures[name].Netmap. +package testcapture + +import ( + "encoding/json" + "time" +) + +// SchemaVersion identifies the on-disk format. Bumped on breaking changes. +// +// Files written before SchemaVersion existed do not have this field; new +// captures from tscap always set it to the current value. +const SchemaVersion = 1 + +// Capture is one captured run of one scenario. +// +// All four corpora (acl, routes, grant, ssh) use this same shape. +// SSH scenarios populate Captures[name].SSHRules; the others populate +// Captures[name].PacketFilterRules + Captures[name].Netmap. +type Capture struct { + // SchemaVersion identifies the on-disk format version. Always set + // to testcapture.SchemaVersion when written by tscap. + SchemaVersion int `json:"schema_version"` + + // TestID is the stable identifier of the scenario, derived from + // its filename. Used as the test name in Go tests. + TestID string `json:"test_id"` + + // Description is free-form text copied from the scenario file. + // Rendered in the comment header at the top of the file. + Description string `json:"description,omitempty"` + + // Category is an optional grouping label (e.g. "routes", + // "grant", "ssh"). + Category string `json:"category,omitempty"` + + // CapturedAt is the UTC timestamp of when the capture was taken. + CapturedAt time.Time `json:"captured_at"` + + // ToolVersion identifies the binary that produced the file. + ToolVersion string `json:"tool_version"` + + // Tailnet is the name of the SaaS tailnet the capture was taken + // against (e.g. "kratail2tid@passkey"). + Tailnet string `json:"tailnet"` + + // Error is true when the SaaS API rejected the policy or when + // capture itself failed. In the rejection case, Captures reflects + // the pre-push baseline (deny-all default) and Input.APIResponseBody + // is populated. + Error bool `json:"error,omitempty"` + + // CaptureError is set when the capture itself failed (timeout, + // missing data, etc.). The partially-captured Captures map is + // still included for post-mortem. Distinct from + // Input.APIResponseBody which describes a SaaS API rejection. + CaptureError string `json:"capture_error,omitempty"` + + // Input is everything that was sent to the tailnet to produce + // the captured state. + Input Input `json:"input"` + + // Topology is the users and nodes present in the tailnet at + // capture time. Always populated by tscap. + Topology Topology `json:"topology"` + + // Captures holds the per-node captured data, keyed by node + // GivenName. + Captures map[string]Node `json:"captures"` +} + +// Input describes everything that was sent to the tailnet to produce +// the captured state. +type Input struct { + // FullPolicy is the verbatim policy that was POSTed to the SaaS + // API. Stored as raw JSON so it round-trips losslessly. + FullPolicy json.RawMessage `json:"full_policy"` + + // APIResponseCode is the HTTP status code of the policy POST. + APIResponseCode int `json:"api_response_code"` + + // APIResponseBody is only populated when APIResponseCode != 200. + APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"` + + // Tailnet describes the tailnet-wide settings tscap applied + // before pushing the policy. + Tailnet TailnetInput `json:"tailnet"` + + // ScenarioHuJSON is the verbatim contents of the scenario file + // (HuJSON). Reading this back is enough to re-run the exact + // same scenario. + ScenarioHuJSON string `json:"scenario_hujson"` + + // ScenarioPath is the path the scenario was loaded from, + // relative to the captures directory. Informational only. + ScenarioPath string `json:"scenario_path,omitempty"` +} + +// APIResponseBody is the (subset of) the SaaS API error response we keep. +type APIResponseBody struct { + Message string `json:"message,omitempty"` +} + +// TailnetInput captures tailnet-wide settings tscap applied before +// pushing the policy. +type TailnetInput struct { + DNS DNSInput `json:"dns"` + Settings SettingsInput `json:"settings"` +} + +// DNSInput describes the DNS configuration applied to the tailnet. +type DNSInput struct { + MagicDNS bool `json:"magic_dns"` + Nameservers []string `json:"nameservers"` + SearchPaths []string `json:"search_paths"` + SplitDNS map[string][]string `json:"split_dns"` +} + +// SettingsInput describes tailnet settings applied via the API. +// +// Pointer fields are nil when the scenario does not override the +// reset default for that setting. +type SettingsInput struct { + DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"` + DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"` + DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"` +} + +// Topology describes the users and nodes present in the tailnet at +// capture time. Headscale's compat tests use this to construct +// equivalent types.User and types.Node objects. +type Topology struct { + // Users in the tailnet. Always populated by tscap. + Users []TopologyUser `json:"users"` + + // Nodes in the tailnet, keyed by GivenName. + Nodes map[string]TopologyNode `json:"nodes"` +} + +// TopologyUser identifies one user account in the tailnet. +type TopologyUser struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// TopologyNode is one node in the tailnet topology. +type TopologyNode struct { + Hostname string `json:"hostname"` + Tags []string `json:"tags"` + IPv4 string `json:"ipv4"` + IPv6 string `json:"ipv6"` + + // User is the TopologyUser.Name for user-owned nodes. Empty for + // tagged nodes. + User string `json:"user,omitempty"` + + // RoutableIPs is what the node advertised + // (Hostinfo.RoutableIPs in its own netmap.SelfNode). + // May include 0.0.0.0/0 + ::/0 for exit nodes. + RoutableIPs []string `json:"routable_ips"` + + // ApprovedRoutes is the subset of RoutableIPs the tailnet has + // approved. Used by Headscale's NodeCanApproveRoute test. + ApprovedRoutes []string `json:"approved_routes"` +} + +// Node is the captured state for one node, keyed by GivenName in +// Capture.Captures. +// +// All four corpora populate the same struct. Different fields are +// used by different test types: +// +// - acl, routes, grant: PacketFilterRules + PacketFilterMatches + Netmap +// - grant (with capture_whois): + Whois +// - ssh: SSHRules +// +// Whichever fields are set in the file is what the consumer reads. +type Node struct { + // PacketFilterRules is the wire-format []tailcfg.FilterRule as + // returned by tailscaled localapi /debug-packet-filter-rules. + // The single most important field for ACL/routes/grant tests. + PacketFilterRules json.RawMessage `json:"packet_filter_rules,omitempty"` + + // PacketFilterMatches is the compiled []filtertype.Match with + // CapMatch, returned by tailscaled localapi + // /debug-packet-filter-matches. Captured alongside + // PacketFilterRules; useful for grant tests that want the + // compiled form. + PacketFilterMatches json.RawMessage `json:"packet_filter_matches,omitempty"` + + // Netmap is the full netmap as returned by tailscaled localapi + // /watch-ipn-bus?mask=NotifyInitialNetMap. NEVER trimmed. + // Consumers extract whatever fields they need. + Netmap json.RawMessage `json:"netmap,omitempty"` + + // Whois is per-peer whois lookups, keyed by peer IP. Each value + // is the verbatim WhoIsResponse JSON returned by + // /localapi/v0/whois. Captured only when + // scenario.options.capture_whois is true. + Whois map[string]json.RawMessage `json:"whois,omitempty"` + + // SSHRules is the SSH rules for SSH corpus. Same shape as + // tailcfg.SSHPolicy.Rules ([]tailcfg.SSHRule), kept as raw JSON. + // Populated only for SSH scenarios. + SSHRules json.RawMessage `json:"ssh_rules,omitempty"` +} diff --git a/hscontrol/types/testcapture/testcapture_test.go b/hscontrol/types/testcapture/testcapture_test.go new file mode 100644 index 00000000..eae2f408 --- /dev/null +++ b/hscontrol/types/testcapture/testcapture_test.go @@ -0,0 +1,399 @@ +package testcapture_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/juanfont/headscale/hscontrol/types/testcapture" +) + +func sampleACLCapture() *testcapture.Capture { + policy := json.RawMessage(`{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`) + rules := json.RawMessage(`[ + { + "SrcIPs": ["*"], + "DstPorts": [{"IP": "*", "Ports": {"First": 0, "Last": 65535}}] + } + ]`) + netmap := json.RawMessage(`{"SelfNode":{"Name":"user1.tail.example.com."}}`) + + return &testcapture.Capture{ + SchemaVersion: testcapture.SchemaVersion, + TestID: "ACL-A01", + Description: "wildcard ACL: every node sees every other node", + Category: "acl", + CapturedAt: time.Date(2026, 4, 7, 12, 34, 56, 0, time.UTC), + ToolVersion: "tscap-test-0.0.0", + Tailnet: "kratail2tid@passkey", + Input: testcapture.Input{ + FullPolicy: policy, + APIResponseCode: 200, + Tailnet: testcapture.TailnetInput{ + DNS: testcapture.DNSInput{ + MagicDNS: false, + Nameservers: []string{}, + SearchPaths: []string{}, + SplitDNS: map[string][]string{}, + }, + }, + ScenarioHuJSON: `{"id":"acl-a01","policy":{}}`, + ScenarioPath: "scenarios/acl/acl-a01.hujson", + }, + Topology: testcapture.Topology{ + Users: []testcapture.TopologyUser{ + {ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"}, + {ID: 2, Name: "kristoffer", Email: "kristoffer@dalby.cc"}, + }, + Nodes: map[string]testcapture.TopologyNode{ + "user1": { + Hostname: "user1", + IPv4: "100.90.199.68", + IPv6: "fd7a:115c:a1e0::2d01:c747", + User: "kratail2tid", + RoutableIPs: []string{}, + ApprovedRoutes: []string{}, + }, + "tagged-server": { + Hostname: "tagged-server", + Tags: []string{"tag:server"}, + IPv4: "100.108.74.26", + IPv6: "fd7a:115c:a1e0::b901:4a87", + RoutableIPs: []string{}, + ApprovedRoutes: []string{}, + }, + }, + }, + Captures: map[string]testcapture.Node{ + "user1": { + PacketFilterRules: rules, + Netmap: netmap, + }, + "tagged-server": { + PacketFilterRules: rules, + Netmap: netmap, + }, + }, + } +} + +func sampleSSHCapture() *testcapture.Capture { + policy := json.RawMessage(`{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`) + sshRules := json.RawMessage(`[{"action":{"accept":true},"principals":[{"nodeIP":"100.90.199.68"}],"sshUsers":{"root":"root"}}]`) + + return &testcapture.Capture{ + SchemaVersion: testcapture.SchemaVersion, + TestID: "SSH-A01", + Description: "ssh accept autogroup:member to autogroup:self", + Category: "ssh", + CapturedAt: time.Date(2026, 4, 7, 13, 0, 0, 0, time.UTC), + ToolVersion: "tscap-test-0.0.0", + Tailnet: "kratail2tid@passkey", + Input: testcapture.Input{ + FullPolicy: policy, + APIResponseCode: 200, + Tailnet: testcapture.TailnetInput{ + DNS: testcapture.DNSInput{ + Nameservers: []string{}, + SearchPaths: []string{}, + SplitDNS: map[string][]string{}, + }, + }, + ScenarioHuJSON: `{"id":"ssh-a01"}`, + }, + Topology: testcapture.Topology{ + Users: []testcapture.TopologyUser{ + {ID: 1, Name: "kratail2tid", Email: "kratail2tid@passkey"}, + }, + Nodes: map[string]testcapture.TopologyNode{ + "user1": { + Hostname: "user1", + IPv4: "100.90.199.68", + IPv6: "fd7a:115c:a1e0::2d01:c747", + User: "kratail2tid", + RoutableIPs: []string{}, + ApprovedRoutes: []string{}, + }, + }, + }, + Captures: map[string]testcapture.Node{ + "user1": {SSHRules: sshRules}, + }, + } +} + +// jsonRawMessageEqual is a cmp.Comparer that treats two json.RawMessage +// values as equal if they decode to the same value. This avoids false +// negatives from indentation/whitespace differences after hujson formats +// the embedded raw blobs. +func jsonRawMessageEqual(a, b json.RawMessage) bool { + var va, vb any + + err := json.Unmarshal(a, &va) + if err != nil { + return string(a) == string(b) + } + + err = json.Unmarshal(b, &vb) + if err != nil { + return string(a) == string(b) + } + + // Both va and vb came from successful Unmarshal, so they're guaranteed to + // re-marshal cleanly. The error returns here are for the linter; we treat + // them as bytewise inequality if they ever fire. + ja, jaErr := json.Marshal(va) + jb, jbErr := json.Marshal(vb) + + if jaErr != nil || jbErr != nil { + return string(a) == string(b) + } + + return string(ja) == string(jb) +} + +func captureCompareOpts() []cmp.Option { + return []cmp.Option{ + cmp.Comparer(jsonRawMessageEqual), + } +} + +func TestWriteReadRoundtrip_ACL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ACL-A01.hujson") + + in := sampleACLCapture() + + err := testcapture.Write(path, in) + if err != nil { + t.Fatalf("Write: %v", err) + } + + out, err := testcapture.Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + + if diff := cmp.Diff(in, out, captureCompareOpts()...); diff != "" { + t.Errorf("ACL roundtrip mismatch (-want +got):\n%s", diff) + } +} + +func TestWriteReadRoundtrip_SSH(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "SSH-A01.hujson") + + in := sampleSSHCapture() + + err := testcapture.Write(path, in) + if err != nil { + t.Fatalf("Write: %v", err) + } + + out, err := testcapture.Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + + if diff := cmp.Diff(in, out, captureCompareOpts()...); diff != "" { + t.Errorf("SSH roundtrip mismatch (-want +got):\n%s", diff) + } +} + +func TestWrite_ProducesCommentHeader(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ACL-A01.hujson") + + c := sampleACLCapture() + + err := testcapture.Write(path, c) + if err != nil { + t.Fatalf("Write: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + lines := strings.Split(string(raw), "\n") + if len(lines) == 0 { + t.Fatal("file is empty") + } + + // First line should be the test ID prefixed with "// ". + if want := "// ACL-A01"; lines[0] != want { + t.Errorf("first line: want %q, got %q", want, lines[0]) + } + + header := strings.Join(extractHeaderLines(lines), "\n") + if !strings.Contains(header, "wildcard ACL") { + t.Errorf("header missing description; got:\n%s", header) + } + + if !strings.Contains(header, "Nodes with filter rules: 2 of 2") { + t.Errorf("header missing stats line; got:\n%s", header) + } + + if !strings.Contains(header, "Captured at:") || !strings.Contains(header, "2026-04-07T12:34:56Z") { + t.Errorf("header missing capture timestamp; got:\n%s", header) + } + + if !strings.Contains(header, "tscap version:") || !strings.Contains(header, "tscap-test-0.0.0") { + t.Errorf("header missing tool version; got:\n%s", header) + } + + if !strings.Contains(header, "schema version: 1") { + t.Errorf("header missing schema version; got:\n%s", header) + } +} + +func TestWrite_SSH_StatsUseSSHRules(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "SSH-A01.hujson") + + c := sampleSSHCapture() + + err := testcapture.Write(path, c) + if err != nil { + t.Fatalf("Write: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + header := strings.Join(extractHeaderLines(strings.Split(string(raw), "\n")), "\n") + if !strings.Contains(header, "Nodes with SSH rules: 1 of 1") { + t.Errorf("ssh stats line missing; got:\n%s", header) + } +} + +func TestRead_HuJSONWithComments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manual.hujson") + + const content = `// hand-written +// comments + trailing commas +{ + "schema_version": 1, + "test_id": "MANUAL", + "captured_at": "2026-04-07T12:00:00Z", + "tool_version": "test", + "tailnet": "example.com", + "input": { + "full_policy": {}, + "api_response_code": 200, + "tailnet": { + "dns": { + "magic_dns": false, + "nameservers": [], + "search_paths": [], + "split_dns": {}, + }, + "settings": {}, + }, + "scenario_hujson": "", + }, + "topology": { + "users": [], + "nodes": {}, + }, + "captures": {}, +} +` + + err := os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + + c, err := testcapture.Read(path) + if err != nil { + t.Fatalf("Read: %v", err) + } + + if c.TestID != "MANUAL" { + t.Errorf("TestID = %q, want MANUAL", c.TestID) + } + + if c.SchemaVersion != testcapture.SchemaVersion { + t.Errorf("SchemaVersion = %d, want %d", c.SchemaVersion, testcapture.SchemaVersion) + } +} + +func TestWrite_NilCapture(t *testing.T) { + err := testcapture.Write(filepath.Join(t.TempDir(), "x.hujson"), nil) + if err == nil { + t.Fatal("Write(nil) returned nil error, want error") + } +} + +func TestCommentHeader_NilSafe(t *testing.T) { + if got := testcapture.CommentHeader(nil); got != "" { + t.Errorf("CommentHeader(nil) = %q, want empty", got) + } +} + +func TestCommentHeader_ZeroTime(t *testing.T) { + c := &testcapture.Capture{TestID: "ZERO"} + got := testcapture.CommentHeader(c) + + if strings.Contains(got, "Captured at") { + t.Errorf("zero time should not produce 'Captured at': %q", got) + } + + if !strings.HasPrefix(got, "ZERO") { + t.Errorf("header should start with TestID: %q", got) + } +} + +func TestCommentHeader_NoStatsForEmptyCaptures(t *testing.T) { + c := &testcapture.Capture{TestID: "EMPTY"} + + header := testcapture.CommentHeader(c) + if strings.Contains(header, "filter rules") || strings.Contains(header, "SSH rules") { + t.Errorf("empty captures should not produce stats line: %q", header) + } +} + +func TestCommentHeader_NullFilterRulesCountAsEmpty(t *testing.T) { + c := &testcapture.Capture{ + TestID: "NULLS", + Captures: map[string]testcapture.Node{ + "a": {PacketFilterRules: json.RawMessage(`null`)}, + "b": {PacketFilterRules: json.RawMessage(`[]`)}, + "c": {PacketFilterRules: json.RawMessage(`[{"SrcIPs":["*"]}]`)}, + }, + } + + header := testcapture.CommentHeader(c) + if !strings.Contains(header, "Nodes with filter rules: 1 of 3") { + t.Errorf("expected '1 of 3' in header; got:\n%s", header) + } +} + +// extractHeaderLines returns the leading "// ..." comment lines from a +// slice of raw file lines, stripped of the "// " prefix. Stops at the +// first non-comment line. +func extractHeaderLines(lines []string) []string { + var out []string + + for _, l := range lines { + switch { + case strings.HasPrefix(l, "// "): + out = append(out, strings.TrimPrefix(l, "// ")) + case l == "//": + out = append(out, "") + default: + return out + } + } + + return out +} diff --git a/hscontrol/types/testcapture/write.go b/hscontrol/types/testcapture/write.go new file mode 100644 index 00000000..2284b738 --- /dev/null +++ b/hscontrol/types/testcapture/write.go @@ -0,0 +1,93 @@ +package testcapture + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/tailscale/hujson" +) + +// ErrNilCapture is returned by Write when called with a nil Capture. +var ErrNilCapture = errors.New("testcapture: nil capture") + +// Write serializes c as a HuJSON file with a comment header. +// +// The comment header is built by CommentHeader from c's TestID, +// Description, and Captures. If the file's parent directory does +// not exist, Write returns the error from os.WriteFile (it does +// not create the directory itself; callers should MkdirAll first). +func Write(path string, c *Capture) error { + if c == nil { + return fmt.Errorf("testcapture: Write %s: %w", path, ErrNilCapture) + } + + header := CommentHeader(c) + + body, err := marshalHuJSON(c) + if err != nil { + return fmt.Errorf("testcapture: marshal %s: %w", path, err) + } + + data := prependCommentHeader(body, header) + + err = os.WriteFile(path, data, 0o600) + if err != nil { + return fmt.Errorf("testcapture: write %s: %w", path, err) + } + + return nil +} + +// marshalHuJSON serializes v as HuJSON-formatted bytes. It is +// standard JSON encoding followed by hujson.Format which produces +// consistent indentation/whitespace. +func marshalHuJSON(v any) ([]byte, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("json marshal: %w", err) + } + + formatted, err := hujson.Format(raw) + if err != nil { + return nil, fmt.Errorf("hujson format: %w", err) + } + + return formatted, nil +} + +// prependCommentHeader emits header as // comment lines at the top of +// body. Empty lines in header become "//" alone (no trailing space). +// The returned bytes always end with a single trailing newline. +func prependCommentHeader(body []byte, header string) []byte { + if header == "" { + if len(body) == 0 || body[len(body)-1] != '\n' { + body = append(body, '\n') + } + + return body + } + + var buf strings.Builder + + for line := range strings.SplitSeq(header, "\n") { + if line == "" { + buf.WriteString("//\n") + continue + } + + buf.WriteString("// ") + buf.WriteString(line) + buf.WriteByte('\n') + } + + buf.Write(body) + + if !strings.HasSuffix(buf.String(), "\n") { + buf.WriteByte('\n') + } + + return []byte(buf.String()) +}