Files
capsule/internal/webhook/ingress/validate_hostnames_test.go
2026-07-10 06:50:35 +02:00

195 lines
5.7 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/util/sets"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
)
// tenantWithAllowedHostnames builds a minimal Tenant carrying the given exact
// allow-list and regex under spec.ingressOptions.allowedHostnames.
func tenantWithAllowedHostnames(exact []string, regex string) capsulev1beta2.Tenant {
return capsulev1beta2.Tenant{
Spec: capsulev1beta2.TenantSpec{
IngressOptions: capsulev1beta2.IngressOptions{
AllowedHostnames: &api.AllowedListSpec{
Exact: exact,
Regex: regex,
},
},
},
}
}
func TestValidateHostnames(t *testing.T) {
t.Parallel()
const (
appsRegex = `^[a-z0-9-]{3,40}\.apps\.example\.com$`
)
tests := []struct {
name string
// tenant carries the allowed hostnames configuration under test.
tenant capsulev1beta2.Tenant
// hostnames are the Ingress hostnames being validated.
hostnames []string
// wantErr is true when the hostnames must be denied.
wantErr bool
// wantDenied lists hostnames that must appear in the denial message.
wantDenied []string
// wantAbsent lists hostnames that must NOT appear as denied (e.g. valid
// via regex but outside the exact list).
wantAbsent []string
}{
{
name: "no allowed hostnames configured allows everything",
tenant: capsulev1beta2.Tenant{},
hostnames: []string{"anything.example.com"},
wantErr: false,
},
{
name: "empty hostname set is allowed",
tenant: tenantWithAllowedHostnames([]string{"a.example.com"}, ""),
hostnames: nil,
wantErr: false,
},
{
name: "all hostnames in exact list are allowed",
tenant: tenantWithAllowedHostnames([]string{"a.example.com", "b.example.com"}, ""),
hostnames: []string{"a.example.com", "b.example.com"},
wantErr: false,
},
{
name: "hostname outside exact list without regex is denied",
tenant: tenantWithAllowedHostnames([]string{"a.example.com"}, ""),
hostnames: []string{"a.example.com", "c.example.com"},
wantErr: true,
wantDenied: []string{"c.example.com"},
},
{
name: "hostnames matching regex are allowed",
tenant: tenantWithAllowedHostnames(nil, `.*\.clastix\.io`),
hostnames: []string{"foo.clastix.io", "bar.clastix.io"},
wantErr: false,
},
{
name: "mixed exact and regex hostnames are allowed",
tenant: tenantWithAllowedHostnames([]string{"a.example.com"}, `.*\.clastix\.io`),
hostnames: []string{"a.example.com", "foo.clastix.io"},
wantErr: false,
},
{
name: "hostname not matching regex is denied",
tenant: tenantWithAllowedHostnames(nil, `.*\.clastix\.io`),
hostnames: []string{"foo.example.com"},
wantErr: true,
wantDenied: []string{"foo.example.com"},
},
{
name: "denies only the hostname that is neither in the exact list nor matches the regex",
tenant: tenantWithAllowedHostnames([]string{"allowed.example.com"}, appsRegex),
hostnames: []string{
"allowed.example.com", // allowed via exact
"web.apps.example.com", // allowed via regex
"denied.example.com", // denied: neither exact nor regex
},
wantErr: true,
wantDenied: []string{"denied.example.com"},
wantAbsent: []string{"web.apps.example.com"},
},
{
name: "invalid regex denies hostnames outside the exact list",
tenant: tenantWithAllowedHostnames([]string{"a.example.com"}, "("),
hostnames: []string{"a.example.com", "b.example.com"},
wantErr: true,
wantDenied: []string{"b.example.com"},
},
{
name: "invalid regex is ignored when every hostname is in the exact list",
tenant: tenantWithAllowedHostnames([]string{"a.example.com"}, "("),
hostnames: []string{"a.example.com"},
wantErr: false,
},
}
h := &hostnames{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := h.validateHostnames(tt.tenant, sets.New[string](tt.hostnames...))
if tt.wantErr && err == nil {
t.Fatalf("expected hostnames %v to be denied, got no error", tt.hostnames)
}
if !tt.wantErr {
if err != nil {
t.Fatalf("expected hostnames %v to be allowed, got error: %v", tt.hostnames, err)
}
return
}
msg := err.Error()
for _, denied := range tt.wantDenied {
if !strings.Contains(msg, denied) {
t.Errorf("expected denial message to mention %q, got: %s", denied, msg)
}
}
for _, absent := range tt.wantAbsent {
if strings.Contains(msg, absent) {
t.Errorf("did not expect denial message to mention allowed hostname %q, got: %s", absent, msg)
}
}
})
}
}
// TestValidateHostnamesDeterministic guards against the historical
// non-deterministic bug: because the hostname set has a randomized iteration
// order, validation of the same input must always produce the same result.
func TestValidateHostnamesDeterministic(t *testing.T) {
t.Parallel()
tenant := tenantWithAllowedHostnames(
[]string{"allowed.example.com"},
`^[a-z0-9-]{3,40}\.apps\.example\.com$`,
)
hostnameSet := sets.New[string](
"allowed.example.com",
"web.apps.example.com",
"denied.example.com",
)
h := &hostnames{}
for i := 0; i < 50; i++ {
err := h.validateHostnames(tenant, hostnameSet)
if err == nil {
t.Fatalf("iteration %d: expected denial, got no error", i)
}
msg := err.Error()
if !strings.Contains(msg, "denied.example.com") {
t.Fatalf("iteration %d: expected message to mention denied.example.com, got: %s", i, msg)
}
if strings.Contains(msg, "web.apps.example.com") {
t.Fatalf("iteration %d: message wrongly mentions regex-allowed hostname: %s", i, msg)
}
}
}