mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-07 16:40:21 +09:00
policy/v2: add global vs per-node filter compilation equivalence tests
Add equivalence tests that compare the global filter path (PolicyManager.FilterForNode) against the per-node path (compileFilterRulesForNode + ReduceFilterRules) for all golden files where the two paths should produce identical output. TestRoutesCompatGlobalEquivalence: 98 ROUTES files, 12 nodes each. All pass (1,176 comparisons). TestACLCompatGlobalEquivalence: ~152 ACL files (excluding error and autogroup:self cases), 8 nodes each. Surfaces 2 divergences (ACL-M06, ACL-K01) where the global path loses individual non-wildcard source IPs absorbed into the CGNAT range. TestGrantsCompatGlobalEquivalence: ~186 GRANT files (excluding error, autogroup:self, and via cases), 8-15 nodes each. Surfaces 3 divergences (GRANT-P09_7A, GRANT-K29, GRANT-H6) with the same root cause. The per-node path matches Tailscale SaaS golden data in all 5 divergent cases, confirming it preserves nonWildcardSrcs correctly. Updates #3157 Updates #3169
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user