diff --git a/hscontrol/policy/v2/sshtester_compat_test.go b/hscontrol/policy/v2/sshtester_compat_test.go new file mode 100644 index 00000000..3c3057d9 --- /dev/null +++ b/hscontrol/policy/v2/sshtester_compat_test.go @@ -0,0 +1,97 @@ +// Compatibility tests for the policy `sshTests` block, replaying captures +// recorded against a real Tailscale SaaS tailnet. The runner mirrors the +// pattern in policytester_compat_test.go: a single Glob over a testdata +// directory, one t.Run per file. Each capture is one of: +// +// - APIResponseCode != 200 — the policy was rejected by SaaS, the +// captured Message is the body the user saw, and headscale must +// reject the same input with an error string that contains the same +// body (substring match, allowing wrapping like "test(s) failed:\n…"). +// - APIResponseCode == 200 — SaaS accepted the policy (its sshTests +// block passed); headscale's evaluateSSHTests must also pass. +// +// Captures live in testdata/sshtest_results/*.hujson. Scenarios in +// knownSSHTesterDivergences are skipped with their tracking note — +// these are real Tailscale ↔ headscale divergences that need engine-level +// fixes in follow-up PRs. +// +// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture + +package v2 + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/types/testcapture" + "github.com/stretchr/testify/require" +) + +// knownSSHTesterDivergences tracks scenarios where headscale and SaaS +// disagree on whether a policy is accepted. Empty until captures land +// and any genuine engine bugs are uncovered. +var knownSSHTesterDivergences = map[string]string{} + +func TestSSHTesterCompat(t *testing.T) { + t.Parallel() + + files, err := filepath.Glob(filepath.Join("testdata", "sshtest_results", "*.hujson")) + require.NoError(t, err, "failed to glob test files") + + if len(files) == 0 { + t.Skip("no sshtest captures yet") + } + + users := setupSSHDataCompatUsers() + nodes := setupSSHDataCompatNodes(users) + + for _, file := range files { + c, err := testcapture.Read(file) + require.NoError(t, err, "reading %s", file) + + t.Run(c.TestID, func(t *testing.T) { + t.Parallel() + + if reason, skip := knownSSHTesterDivergences[c.TestID]; skip { + t.Skip(reason) + } + + policyJSON := []byte(c.Input.FullPolicy) + + pm, parseErr := NewPolicyManager(policyJSON, users, nodes.ViewSlice()) + + if c.Input.APIResponseCode == 200 { + require.NoError(t, parseErr, + "tailscale accepted this policy; headscale must parse it") + + _, setErr := pm.SetPolicy(policyJSON) + require.NoError(t, setErr, + "tailscale accepted this policy; headscale sshTests must pass") + + return + } + + var got error + + switch { + case parseErr != nil: + got = parseErr + default: + _, setErr := pm.SetPolicy(policyJSON) + got = setErr + } + + require.Error(t, got, "tailscale rejected; headscale must reject too") + + if c.Input.APIResponseBody == nil || c.Input.APIResponseBody.Message == "" { + return + } + + want := c.Input.APIResponseBody.Message + if !strings.Contains(got.Error(), want) { + t.Errorf("error body mismatch\n tailscale wants: %q\n headscale got: %q", want, got.Error()) + } + }) + } +}