2026-04-15 08:32:10 +00:00
|
|
|
// This file implements a data-driven test runner for grant compatibility
|
|
|
|
|
// tests. It loads HuJSON golden files from testdata/grant_results/grant-*.hujson
|
policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.
tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.
testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.
The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
2026-05-11 14:51:09 +00:00
|
|
|
// and via-grant-*.hujson, captured from a Tailscale-hosted control plane, and compares
|
2026-04-15 08:32:10 +00:00
|
|
|
// headscale's grants engine output against the captured packet filter rules.
|
2026-02-23 15:39:54 +00:00
|
|
|
//
|
2026-04-15 08:32:10 +00:00
|
|
|
// 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
|
2026-02-23 15:39:54 +00:00
|
|
|
//
|
2026-04-15 08:32:10 +00:00
|
|
|
// 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.
|
2026-02-23 15:39:54 +00:00
|
|
|
//
|
2026-04-15 08:32:10 +00:00
|
|
|
// Test data source: testdata/grant_results/{grant,via-grant}-*.hujson
|
|
|
|
|
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
2026-02-23 15:39:54 +00:00
|
|
|
|
|
|
|
|
package v2
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-18 13:40:36 +00:00
|
|
|
"net/netip"
|
2026-02-23 15:39:54 +00:00
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
|
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
|
|
|
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
2026-04-15 08:32:10 +00:00
|
|
|
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
2026-02-23 15:39:54 +00:00
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
"tailscale.com/tailcfg"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// setupGrantsCompatUsers returns the 3 test users for grants compatibility tests.
|
2026-04-15 08:32:10 +00:00
|
|
|
// Users get norse-god names; nodes get original-151 pokémon names — matching
|
policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.
tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.
testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.
The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
2026-05-11 14:51:09 +00:00
|
|
|
// the anonymized identifiers the capture tool writes into the capture files
|
|
|
|
|
// .
|
2026-02-23 15:39:54 +00:00
|
|
|
func setupGrantsCompatUsers() types.Users {
|
|
|
|
|
return types.Users{
|
2026-04-15 08:32:10 +00:00
|
|
|
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
|
|
|
|
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
|
|
|
|
|
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
|
2026-02-23 15:39:54 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// findGrantsNode finds a node by its GivenName in the grants test environment.
|
|
|
|
|
func findGrantsNode(nodes types.Nodes, name string) *types.Node {
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
if n.GivenName == name {
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
// buildGrantsNodesFromCapture constructs types.Nodes from a capture's
|
policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.
tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.
testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.
The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
2026-05-11 14:51:09 +00:00
|
|
|
// topology section. Each scenario in the capture tool uses clean-slate mode, so
|
2026-04-15 08:32:10 +00:00
|
|
|
// node IPs differ between scenarios; this builds the node set with
|
|
|
|
|
// the IPs that were actually present during that capture.
|
|
|
|
|
func buildGrantsNodesFromCapture(
|
|
|
|
|
users types.Users,
|
|
|
|
|
tf *testcapture.Capture,
|
|
|
|
|
) types.Nodes {
|
|
|
|
|
nodes := make(types.Nodes, 0, len(tf.Topology.Nodes))
|
|
|
|
|
autoID := 1
|
|
|
|
|
|
|
|
|
|
for _, nodeDef := range tf.Topology.Nodes {
|
|
|
|
|
node := &types.Node{
|
|
|
|
|
ID: types.NodeID(autoID), //nolint:gosec
|
|
|
|
|
GivenName: nodeDef.Hostname,
|
|
|
|
|
IPv4: ptrAddr(nodeDef.IPv4),
|
|
|
|
|
IPv6: ptrAddr(nodeDef.IPv6),
|
|
|
|
|
Tags: nodeDef.Tags,
|
|
|
|
|
}
|
|
|
|
|
autoID++
|
2026-02-23 15:39:54 +00:00
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
hostinfo := &tailcfg.Hostinfo{}
|
2026-02-23 15:39:54 +00:00
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
if len(nodeDef.RoutableIPs) > 0 {
|
|
|
|
|
routableIPs := make([]netip.Prefix, 0, len(nodeDef.RoutableIPs))
|
|
|
|
|
for _, r := range nodeDef.RoutableIPs {
|
|
|
|
|
routableIPs = append(routableIPs, netip.MustParsePrefix(r))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hostinfo.RoutableIPs = routableIPs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
node.Hostinfo = hostinfo
|
|
|
|
|
|
|
|
|
|
if len(nodeDef.ApprovedRoutes) > 0 {
|
|
|
|
|
approved := make([]netip.Prefix, 0, len(nodeDef.ApprovedRoutes))
|
|
|
|
|
for _, r := range nodeDef.ApprovedRoutes {
|
|
|
|
|
approved = append(approved, netip.MustParsePrefix(r))
|
|
|
|
|
}
|
2026-02-23 15:39:54 +00:00
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
node.ApprovedRoutes = approved
|
|
|
|
|
} else {
|
|
|
|
|
node.ApprovedRoutes = []netip.Prefix{}
|
|
|
|
|
}
|
2026-03-30 17:08:26 +00:00
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
// Assign user — untagged nodes look up by User field.
|
|
|
|
|
if len(nodeDef.Tags) == 0 && nodeDef.User != "" {
|
|
|
|
|
for i := range users {
|
|
|
|
|
if users[i].Name == nodeDef.User {
|
|
|
|
|
node.User = &users[i]
|
|
|
|
|
node.UserID = &users[i].ID
|
2026-02-23 15:39:54 +00:00
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-23 15:39:54 +00:00
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
nodes = append(nodes, node)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nodes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
|
policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.
tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.
testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.
The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
2026-05-11 14:51:09 +00:00
|
|
|
// captures anonymize the policy JSON at write time (kratail2tid -> odin,
|
2026-04-15 08:32:10 +00:00
|
|
|
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
|
|
|
|
|
// FullPolicy is already in its final form and this is a passthrough that
|
|
|
|
|
// just adapts the captured string value to the []byte that the policy
|
|
|
|
|
// parser expects.
|
|
|
|
|
func convertPolicyUserEmails(policyJSON string) []byte {
|
|
|
|
|
return []byte(policyJSON)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loadGrantTestFile loads and parses a single grant capture HuJSON file.
|
|
|
|
|
func loadGrantTestFile(t *testing.T, path string) *testcapture.Capture {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
c, err := testcapture.Read(path)
|
|
|
|
|
require.NoError(t, err, "failed to read test file %s", path)
|
|
|
|
|
|
|
|
|
|
return c
|
2026-02-23 15:39:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 13:15:09 +00:00
|
|
|
// Skip categories document WHY tests are expected to differ from Tailscale SaaS.
|
|
|
|
|
// Tests are grouped by root cause.
|
2026-02-23 15:39:54 +00:00
|
|
|
//
|
2026-03-30 13:15:09 +00:00
|
|
|
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern not supported
|
2026-02-23 15:39:54 +00:00
|
|
|
//
|
2026-03-30 13:15:09 +00:00
|
|
|
// Total: 2 tests skipped, ~246 tests expected to pass.
|
2026-02-23 15:39:54 +00:00
|
|
|
var grantSkipReasons = map[string]string{
|
2026-03-04 16:16:40 +01:00
|
|
|
// USER_PASSKEY_WILDCARD (2 tests)
|
|
|
|
|
//
|
|
|
|
|
// Tailscale SaaS policies can use user:*@passkey as a wildcard matching
|
2026-03-30 13:15:09 +00:00
|
|
|
// all passkey-authenticated users. headscale does not support passkey
|
|
|
|
|
// authentication and has no equivalent for this wildcard pattern.
|
2026-04-15 08:32:10 +00:00
|
|
|
"grant-k20": "USER_PASSKEY_WILDCARD: src=user:*@passkey not supported in headscale",
|
|
|
|
|
"grant-k21": "USER_PASSKEY_WILDCARD: dst=user:*@passkey not supported in headscale",
|
2026-02-23 15:39:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 13:15:09 +00:00
|
|
|
// TestGrantsCompat is a data-driven test that loads all GRANT-*.json
|
2026-02-23 15:39:54 +00:00
|
|
|
// test files captured from Tailscale SaaS and compares headscale's grants
|
|
|
|
|
// engine output against the real Tailscale behavior.
|
|
|
|
|
//
|
|
|
|
|
// Each JSON file contains:
|
|
|
|
|
// - A full policy (groups, tagOwners, hosts, autoApprovers, grants, optionally acls)
|
2026-03-30 13:15:09 +00:00
|
|
|
// - For success cases: expected packet_filter_rules per node
|
2026-02-23 15:39:54 +00:00
|
|
|
// - For error cases: expected error message
|
|
|
|
|
//
|
2026-04-15 08:32:10 +00:00
|
|
|
// The test converts Tailscale user email formats to headscale format
|
|
|
|
|
// (@example.com, @example.org) and runs the policy through unmarshalPolicy,
|
2026-02-23 15:39:54 +00:00
|
|
|
// validate, compileFilterRulesForNode, and ReduceFilterRules.
|
|
|
|
|
//
|
2026-03-30 13:15:09 +00:00
|
|
|
// 2 tests are skipped for user:*@passkey wildcard (not supported in headscale).
|
2026-02-23 15:39:54 +00:00
|
|
|
func TestGrantsCompat(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
files, err := filepath.Glob(filepath.Join("testdata", "grant_results", "*-*.hujson"))
|
2026-02-23 15:39:54 +00:00
|
|
|
require.NoError(t, err, "failed to glob test files")
|
2026-04-15 08:32:10 +00:00
|
|
|
require.NotEmpty(t, files, "no grant test files found in testdata/grant_results/")
|
2026-02-23 15:39:54 +00:00
|
|
|
|
|
|
|
|
t.Logf("Loaded %d grant test files", len(files))
|
|
|
|
|
|
|
|
|
|
users := setupGrantsCompatUsers()
|
|
|
|
|
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
tf := loadGrantTestFile(t, file)
|
|
|
|
|
|
|
|
|
|
t.Run(tf.TestID, func(t *testing.T) {
|
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
|
|
// Check if this test is in the skip list
|
|
|
|
|
if reason, ok := grantSkipReasons[tf.TestID]; ok {
|
|
|
|
|
t.Skipf("TODO: %s — see grantSkipReasons comments for details", reason)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
// Build nodes per-scenario from this file's topology.
|
policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.
tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.
testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.
The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
2026-05-11 14:51:09 +00:00
|
|
|
// the capture tool uses clean-slate mode, so each scenario has
|
2026-04-15 08:32:10 +00:00
|
|
|
// different node IPs.
|
|
|
|
|
nodes := buildGrantsNodesFromCapture(users, tf)
|
2026-03-28 11:05:16 +00:00
|
|
|
|
2026-04-29 14:41:37 +00:00
|
|
|
// Use the captured full policy as is (anonymization
|
policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson
and diffs the captured SaaS-rendered netmaps against headscale's
compileNodeAttrs output. Each capture is one scenario the SaaS
control plane has rendered against the same policy headscale is asked
to compile -- the test enforces shape parity per node.
tailnet_state_caps.go enumerates the caps SaaS emits where headscale
has no equivalent concept yet (user-role admin/owner, tailnet lock,
services host, app connectors, internal magicsock and SSH tuning,
tailnet-state metadata) plus the always-on baseline (admin, ssh,
file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps
filters both sides of cmp.Diff so the comparison focuses on the
policy-driven caps. PeerCapMap encodes which caps the Tailscale
client reads from the peer view (suggest-exit-node when exit routes
are approved, etc.) for use by the mapper.
testcapture switches to typed tailcfg/netmap/filtertype/apitype
values so schema drift between the capture tool and headscale
becomes a compile error rather than a silent test failure. Existing
compat suites (acl, grants, routes, ssh, issue_3212) move to the
typed shape.
The 53 SelfNode netmap captures and the 7 anonymizer-corrupted
suggest-charmander -> suggest-exit-node restorations in
routes_results / issue_3212 ride along.
2026-05-11 14:51:09 +00:00
|
|
|
// the capture tool already rewrote SaaS emails).
|
2026-02-23 15:39:54 +00:00
|
|
|
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
|
|
|
|
|
|
|
|
|
if tf.Input.APIResponseCode == 400 || tf.Error {
|
|
|
|
|
testGrantError(t, policyJSON, tf)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
testGrantSuccess(t, policyJSON, tf, users, nodes)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// testGrantError verifies that an invalid policy produces the expected error.
|
2026-04-15 08:32:10 +00:00
|
|
|
func testGrantError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
|
2026-02-23 15:39:54 +00:00
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
wantMsg := ""
|
|
|
|
|
if tf.Input.APIResponseBody != nil {
|
|
|
|
|
wantMsg = tf.Input.APIResponseBody.Message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pol, err := unmarshalPolicy(policyJSON)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// Parse-time error
|
|
|
|
|
if wantMsg != "" {
|
|
|
|
|
assertGrantErrorContains(t, err, wantMsg, tf.TestID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = pol.validate()
|
|
|
|
|
if err != nil {
|
|
|
|
|
// Validation error
|
|
|
|
|
if wantMsg != "" {
|
|
|
|
|
assertGrantErrorContains(t, err, wantMsg, tf.TestID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
t.Errorf("%s: expected error (api_response_code=400) but policy parsed and validated successfully; want message: %q",
|
|
|
|
|
tf.TestID, wantMsg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
// assertGrantErrorContains requires that headscale's error contains
|
2026-04-29 14:41:37 +00:00
|
|
|
// the Tailscale SaaS error message exactly. Divergence means an
|
2026-04-15 08:32:10 +00:00
|
|
|
// emitter needs to be aligned, not papered over with a translation
|
|
|
|
|
// table.
|
2026-02-23 15:39:54 +00:00
|
|
|
func assertGrantErrorContains(t *testing.T, err error, wantMsg string, testID string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
errStr := err.Error()
|
|
|
|
|
if strings.Contains(errStr, wantMsg) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
t.Errorf("%s: error message mismatch\n tailscale wants: %q\n headscale got: %q",
|
|
|
|
|
testID, wantMsg, errStr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// testGrantSuccess verifies that a valid policy produces the expected
|
|
|
|
|
// packet filter rules for each node.
|
|
|
|
|
func testGrantSuccess(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
policyJSON []byte,
|
2026-04-15 08:32:10 +00:00
|
|
|
tf *testcapture.Capture,
|
2026-02-23 15:39:54 +00:00
|
|
|
users types.Users,
|
|
|
|
|
nodes types.Nodes,
|
|
|
|
|
) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
pol, err := unmarshalPolicy(policyJSON)
|
|
|
|
|
require.NoError(t, err, "%s: policy should parse successfully", tf.TestID)
|
|
|
|
|
|
|
|
|
|
err = pol.validate()
|
|
|
|
|
require.NoError(t, err, "%s: policy should validate successfully", tf.TestID)
|
|
|
|
|
|
|
|
|
|
for nodeName, capture := range tf.Captures {
|
|
|
|
|
t.Run(nodeName, func(t *testing.T) {
|
|
|
|
|
node := findGrantsNode(nodes, nodeName)
|
2026-04-15 08:32:10 +00:00
|
|
|
require.NotNilf(t, node,
|
|
|
|
|
"golden node %s not found in test setup", nodeName)
|
2026-02-23 15:39:54 +00:00
|
|
|
|
|
|
|
|
// Compile headscale filter rules for this node
|
2026-05-18 18:32:58 +00:00
|
|
|
gotRules := pol.compileFilterRulesForNode(
|
2026-02-23 15:39:54 +00:00
|
|
|
users,
|
|
|
|
|
node.View(),
|
|
|
|
|
nodes.ViewSlice(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
gotRules = policyutil.ReduceFilterRules(node.View(), gotRules)
|
|
|
|
|
|
2026-04-15 08:32:10 +00:00
|
|
|
wantRules := capture.PacketFilterRules
|
2026-02-23 15:39:54 +00:00
|
|
|
|
|
|
|
|
// Compare headscale output against Tailscale expected output.
|
|
|
|
|
// The diff labels show (-tailscale +headscale) to make clear
|
|
|
|
|
// which side produced which output.
|
|
|
|
|
// EquateEmpty treats nil and empty slices as equal since
|
|
|
|
|
// Tailscale's JSON null -> nil, headscale may return empty slice.
|
|
|
|
|
opts := append(cmpOptions(), cmpopts.EquateEmpty())
|
|
|
|
|
if diff := cmp.Diff(wantRules, gotRules, opts...); diff != "" {
|
|
|
|
|
t.Errorf(
|
|
|
|
|
"%s/%s: filter rules mismatch (-tailscale +headscale):\n%s",
|
|
|
|
|
tf.TestID,
|
|
|
|
|
nodeName,
|
|
|
|
|
diff,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|