mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-07 16:40:21 +09:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user