From f678bcb6c23ceb28673ed9cb7cd5515840be2811 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 3 Apr 2026 09:23:21 +0000 Subject: [PATCH] policy/v2: add filter compilation equivalence tests Updates #3157 --- .../v2/tailscale_acl_data_compat_test.go | 121 ++++++++++++++++++ .../policy/v2/tailscale_grants_compat_test.go | 113 ++++++++++++++++ .../v2/tailscale_routes_data_compat_test.go | 94 ++++++++++++++ 3 files changed, 328 insertions(+) diff --git a/hscontrol/policy/v2/tailscale_acl_data_compat_test.go b/hscontrol/policy/v2/tailscale_acl_data_compat_test.go index ec56a408..9d764fe9 100644 --- a/hscontrol/policy/v2/tailscale_acl_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_acl_data_compat_test.go @@ -483,3 +483,124 @@ func testACLSuccess( }) } } + +// TestACLCompatGlobalEquivalence verifies that the global filter +// compilation path (PolicyManager.FilterForNode) produces the same +// output as the per-node path (compileFilterRulesForNode + +// ReduceFilterRules) for all ACL golden files that use ACL-only +// policies (no autogroup:self, no via grants). +// +// Files using autogroup:self are skipped because they correctly +// require per-node compilation (needsPerNodeFilter=true), where +// the global path intentionally diverges. +func TestACLCompatGlobalEquivalence(t *testing.T) { + t.Parallel() + + files, err := filepath.Glob( + filepath.Join("testdata", "acl_results", "ACL-*.hujson"), + ) + require.NoError(t, err) + require.NotEmpty(t, files) + + users := setupACLCompatUsers() + nodes := setupACLCompatNodes(users) + + tested := 0 + skippedSelf := 0 + skippedErr := 0 + + for _, file := range files { + tf := loadACLTestFile(t, file) + + t.Run(tf.TestID, func(t *testing.T) { + t.Parallel() + + if reason, ok := aclSkipReasons[tf.TestID]; ok { + t.Skipf("TODO: %s", reason) + + return + } + + if tf.Error { + skippedErr++ + + t.Skip("error case, not applicable") + + return + } + + policyJSON := convertPolicyUserEmails( + tf.Input.FullPolicy, + ) + + // Check if policy uses autogroup:self — if so, the + // global and per-node paths intentionally diverge. + if strings.Contains(string(policyJSON), "autogroup:self") { + skippedSelf++ + + t.Skip( + "uses autogroup:self, global path " + + "intentionally diverges", + ) + + return + } + + // Per-node path. + pol, err := unmarshalPolicy(policyJSON) + require.NoError(t, err) + + err = pol.validate() + require.NoError(t, err) + + // Global path. + pm, err := NewPolicyManager( + policyJSON, users, nodes.ViewSlice(), + ) + require.NoError(t, err) + + for _, node := range nodes { + nv := node.View() + + perNodeRules, err := pol.compileFilterRulesForNode( + users, nv, nodes.ViewSlice(), + ) + require.NoError(t, err) + + perNodeReduced := policyutil.ReduceFilterRules( + nv, perNodeRules, + ) + + globalReduced, err := pm.FilterForNode(nv) + require.NoError(t, err) + + opts := append( + cmpOptions(), + cmpopts.EquateEmpty(), + ) + if diff := cmp.Diff( + perNodeReduced, + globalReduced, + opts..., + ); diff != "" { + t.Errorf( + "%s/%s: global vs per-node "+ + "filter mismatch "+ + "(-perNode +global):\n%s", + tf.TestID, + node.GivenName, + diff, + ) + } + } + + tested++ + }) + } + + t.Logf( + "ACL global equivalence: tested=%d, "+ + "skippedSelf=%d, skippedErr=%d", + tested, skippedSelf, skippedErr, + ) +} diff --git a/hscontrol/policy/v2/tailscale_grants_compat_test.go b/hscontrol/policy/v2/tailscale_grants_compat_test.go index c87f1fc3..df53f1fd 100644 --- a/hscontrol/policy/v2/tailscale_grants_compat_test.go +++ b/hscontrol/policy/v2/tailscale_grants_compat_test.go @@ -618,3 +618,116 @@ func testGrantSuccess( }) } } + +// TestGrantsCompatGlobalEquivalence verifies that the global filter +// compilation path (PolicyManager.FilterForNode) produces the same +// output as the per-node path (compileFilterRulesForNode + +// ReduceFilterRules) for all GRANT golden files that don't require +// per-node compilation. +// +// Files using via grants or autogroup:self are skipped because they +// correctly require per-node compilation (needsPerNodeFilter=true). +func TestGrantsCompatGlobalEquivalence(t *testing.T) { + t.Parallel() + + files, err := filepath.Glob( + filepath.Join("testdata", "grant_results", "GRANT-*.hujson"), + ) + require.NoError(t, err) + require.NotEmpty(t, files) + + users := setupGrantsCompatUsers() + allNodes := setupGrantsCompatNodes(users) + + for _, file := range files { + tf := loadGrantTestFile(t, file) + + t.Run(tf.TestID, func(t *testing.T) { + t.Parallel() + + if reason, ok := grantSkipReasons[tf.TestID]; ok { + t.Skipf("TODO: %s", reason) + + return + } + + if tf.Error { + t.Skip("error case") + + return + } + + policyJSON := convertPolicyUserEmails( + tf.Input.FullPolicy, + ) + policyStr := string(policyJSON) + + // Skip files that require per-node compilation. + if strings.Contains(policyStr, "autogroup:self") { + t.Skip("uses autogroup:self") + + return + } + + if strings.Contains(policyStr, "\"via\"") { + t.Skip("uses via grants") + + return + } + + // Select nodes based on topology size. + nodes := allNodes + if _, hasNewNodes := tf.Captures["exit-a"]; !hasNewNodes { + nodes = allNodes[:8] + } + + // Per-node path. + pol, err := unmarshalPolicy(policyJSON) + require.NoError(t, err) + + err = pol.validate() + require.NoError(t, err) + + // Global path. + pm, err := NewPolicyManager( + policyJSON, users, nodes.ViewSlice(), + ) + require.NoError(t, err) + + for _, node := range nodes { + nv := node.View() + + perNodeRules, err := pol.compileFilterRulesForNode( + users, nv, nodes.ViewSlice(), + ) + require.NoError(t, err) + + perNodeReduced := policyutil.ReduceFilterRules( + nv, perNodeRules, + ) + + globalReduced, err := pm.FilterForNode(nv) + require.NoError(t, err) + + opts := append( + cmpOptions(), + cmpopts.EquateEmpty(), + ) + if diff := cmp.Diff( + perNodeReduced, + globalReduced, + opts..., + ); diff != "" { + t.Errorf( + "%s/%s: global vs per-node "+ + "filter mismatch "+ + "(-perNode +global):\n%s", + tf.TestID, + node.GivenName, + diff, + ) + } + } + }) + } +} diff --git a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go index 5d934a33..70445120 100644 --- a/hscontrol/policy/v2/tailscale_routes_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_routes_data_compat_test.go @@ -974,6 +974,100 @@ func TestRoutesCompatReduceRoutes(t *testing.T) { } } +// TestRoutesCompatGlobalEquivalence verifies that the global filter +// compilation path (compileFilterRules + ReduceFilterRules, used in +// production for ACL-only policies) produces the same output as the +// per-node path (compileFilterRulesForNode + ReduceFilterRules, used +// by TestRoutesCompat). +// +// All 98 ROUTES golden files use ACL-only policies (no autogroup:self, +// no via grants), so production code takes the global path. The +// TestRoutesCompat test always uses the per-node path. If these two +// paths ever diverge, this test will detect it. +func TestRoutesCompatGlobalEquivalence(t *testing.T) { + t.Parallel() + + files, err := filepath.Glob( + 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", + ) + + for _, file := range files { + tf := loadRoutesTestFile(t, file) + + t.Run(tf.TestID, func(t *testing.T) { + t.Parallel() + + if reason, ok := routesSkipReasons[tf.TestID]; ok { + t.Skipf("TODO: %s", reason) + + return + } + + users, nodes := buildRoutesUsersAndNodes(t, tf.Topology) + policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy) + + // Per-node path: compile per-node, then reduce. + pol, err := unmarshalPolicy(policyJSON) + require.NoError(t, err) + + err = pol.validate() + require.NoError(t, err) + + // Global path: create PolicyManager (compiles global + // filter internally). + pm, err := NewPolicyManager( + policyJSON, users, nodes.ViewSlice(), + ) + require.NoError(t, err, + "%s: failed to create PolicyManager", tf.TestID, + ) + + for _, node := range nodes { + nv := node.View() + + // Per-node path (what TestRoutesCompat uses). + perNodeRules, err := pol.compileFilterRulesForNode( + users, nv, nodes.ViewSlice(), + ) + require.NoError(t, err) + + perNodeReduced := policyutil.ReduceFilterRules( + nv, perNodeRules, + ) + + // Global path (what production FilterForNode + // uses for ACL-only policies). + globalReduced, err := pm.FilterForNode(nv) + require.NoError(t, err) + + opts := append( + cmpOptions(), + cmpopts.EquateEmpty(), + ) + if diff := cmp.Diff( + perNodeReduced, + globalReduced, + opts..., + ); diff != "" { + t.Errorf( + "%s/%s: global vs per-node filter "+ + "mismatch (-perNode +global):\n%s", + tf.TestID, + node.GivenName, + diff, + ) + } + } + }) + } +} + // TestRoutesCompatNoFalsePositivePeers verifies that nodes which do NOT // have subnet routes overlapping an ACL's source or destination CIDRs // are NOT incorrectly peered with subnet routers.