// Copyright 2020-2026 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0

package e2e

import (
	"context"
	"fmt"
	"strings"
	"time"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	corev1 "k8s.io/api/core/v1"
	networkingv1 "k8s.io/api/networking/v1"
	rbacv1 "k8s.io/api/rbac/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/kubernetes/scheme"
	"k8s.io/utils/ptr"
	"sigs.k8s.io/controller-runtime/pkg/client"
	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"

	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
	"github.com/projectcapsule/capsule/pkg/api/meta"
	"github.com/projectcapsule/capsule/pkg/api/rbac"
	"github.com/projectcapsule/capsule/pkg/api/rules"
	"github.com/projectcapsule/capsule/pkg/api/runtime"
	"github.com/projectcapsule/capsule/pkg/runtime/events"
	"github.com/projectcapsule/capsule/pkg/utils"
)

var _ = Describe("enforcing ingress hostname namespace rules", Ordered, Label("tenant", "rules", "enforce", "ingress"), func() {
	const (
		ownerName             = "e2e-rules-ingress"
		gatewayAPIClusterRole = "e2e-rules-ingress-gateway-api"
	)

	var tnt *capsulev1beta2.Tenant

	hostnameByExact := func(hostnames ...string) runtime.ExpressionMatch {
		return runtime.ExpressionMatch{Exact: hostnames}
	}

	hostnameByExpression := func(expression string) runtime.ExpressionMatch {
		return runtime.ExpressionMatch{
			ExpressionRegex: runtime.ExpressionRegex{Expression: expression},
		}
	}

	hostnameByMatch := func(exact []string, expression string) runtime.ExpressionMatch {
		return runtime.ExpressionMatch{
			Exact: exact,
			ExpressionRegex: runtime.ExpressionRegex{
				Expression: expression,
			},
		}
	}

	hostnameByNegatedExpression := func(expression string) runtime.ExpressionMatch {
		return runtime.ExpressionMatch{
			ExpressionRegex: runtime.ExpressionRegex{
				Expression: expression,
				Negate:     true,
			},
		}
	}

	ingressRule := func(
		action rules.ActionType,
		types []rules.IngressType,
		hostnames ...runtime.ExpressionMatch,
	) *rules.NamespaceRuleBodyTenant {
		return &rules.NamespaceRuleBodyTenant{
			NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
				Enforce: &rules.NamespaceRuleEnforceBody{
					Action: action,
					Ingress: rules.NamespaceRuleEnforceIngressBody{
						Types:     types,
						Hostnames: hostnames,
					},
				},
			},
		}
	}

	selectedRule := func(
		selector map[string]string,
		rule *rules.NamespaceRuleBodyTenant,
	) *rules.NamespaceRuleBodyTenant {
		rule.NamespaceSelector = &metav1.LabelSelector{MatchLabels: selector}

		return rule
	}

	targetTypes := []rules.IngressType{
		rules.IngressTypeIngress,
		rules.IngressTypeHTTPRoute,
		rules.IngressTypeGateway,
	}

	baseTenantRules := func() []*rules.NamespaceRuleBodyTenant {
		return []*rules.NamespaceRuleBodyTenant{
			ingressRule(
				rules.ActionTypeAudit,
				targetTypes,
				hostnameByExact("audited.example.com", "audited.example.net"),
			),
			selectedRule(
				map[string]string{"enforce-hostnames": "true"},
				ingressRule(
					rules.ActionTypeAllow,
					targetTypes,
					hostnameByExact("internal.example.com"),
					hostnameByExpression("^[a-z0-9-]+\\.example\\.com$"),
				),
			),
			selectedRule(
				map[string]string{"enforce-hostnames": "true"},
				ingressRule(
					rules.ActionTypeDeny,
					targetTypes,
					hostnameByExact("blocked.example.com"),
				),
			),
			selectedRule(
				map[string]string{"allow-blocked-hostname": "true"},
				ingressRule(
					rules.ActionTypeAllow,
					targetTypes,
					hostnameByExact("blocked.example.com"),
				),
			),
			selectedRule(
				map[string]string{"combined-hostname-match": "true"},
				ingressRule(
					rules.ActionTypeAllow,
					targetTypes,
					hostnameByMatch(
						[]string{"combined-exact.example.net"},
						"^combined-[a-z0-9-]+\\.example\\.org$",
					),
				),
			),
			selectedRule(
				map[string]string{"negated-hostname-match": "true"},
				ingressRule(
					rules.ActionTypeDeny,
					targetTypes,
					hostnameByNegatedExpression("^([a-z0-9-]+\\.)*trusted\\.example$"),
				),
			),
		}
	}

	newTenant := func() *capsulev1beta2.Tenant {
		return &capsulev1beta2.Tenant{
			ObjectMeta: metav1.ObjectMeta{
				Name: "e2e-rule-ingress",
				Labels: map[string]string{
					"env": "e2e",
				},
			},
			Spec: capsulev1beta2.TenantSpec{
				Owners: rbac.OwnerListSpec{
					{
						CoreOwnerSpec: rbac.CoreOwnerSpec{
							UserSpec: rbac.UserSpec{
								Name: ownerName,
								Kind: "User",
							},
						},
					},
				},
				AdditionalRoleBindings: []rbac.AdditionalRoleBindingsSpec{
					{
						ClusterRoleName: gatewayAPIClusterRole,
						Subjects: []rbacv1.Subject{
							{
								APIGroup: rbacv1.GroupName,
								Kind:     rbacv1.UserKind,
								Name:     ownerName,
							},
						},
					},
				},
				Rules: baseTenantRules(),
			},
		}
	}

	type expectedIngressStatusRule struct {
		action    rules.ActionType
		hostnames []runtime.ExpressionMatch
	}

	expectNamespaceStatusRules := func(nsName string, want []expectedIngressStatusRule) {
		Eventually(func(g Gomega) {
			nsStatus := &capsulev1beta2.RuleStatus{}
			g.Expect(k8sClient.Get(
				context.Background(),
				client.ObjectKey{Name: meta.NameForManagedRuleStatus(), Namespace: nsName},
				nsStatus,
			)).To(Succeed())

			g.Expect(nsStatus.Status.Rules).To(HaveLen(len(want)))

			for i, expected := range want {
				got := nsStatus.Status.Rules[i]
				g.Expect(got).NotTo(BeNil())
				g.Expect(got.Enforce).NotTo(BeNil())
				g.Expect(got.Enforce.Action).To(Equal(expected.action))
				g.Expect(got.Enforce.Ingress.Types).To(Equal(targetTypes))
				g.Expect(got.Enforce.Ingress.Hostnames).To(Equal(expected.hostnames))
			}
		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
	}

	auditStatusRule := expectedIngressStatusRule{
		action: rules.ActionTypeAudit,
		hostnames: []runtime.ExpressionMatch{
			hostnameByExact("audited.example.com", "audited.example.net"),
		},
	}

	allowStatusRule := expectedIngressStatusRule{
		action: rules.ActionTypeAllow,
		hostnames: []runtime.ExpressionMatch{
			hostnameByExact("internal.example.com"),
			hostnameByExpression("^[a-z0-9-]+\\.example\\.com$"),
		},
	}

	denyStatusRule := expectedIngressStatusRule{
		action: rules.ActionTypeDeny,
		hostnames: []runtime.ExpressionMatch{
			hostnameByExact("blocked.example.com"),
		},
	}

	exceptionStatusRule := expectedIngressStatusRule{
		action: rules.ActionTypeAllow,
		hostnames: []runtime.ExpressionMatch{
			hostnameByExact("blocked.example.com"),
		},
	}

	combinedStatusRule := expectedIngressStatusRule{
		action: rules.ActionTypeAllow,
		hostnames: []runtime.ExpressionMatch{
			hostnameByMatch(
				[]string{"combined-exact.example.net"},
				"^combined-[a-z0-9-]+\\.example\\.org$",
			),
		},
	}

	negatedStatusRule := expectedIngressStatusRule{
		action: rules.ActionTypeDeny,
		hostnames: []runtime.ExpressionMatch{
			hostnameByNegatedExpression("^([a-z0-9-]+\\.)*trusted\\.example$"),
		},
	}

	createNamespace := func(labels map[string]string, expectedRules ...expectedIngressStatusRule) *corev1.Namespace {
		if labels == nil {
			labels = map[string]string{}
		}
		labels[meta.TenantLabel] = tnt.GetName()

		ns := NewNamespace("", labels)
		NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
		NamespaceIsPartOfTenant(tnt, ns).Should(Succeed())
		expectNamespaceStatusRules(ns.Name, expectedRules)

		return ns
	}

	ingress := func(name string, hostnames ...string) *networkingv1.Ingress {
		obj := &networkingv1.Ingress{
			ObjectMeta: metav1.ObjectMeta{Name: name},
		}

		for _, hostname := range hostnames {
			obj.Spec.Rules = append(obj.Spec.Rules, networkingv1.IngressRule{
				Host: hostname,
				IngressRuleValue: networkingv1.IngressRuleValue{
					HTTP: &networkingv1.HTTPIngressRuleValue{
						Paths: []networkingv1.HTTPIngressPath{
							{
								Path:     "/",
								PathType: ptr.To(networkingv1.PathTypePrefix),
								Backend: networkingv1.IngressBackend{
									Service: &networkingv1.IngressServiceBackend{
										Name: "tenant-api",
										Port: networkingv1.ServiceBackendPort{Number: 8080},
									},
								},
							},
						},
					},
				},
			})
		}

		return obj
	}

	hostlessIngress := func(name string) *networkingv1.Ingress {
		obj := ingress(name)
		obj.Spec.DefaultBackend = &networkingv1.IngressBackend{
			Service: &networkingv1.IngressServiceBackend{
				Name: "tenant-api",
				Port: networkingv1.ServiceBackendPort{Number: 8080},
			},
		}

		return obj
	}

	createIngressAndExpectAllowed := func(cs kubernetes.Interface, nsName string, obj *networkingv1.Ingress) {
		EventuallyCreation(func() error {
			_, err := cs.NetworkingV1().Ingresses(nsName).Create(context.Background(), obj, metav1.CreateOptions{})

			return err
		}).Should(Succeed())
	}

	createIngressAndExpectDenied := func(
		cs kubernetes.Interface,
		nsName string,
		obj *networkingv1.Ingress,
		substrings ...string,
	) {
		base := obj.DeepCopy()
		baseName := base.Name

		Eventually(func() error {
			candidate := base.DeepCopy()
			candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6)

			_, err := cs.NetworkingV1().Ingresses(nsName).Create(context.Background(), candidate, metav1.CreateOptions{})
			if err == nil {
				_ = cs.NetworkingV1().Ingresses(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{})

				return fmt.Errorf("expected ingress create to be denied, but it succeeded")
			}
			if apierrors.IsAlreadyExists(err) {
				return fmt.Errorf("unexpected AlreadyExists: %v", err)
			}

			for _, substring := range substrings {
				if !strings.Contains(err.Error(), substring) {
					return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
				}
			}

			return nil
		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
	}

	expectAuditEvent := func(cs kubernetes.Interface, nsName, kind, objectName string, substrings ...string) {
		Eventually(func() error {
			eventList, err := cs.EventsV1().Events(nsName).List(context.Background(), metav1.ListOptions{})
			if err != nil {
				return err
			}

			for _, event := range eventList.Items {
				if event.Reason != events.ReasonNamespaceRuleAudit ||
					event.Regarding.Kind != kind ||
					event.Regarding.Name != objectName {
					continue
				}

				matched := true
				for _, substring := range substrings {
					if !strings.Contains(event.Note, substring) {
						matched = false

						break
					}
				}
				if matched {
					return nil
				}
			}

			return fmt.Errorf("expected audit event for %s %q containing %q", kind, objectName, substrings)
		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
	}

	requireOptionalAPI := func(nsName string, list client.ObjectList, description string) {
		if err := k8sClient.List(context.Background(), list, client.InNamespace(nsName)); err != nil {
			if utils.IsUnsupportedAPI(err) {
				Skip(fmt.Sprintf("%s is not available: %s", description, err))
			}

			Expect(err).NotTo(HaveOccurred())
		}
	}

	BeforeAll(func() {
		utilruntime.Must(gatewayv1.Install(scheme.Scheme))

		role := &rbacv1.ClusterRole{
			ObjectMeta: metav1.ObjectMeta{Name: gatewayAPIClusterRole},
			Rules: []rbacv1.PolicyRule{
				{
					APIGroups: []string{"gateway.networking.k8s.io"},
					Resources: []string{"gateways", "httproutes"},
					Verbs:     []string{"create", "delete"},
				},
			},
		}
		Eventually(func() error {
			current := &rbacv1.ClusterRole{}
			err := k8sClient.Get(context.Background(), client.ObjectKey{Name: gatewayAPIClusterRole}, current)
			if apierrors.IsNotFound(err) {
				return k8sClient.Create(context.Background(), role)
			}
			if err != nil {
				return err
			}

			current.Rules = role.Rules

			return k8sClient.Update(context.Background(), current)
		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
	})

	AfterAll(func() {
		err := k8sClient.Delete(context.Background(), &rbacv1.ClusterRole{
			ObjectMeta: metav1.ObjectMeta{Name: gatewayAPIClusterRole},
		})
		if !apierrors.IsNotFound(err) {
			Expect(err).NotTo(HaveOccurred())
		}
	})

	JustBeforeEach(func() {
		tnt = newTenant()

		EventuallyCreation(func() error {
			tnt.ResourceVersion = ""

			return k8sClient.Create(context.Background(), tnt)
		}).Should(Succeed())

		TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval)
	})

	JustAfterEach(func() {
		EventuallyDeletion(tnt)
	})

	It("projects ingress rules into namespace RuleStatus", func() {
		createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
	})

	It("allows exact and regex hostnames across Ingress rules and TLS hosts", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		obj := ingress("allowed-hostnames", "internal.example.com", "api.example.com")
		obj.Spec.TLS = []networkingv1.IngressTLS{{Hosts: []string{"api.example.com"}}}

		createIngressAndExpectAllowed(cs, ns.Name, obj)
	})

	It("supports exact and regex alternatives in the same hostname matcher", func() {
		ns := createNamespace(
			map[string]string{"combined-hostname-match": "true"},
			auditStatusRule,
			combinedStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		createIngressAndExpectAllowed(cs, ns.Name, ingress("combined-exact", "combined-exact.example.net"))
		createIngressAndExpectAllowed(cs, ns.Name, ingress("combined-regex", "combined-team-a.example.org"))
		createIngressAndExpectDenied(cs, ns.Name, ingress("combined-miss", "combined.example.com"),
			"combined.example.com",
			"not allowed",
			"combined-exact.example.net",
			"combined-[a-z0-9-]+",
		)
	})

	It("applies negation to hostname expressions", func() {
		ns := createNamespace(
			map[string]string{"negated-hostname-match": "true"},
			auditStatusRule,
			negatedStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		createIngressAndExpectAllowed(cs, ns.Name, ingress("negated-trusted", "api.trusted.example"))
		createIngressAndExpectDenied(cs, ns.Name, ingress("negated-denied", "api.untrusted.example"),
			"api.untrusted.example",
			"denied",
			"not exp:",
		)
	})

	It("denies a hostname outside the allow-list", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		createIngressAndExpectDenied(cs, ns.Name, ingress("allow-miss", "api.example.net"),
			"api.example.net",
			"spec.rules[0].host",
			"not allowed",
		)
	})

	It("reports the index of a rejected hostname in a multi-rule Ingress", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		createIngressAndExpectDenied(
			cs,
			ns.Name,
			ingress("multi-rule-allow-miss", "api.example.com", "api.example.net"),
			"api.example.net",
			"spec.rules[1].host",
			"not allowed",
		)
	})

	It("denies a disallowed TLS hostname even when the routing hostname is allowed", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		obj := ingress("tls-allow-miss", "api.example.com")
		obj.Spec.TLS = []networkingv1.IngressTLS{{Hosts: []string{"legacy.example.net"}}}

		createIngressAndExpectDenied(cs, ns.Name, obj,
			"legacy.example.net",
			"spec.tls[0].hosts[0]",
			"not allowed",
		)
	})

	It("reports the index of a rejected hostname in a multi-host TLS entry", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		obj := ingress("multi-tls-allow-miss", "api.example.com")
		obj.Spec.TLS = []networkingv1.IngressTLS{{
			Hosts: []string{"api.example.com", "legacy.example.net"},
		}}

		createIngressAndExpectDenied(cs, ns.Name, obj,
			"legacy.example.net",
			"spec.tls[0].hosts[1]",
			"not allowed",
		)
	})

	It("applies later deny and namespace-selected allow precedence", func() {
		deniedNS := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		allowedNS := createNamespace(
			map[string]string{
				"enforce-hostnames":      "true",
				"allow-blocked-hostname": "true",
			},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
			exceptionStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		createIngressAndExpectDenied(cs, deniedNS.Name, ingress("blocked-denied", "blocked.example.com"),
			"blocked.example.com",
			"denied",
		)
		createIngressAndExpectAllowed(cs, allowedNS.Name, ingress("blocked-allowed", "blocked.example.com"))
	})

	It("denies an update that changes an allowed hostname to an allow-list miss", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
		obj := ingress("hostname-update", "api.example.com")

		createIngressAndExpectAllowed(cs, ns.Name, obj)

		Eventually(func() error {
			current, err := cs.NetworkingV1().Ingresses(ns.Name).Get(context.Background(), obj.Name, metav1.GetOptions{})
			if err != nil {
				return err
			}
			current.Spec.Rules[0].Host = "api.example.net"

			_, err = cs.NetworkingV1().Ingresses(ns.Name).Update(context.Background(), current, metav1.UpdateOptions{})
			if err == nil {
				return fmt.Errorf("expected ingress update to be denied, but it succeeded")
			}
			for _, substring := range []string{"api.example.net", "spec.rules[0].host", "not allowed"} {
				if !strings.Contains(err.Error(), substring) {
					return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
				}
			}

			return nil
		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
	})

	It("audits a matching hostname without blocking admission", func() {
		ns := createNamespace(nil, auditStatusRule)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
		obj := ingress("hostname-audited", "audited.example.com")

		createIngressAndExpectAllowed(cs, ns.Name, obj)
		expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
			`ingress hostname "audited.example.com"`,
			"spec.rules[0].host",
			"matched audit namespace rule",
		)
	})

	It("emits an audit event even when an allow-list denies the request", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
		obj := ingress("audited-and-denied", "audited.example.net")

		_, err := cs.NetworkingV1().Ingresses(ns.Name).Create(context.Background(), obj, metav1.CreateOptions{})
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("audited.example.net"))
		Expect(err.Error()).To(ContainSubstring("not allowed"))

		expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
			`ingress hostname "audited.example.net"`,
			"matched audit namespace rule",
		)
	})

	It("admits an empty hostname for audit-only rules and emits an audit event", func() {
		ns := createNamespace(nil, auditStatusRule)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
		obj := hostlessIngress("empty-hostname-audited")

		createIngressAndExpectAllowed(cs, ns.Name, obj)
		expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
			"empty hostname detected",
			"spec.rules[].host",
			"audit namespace rule",
		)
	})

	It("audits an empty TLS hosts entry without blocking audit-only admission", func() {
		ns := createNamespace(nil, auditStatusRule)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
		obj := ingress("empty-tls-hosts-audited", "neutral.example.org")
		obj.Spec.TLS = []networkingv1.IngressTLS{{SecretName: "tenant-api-tls"}}

		createIngressAndExpectAllowed(cs, ns.Name, obj)
		expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
			"empty hostname detected",
			"spec.tls[0].hosts",
			"audit namespace rule",
		)
	})

	It("denies an empty TLS hosts entry when allow or deny rules apply", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
		obj := ingress("empty-tls-hosts-denied", "api.example.com")
		obj.Spec.TLS = []networkingv1.IngressTLS{{SecretName: "tenant-api-tls"}}

		createIngressAndExpectDenied(cs, ns.Name, obj,
			"hostname is required",
			"spec.tls[0].hosts",
			"Ingress",
		)
	})

	It("denies an empty hostname when allow or deny rules apply", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		cs := ownerClient(tnt.Spec.Owners[0].UserSpec)

		createIngressAndExpectDenied(cs, ns.Name, hostlessIngress("empty-hostname-denied"),
			"hostname is required",
			"spec.rules[].host",
			"Ingress",
		)
	})

	It("enforces every HTTPRoute hostname when Gateway API is available", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)

		requireOptionalAPI(ns.Name, &gatewayv1.HTTPRouteList{}, "Gateway API HTTPRoute")

		owner := impersonationClient(ownerName, withDefaultGroups(nil))
		allowed := &gatewayv1.HTTPRoute{
			ObjectMeta: metav1.ObjectMeta{Name: "route-hostnames-allowed", Namespace: ns.Name},
			Spec: gatewayv1.HTTPRouteSpec{
				Hostnames: []gatewayv1.Hostname{"api.example.com", "internal.example.com"},
			},
		}
		EventuallyCreation(func() error {
			return owner.Create(context.Background(), allowed)
		}).Should(Succeed())

		Eventually(func() error {
			denied := &gatewayv1.HTTPRoute{
				ObjectMeta: metav1.ObjectMeta{
					Name:      fmt.Sprintf("route-hostnames-denied-%d", time.Now().UnixNano()%1e6),
					Namespace: ns.Name,
				},
				Spec: gatewayv1.HTTPRouteSpec{
					Hostnames: []gatewayv1.Hostname{"api.example.com", "api.example.net"},
				},
			}

			err := owner.Create(context.Background(), denied)
			if err == nil {
				_ = owner.Delete(context.Background(), denied)

				return fmt.Errorf("expected HTTPRoute create to be denied, but it succeeded")
			}
			for _, substring := range []string{"api.example.net", "spec.hostnames[1]", "not allowed"} {
				if !strings.Contains(err.Error(), substring) {
					return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
				}
			}

			return nil
		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
	})

	It("audits or rejects an HTTPRoute without hostnames according to action", func() {
		auditNS := createNamespace(nil, auditStatusRule)
		enforceNS := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		requireOptionalAPI(auditNS.Name, &gatewayv1.HTTPRouteList{}, "Gateway API HTTPRoute")

		owner := impersonationClient(ownerName, withDefaultGroups(nil))
		audited := &gatewayv1.HTTPRoute{
			ObjectMeta: metav1.ObjectMeta{Name: "route-empty-hostnames-audited", Namespace: auditNS.Name},
		}
		EventuallyCreation(func() error {
			return owner.Create(context.Background(), audited)
		}).Should(Succeed())
		expectAuditEvent(clusterAdminClient(), auditNS.Name, "HTTPRoute", audited.Name,
			"empty hostname detected",
			"spec.hostnames[]",
			"audit namespace rule",
		)

		denied := &gatewayv1.HTTPRoute{
			ObjectMeta: metav1.ObjectMeta{Name: "route-empty-hostnames-denied", Namespace: enforceNS.Name},
		}
		err := owner.Create(context.Background(), denied)
		Expect(err).To(HaveOccurred())
		Expect(err.Error()).To(ContainSubstring("hostname is required"))
		Expect(err.Error()).To(ContainSubstring("spec.hostnames[]"))
		Expect(err.Error()).To(ContainSubstring("HTTPRoute"))
	})

	It("enforces every Gateway listener hostname when Gateway API is available", func() {
		ns := createNamespace(
			map[string]string{"enforce-hostnames": "true"},
			auditStatusRule,
			allowStatusRule,
			denyStatusRule,
		)
		requireOptionalAPI(ns.Name, &gatewayv1.GatewayList{}, "Gateway API Gateway")

		owner := impersonationClient(ownerName, withDefaultGroups(nil))
		allowed := &gatewayv1.Gateway{
			ObjectMeta: metav1.ObjectMeta{Name: "gateway-hostnames-allowed", Namespace: ns.Name},
			Spec: gatewayv1.GatewaySpec{
				GatewayClassName: "unmanaged-e2e-class",
				Listeners: []gatewayv1.Listener{
					{
						Name:     "http",
						Protocol: gatewayv1.HTTPProtocolType,
						Port:     80,
						Hostname: ptr.To(gatewayv1.Hostname("api.example.com")),
					},
					{
						Name:     "http-alt",
						Protocol: gatewayv1.HTTPProtocolType,
						Port:     8080,
						Hostname: ptr.To(gatewayv1.Hostname("internal.example.com")),
					},
				},
			},
		}
		EventuallyCreation(func() error {
			return owner.Create(context.Background(), allowed)
		}).Should(Succeed())

		Eventually(func() error {
			denied := allowed.DeepCopy()
			denied.ResourceVersion = ""
			denied.Name = fmt.Sprintf("gateway-hostnames-denied-%d", time.Now().UnixNano()%1e6)
			denied.Spec.Listeners[1].Hostname = ptr.To(gatewayv1.Hostname("api.example.net"))

			err := owner.Create(context.Background(), denied)
			if err == nil {
				_ = owner.Delete(context.Background(), denied)

				return fmt.Errorf("expected Gateway create to be denied, but it succeeded")
			}
			for _, substring := range []string{"api.example.net", "spec.listeners[1].hostname", "not allowed"} {
				if !strings.Contains(err.Error(), substring) {
					return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
				}
			}

			return nil
		}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
	})

	It("audits a Gateway listener without a hostname without blocking admission", func() {
		ns := createNamespace(nil, auditStatusRule)
		requireOptionalAPI(ns.Name, &gatewayv1.GatewayList{}, "Gateway API Gateway")

		owner := impersonationClient(ownerName, withDefaultGroups(nil))
		obj := &gatewayv1.Gateway{
			ObjectMeta: metav1.ObjectMeta{Name: "gateway-empty-hostname-audited", Namespace: ns.Name},
			Spec: gatewayv1.GatewaySpec{
				GatewayClassName: "unmanaged-e2e-class",
				Listeners: []gatewayv1.Listener{
					{
						Name:     "http",
						Protocol: gatewayv1.HTTPProtocolType,
						Port:     80,
					},
				},
			},
		}
		EventuallyCreation(func() error {
			return owner.Create(context.Background(), obj)
		}).Should(Succeed())
		expectAuditEvent(clusterAdminClient(), ns.Name, "Gateway", obj.Name,
			"empty hostname detected",
			"spec.listeners[0].hostname",
			"audit namespace rule",
		)
	})
})
