policy/v2: add sshtester compat runner

Replays recorded policy responses for the sshTests block. 200 captures must evaluate; non-200 captures must reject with the recorded body as a substring of the headscale error. Divergences are listed in knownSSHTesterDivergences.
This commit is contained in:
Kristoffer Dalby
2026-05-13 12:48:43 +00:00
parent 2f7f90529a
commit 81246bde16
33 changed files with 611275 additions and 0 deletions
@@ -0,0 +1,92 @@
// Replay golden HuJSON captures under testdata/sshtest_results/*.hujson:
// the 200 path requires headscale's evaluateSSHTests to pass; the
// non-200 path requires headscale to reject the same input with the
// captured error body as a substring. Divergences are listed in
// knownSSHTesterDivergences with the engine gap each represents.
package v2
import (
"path/filepath"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/require"
)
// knownSSHTesterDivergences documents captures where headscale and the
// upstream control plane disagree. Each entry names the engine area a
// follow-up needs to touch.
var knownSSHTesterDivergences = map[string]string{
"sshtest-malformed-dst-bare-ipv6": "bare-IPv6 sshTests dst: upstream parse-accepts then engine-rejects; headscale accepts (IPv4 mirror passes both sides)",
}
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()
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)
}
// Per-capture nodes: each capture pins its own
// topology IPs, and policy `hosts` aliases reference
// them by literal IP. A shared fixture would leave
// host-alias dsts resolving to no nodes — surface
// the path as a load-bearing failure instead.
nodes := buildGrantsNodesFromCapture(users, c)
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())
}
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff