policy/v2: add grant port list refusal capture

Grant and ACL refusals share one check-keyed refused/message check.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 16:09:00 +01:00
1 parent f70d25579b
commit 63130fb8e5
4 files changed
+32482 -56

No files matched your search

@@ -271,7 +271,7 @@ func TestACLCompat(t *testing.T) {
t.Parallel()
if tf.Error {
testACLError(t, tf)
testCompatRefusal(t, aclSkipReasons, tf)
return
}
@@ -288,8 +288,9 @@ func TestACLCompat(t *testing.T) {
}
}
// testACLError verifies that an invalid policy produces the expected error.
func testACLError(t *testing.T, tf *testcapture.Capture) {
// testCompatRefusal checks that headscale refuses a policy SaaS refused, with
// SaaS's message in its error; checks are keyed <test_id>/<check> in skips.
func testCompatRefusal(t *testing.T, skips map[string]string, tf *testcapture.Capture) {
t.Helper()
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
@@ -301,7 +302,7 @@ func testACLError(t *testing.T, tf *testcapture.Capture) {
key := tf.TestID + "/"
compatCheck(t, aclSkipReasons, key+"refused", func() string {
compatCheck(t, skips, key+"refused", func() string {
if err == nil {
return "SaaS refused; the policy parsed and validated"
}
@@ -310,7 +311,7 @@ func testACLError(t *testing.T, tf *testcapture.Capture) {
})
if err == nil {
compatChecksNotRun(t, aclSkipReasons, key+"message")
compatChecksNotRun(t, skips, key+"message")
return
}
@@ -322,7 +323,7 @@ func testACLError(t *testing.T, tf *testcapture.Capture) {
want = tf.Input.APIResponseBody.Message
}
compatCheck(t, aclSkipReasons, key+"message", func() string {
compatCheck(t, skips, key+"message", func() string {
if strings.Contains(err.Error(), want) {
return ""
}
@@ -175,6 +175,8 @@ var grantSkipReasons = map[string]string{
"via-grant-v63/blastoise/filter": viaDstAddressForm,
"via-grant-v63/squirtle/filter": viaDstAddressForm,
"grant-dxp-comma-ports/refused": grantPortList,
// Contiguous pinned addresses; TestViaGrantHACompat compares these
// filters by address.
"via-grant-v64/client-a/filter": srcAddressForm,
@@ -191,6 +193,8 @@ var grantSkipReasons = map[string]string{
"via-grant-v65/via-r/filter": srcAddressForm,
}
const grantPortList = "GRANT_PORT_LIST: SaaS refuses a comma port list in a grant ip entry (only one port range allowed); headscale accepts it"
const viaDstAddressForm = "FILTER_ADDRESS_FORM: SaaS writes a single-address via destination as a bare address, headscale as a /32 prefix"
// TestGrantsCompat is a data-driven test that loads all GRANT-*.json
@@ -240,7 +244,7 @@ func TestGrantsCompat(t *testing.T) {
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
if tf.Input.APIResponseCode == 400 || tf.Error {
testGrantError(t, policyJSON, tf)
testCompatRefusal(t, grantSkipReasons, tf)
return
}
@@ -249,55 +253,6 @@ func TestGrantsCompat(t *testing.T) {
}
}
// testGrantError verifies that an invalid policy produces the expected error.
func testGrantError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
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)
}
// assertGrantErrorContains requires that headscale's error contains
// the Tailscale SaaS error message exactly. Divergence means an
// emitter needs to be aligned, not papered over with a translation
// table.
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(
File diff suppressed because it is too large. Load diff
+2
View File
@@ -322,6 +322,8 @@ var rejectedPolicySkipReasons = map[string]string{
"ssh-check-tag-src/refused": "SSH_CHECK_TAG_SRC: headscale accepts a check rule with a tag source",
"grant-dxp-comma-ports/refused": "GRANT_PORT_LIST: headscale accepts a comma port list in a grant ip entry",
"policytest-src-cidr-accept/refused": "TEST_SRC_SHAPE: headscale evaluates a CIDR tests source",
"policytest-src-wildcard-deny/refused": "TEST_SRC_SHAPE: headscale evaluates \"*\" as a tests source",
"policytest-dst-multinode-partial-accept/refused": "TEST_DST_EVERY_NODE: headscale accepts when one node of the dst alias is reached",