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
This commit is contained in:
Kristoffer Dalby
2026-04-08 08:05:45 +00:00
parent 211da43f57
commit e30de58627
9 changed files with 1034 additions and 326 deletions
@@ -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,
) {
@@ -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,
) {
@@ -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)
@@ -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,
+126
View File
@@ -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:
//
// <TestID>
//
// <Description, possibly multi-line>
//
// Nodes with filter rules: <X> of <Y> ← for non-SSH corpora
// Nodes with SSH rules: <X> of <Y> ← for SSH corpora
// Captured at: <RFC3339 UTC>
// tscap version: <ToolVersion>
// schema version: <SchemaVersion>
//
// 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
}
}
+49
View File
@@ -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
}
+220
View File
@@ -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"`
}
@@ -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
}
+93
View File
@@ -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())
}