Ctrl K
ComunidadGuías…

api/handler/gateway_action_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handler

import (
	"bytes"
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"math/big"
	"os"
	"reflect"
	"sort"
	"testing"
	"time"

	v2 "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/apis/config/v2"
	apisixversioned "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/client/clientset/versioned"
	apisixfake "github.com/apache/apisix-ingress-controller/pkg/kube/apisix/client/clientset/versioned/fake"
	apimodel "github.com/goodrain/rainbond/api/model"
	"github.com/goodrain/rainbond/db"
	dbdao "github.com/goodrain/rainbond/db/dao"
	dbmodel "github.com/goodrain/rainbond/db/model"
	corev1 "k8s.io/api/core/v1"
	k8serrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/client-go/kubernetes"
	k8sfake "k8s.io/client-go/kubernetes/fake"
	k8stesting "k8s.io/client-go/testing"
	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)

type gatewayRouteDeleteEventTestManager struct {
	db.Manager
	portDao  dbdao.TenantServicesPortDao
	eventDao dbdao.EventDao
}

func (m gatewayRouteDeleteEventTestManager) TenantServicesPortDao() dbdao.TenantServicesPortDao {
	return m.portDao
}

func (m gatewayRouteDeleteEventTestManager) ServiceEventDao() dbdao.EventDao {
	return m.eventDao
}

type gatewayRouteDeleteEventPortDao struct {
	dbdao.TenantServicesPortDao
	portsByName map[string]*dbmodel.TenantServicesPort
}

func (d *gatewayRouteDeleteEventPortDao) ListByK8sServiceNames(names []string) ([]*dbmodel.TenantServicesPort, error) {
	var ports []*dbmodel.TenantServicesPort
	for _, name := range names {
		if port, ok := d.portsByName[name]; ok {
			ports = append(ports, port)
		}
	}
	return ports, nil
}

type gatewayRouteDeleteEventDao struct {
	dbdao.EventDao
	events []*dbmodel.ServiceEvent
}

func (d *gatewayRouteDeleteEventDao) AddModel(arg dbmodel.Interface) error {
	d.events = append(d.events, arg.(*dbmodel.ServiceEvent))
	return nil
}

// capability_id: rainbond.gateway.allocate-lb-port
func TestSelectAvailablePort(t *testing.T) {
	// 设置环境变量
	os.Setenv("MIN_LB_PORT", "30000")
	os.Setenv("MAX_LB_PORT", "65535")

	tests := []struct {
		name     string
		used     []int
		expected int
	}{
		{
			name:     "空列表,返回最小端口",
			used:     []int{},
			expected: 30000,
		},
		{
			name:     "连续端口,返回下一个",
			used:     []int{30000, 30001, 30002},
			expected: 30003,
		},
		{
			name:     "有间隙,返回第一个空闲端口",
			used:     []int{30000, 30002, 30003},
			expected: 30001,
		},
		{
			name:     "大间隙,返回第一个空闲端口",
			used:     []int{30000, 32077},
			expected: 30001,
		},
		{
			name:     "乱序输入,返回第一个空闲端口",
			used:     []int{30002, 30000, 30003},
			expected: 30001,
		},
		{
			name:     "从中间开始有间隙",
			used:     []int{30000, 30001, 30002, 30005, 30006},
			expected: 30003,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := selectAvailablePort(tt.used)
			if result != tt.expected {
				t.Errorf("selectAvailablePort(%v) = %d, expected %d", tt.used, result, tt.expected)
			}
		})
	}
}

// capability_id: rainbond.gateway.reassign-conflicting-imported-tcp-port
func TestReassignConflictingTCPRulePorts(t *testing.T) {
	t.Setenv("MIN_LB_PORT", "30000")
	t.Setenv("MAX_LB_PORT", "30010")

	existing := []*dbmodel.TCPRule{
		{
			ServiceID: "source-service",
			IP:        "0.0.0.0",
			Port:      30000,
		},
	}
	incoming := []*dbmodel.TCPRule{
		{
			UUID:          "imported-rule",
			ServiceID:     "installed-service",
			ContainerPort: 8080,
			IP:            "0.0.0.0",
			Port:          30000,
		},
	}

	err := reassignConflictingTCPRulePorts(existing, incoming)

	if err != nil {
		t.Fatalf("reassignConflictingTCPRulePorts returned error: %v", err)
	}
	if incoming[0].Port != 30001 {
		t.Fatalf("incoming TCP rule port = %d, expected 30001", incoming[0].Port)
	}
}

func TestGatewayParentRefValuesHandlesEmptyParentRefs(t *testing.T) {
	gatewayName, gatewayNamespace, sectionName := gatewayParentRefValues(nil, "app-ns")

	if gatewayName != "" {
		t.Fatalf("gatewayName = %q, expected empty", gatewayName)
	}
	if gatewayNamespace != "app-ns" {
		t.Fatalf("gatewayNamespace = %q, expected default namespace", gatewayNamespace)
	}
	if sectionName != "" {
		t.Fatalf("sectionName = %q, expected empty", sectionName)
	}
}

func TestGatewayParentRefValuesReturnsFirstParentRef(t *testing.T) {
	parentNamespace := gatewayv1.Namespace("gateway-ns")
	parentSection := gatewayv1.SectionName("https")

	gatewayName, gatewayNamespace, sectionName := gatewayParentRefValues([]gatewayv1.ParentReference{
		{
			Name:        gatewayv1.ObjectName("gateway-a"),
			Namespace:   &parentNamespace,
			SectionName: &parentSection,
		},
	}, "app-ns")

	if gatewayName != "gateway-a" {
		t.Fatalf("gatewayName = %q, expected gateway-a", gatewayName)
	}
	if gatewayNamespace != "gateway-ns" {
		t.Fatalf("gatewayNamespace = %q, expected gateway-ns", gatewayNamespace)
	}
	if sectionName != "https" {
		t.Fatalf("sectionName = %q, expected https", sectionName)
	}
}

func gatewayCertificateRequestForTest(t *testing.T, name, namespace, domain string) *apimodel.GatewayCertificate {
	t.Helper()
	return gatewayCertificateRequestForDomains(t, name, namespace, []string{domain})
}

func gatewayCertificateRequestForDomains(t *testing.T, name, namespace string, domains []string) *apimodel.GatewayCertificate {
	t.Helper()
	if len(domains) == 0 {
		t.Fatal("at least one certificate domain is required")
	}

	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatalf("generate certificate key: %v", err)
	}
	serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
	if err != nil {
		t.Fatalf("generate certificate serial: %v", err)
	}
	template := &x509.Certificate{
		SerialNumber:          serialNumber,
		Subject:               pkix.Name{CommonName: domains[0]},
		DNSNames:              domains,
		NotBefore:             time.Now().Add(-time.Hour),
		NotAfter:              time.Now().Add(24 * time.Hour),
		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		BasicConstraintsValid: true,
	}
	certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
	if err != nil {
		t.Fatalf("create certificate: %v", err)
	}
	keyBytes, err := x509.MarshalECPrivateKey(key)
	if err != nil {
		t.Fatalf("marshal certificate key: %v", err)
	}

	return &apimodel.GatewayCertificate{
		Name:        name,
		Namespace:   namespace,
		Certificate: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})),
		PrivateKey:  string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})),
	}
}

func gatewayActionForCertificateTest(kubeClient kubernetes.Interface, apisixClient apisixversioned.Interface) *GatewayAction {
	return &GatewayAction{
		kubeClient:   kubeClient,
		apisixClient: apisixClient,
		domainConflictChecker: func(context.Context, []v2.HostType, string, string) error {
			return nil
		},
	}
}

func testCertificatePEM(t *testing.T, commonName string, isCA bool, notAfter time.Time) string {
	t.Helper()

	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatalf("generate certificate key: %v", err)
	}
	serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
	if err != nil {
		t.Fatalf("generate certificate serial: %v", err)
	}
	template := &x509.Certificate{
		SerialNumber:          serialNumber,
		Subject:               pkix.Name{CommonName: commonName},
		NotBefore:             time.Now().Add(-time.Hour),
		NotAfter:              notAfter,
		BasicConstraintsValid: true,
		IsCA:                  isCA,
		KeyUsage:              x509.KeyUsageDigitalSignature,
	}
	if isCA {
		template.KeyUsage |= x509.KeyUsageCertSign
	} else {
		template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
	}
	der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
	if err != nil {
		t.Fatalf("create certificate: %v", err)
	}
	return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}

// capability_id: rainbond.gateway.client-ca-lifecycle
func TestGatewayClientCALifecycle(t *testing.T) {
	const (
		namespace = "tenant-ns"
		name      = "rbd-client-ca-certificate-id"
	)

	t.Run("creates updates and lists a valid client CA", func(t *testing.T) {
		firstCertificate := testCertificatePEM(t, "first-client-ca", true, time.Now().Add(24*time.Hour))
		updatedCertificate := testCertificatePEM(t, "updated-client-ca", true, time.Now().Add(48*time.Hour))
		kubeClient := k8sfake.NewSimpleClientset()
		apisixClient := apisixfake.NewSimpleClientset()
		action := gatewayActionForCertificateTest(kubeClient, apisixClient)

		if err := action.AddGatewayClientCA(namespace, &apimodel.GatewayClientCA{
			Name: name, Certificate: firstCertificate,
		}); err != nil {
			t.Fatalf("add client CA: %v", err)
		}
		secret, err := kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get client CA secret: %v", err)
		}
		if got := string(secret.Data[corev1.ServiceAccountRootCAKey]); got != firstCertificate {
			t.Fatal("client CA secret does not contain the submitted certificate")
		}
		if _, ok := secret.Data[corev1.TLSPrivateKeyKey]; ok {
			t.Fatal("client CA secret must not contain a private key")
		}

		if err := action.UpdateGatewayClientCA(namespace, &apimodel.GatewayClientCA{
			Name: name, Certificate: updatedCertificate,
		}); err != nil {
			t.Fatalf("update client CA: %v", err)
		}
		secret, err = kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get updated client CA secret: %v", err)
		}
		if got := string(secret.Data[corev1.ServiceAccountRootCAKey]); got != updatedCertificate {
			t.Fatal("updated client CA secret does not contain the submitted certificate")
		}

		_, err = apisixClient.ApisixV2().ApisixTlses(namespace).Create(context.Background(), &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{Name: "api-tls", Namespace: namespace},
			Spec: &v2.ApisixTlsSpec{
				Hosts:  []v2.HostType{"api.example.com"},
				Secret: v2.ApisixSecret{Name: "server-cert", Namespace: namespace},
				Client: &v2.ApisixMutualTlsClientConfig{
					CASecret: v2.ApisixSecret{Name: name, Namespace: namespace},
					Depth:    1,
				},
			},
		}, metav1.CreateOptions{})
		if err != nil {
			t.Fatalf("create referencing ApisixTls: %v", err)
		}

		statuses, err := action.ListGatewayClientCAs(namespace)
		if err != nil {
			t.Fatalf("list client CAs: %v", err)
		}
		if len(statuses) != 1 || statuses[0].Name != name || !reflect.DeepEqual(statuses[0].BoundDomains, []string{"api.example.com"}) {
			t.Fatalf("client CA statuses = %#v", statuses)
		}

		if err := action.DeleteGatewayClientCA(namespace, name); err == nil {
			t.Fatal("expected referenced client CA deletion to fail")
		}
		if _, err := kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{}); err != nil {
			t.Fatalf("referenced client CA secret was deleted: %v", err)
		}

		if err := apisixClient.ApisixV2().ApisixTlses(namespace).Delete(context.Background(), "api-tls", metav1.DeleteOptions{}); err != nil {
			t.Fatalf("delete referencing ApisixTls: %v", err)
		}
		if err := action.DeleteGatewayClientCA(namespace, name); err != nil {
			t.Fatalf("delete unreferenced client CA: %v", err)
		}
		if _, err := kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{}); !k8serrors.IsNotFound(err) {
			t.Fatalf("client CA secret still exists, error: %v", err)
		}
	})

	tests := []struct {
		name        string
		certificate string
	}{
		{name: "malformed", certificate: "not a certificate"},
		{name: "non CA", certificate: testCertificatePEM(t, "client", false, time.Now().Add(24*time.Hour))},
		{name: "expired", certificate: testCertificatePEM(t, "expired-ca", true, time.Now().Add(-time.Minute))},
	}
	for _, tt := range tests {
		t.Run("rejects "+tt.name, func(t *testing.T) {
			kubeClient := k8sfake.NewSimpleClientset()
			action := gatewayActionForCertificateTest(kubeClient, apisixfake.NewSimpleClientset())
			if err := action.AddGatewayClientCA(namespace, &apimodel.GatewayClientCA{
				Name: name, Certificate: tt.certificate,
			}); err == nil {
				t.Fatalf("expected %s certificate to be rejected", tt.name)
			}
			if _, err := kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{}); !k8serrors.IsNotFound(err) {
				t.Fatalf("invalid client CA created a secret, error: %v", err)
			}
		})
	}
}

func tlsHostsForTest(tls *v2.ApisixTls) []string {
	hosts := make([]string, 0, len(tls.Spec.Hosts))
	for _, host := range tls.Spec.Hosts {
		hosts = append(hosts, string(host))
	}
	sort.Strings(hosts)
	return hosts
}

// capability_id: rainbond.gateway.domain-mtls
func TestGatewayDomainMTLS(t *testing.T) {
	const (
		namespace = "tenant-ns"
		caName    = "rbd-client-ca-certificate-id"
	)
	validCA := testCertificatePEM(t, "client-ca", true, time.Now().Add(24*time.Hour))
	clientCASecret := &corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      caName,
			Namespace: namespace,
			Labels:    map[string]string{gatewayClientCASecretLabel: gatewayClientCASecretLabelValue},
		},
		Data: map[string][]byte{corev1.ServiceAccountRootCAKey: []byte(validCA)},
	}

	t.Run("configures and disables a single exact domain", func(t *testing.T) {
		tls := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{Name: "server-tls", Namespace: namespace},
			Spec: &v2.ApisixTlsSpec{
				Hosts:  []v2.HostType{"api.example.com"},
				Secret: v2.ApisixSecret{Name: "server-secret", Namespace: namespace},
			},
		}
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(clientCASecret.DeepCopy()), apisixfake.NewSimpleClientset(tls))

		if err := action.ConfigureGatewayDomainMTLS(namespace, &apimodel.GatewayDomainMTLS{
			Domain: "api.example.com", ClientCASecretName: caName,
		}); err != nil {
			t.Fatalf("configure domain mTLS: %v", err)
		}
		updated, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).Get(context.Background(), "server-tls", metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get configured ApisixTls: %v", err)
		}
		if updated.Spec.Client == nil || updated.Spec.Client.CASecret.Name != caName || updated.Spec.Client.Depth != 1 {
			t.Fatalf("mTLS client config = %#v", updated.Spec.Client)
		}
		statuses, err := action.GetGatewayDomainMTLS(namespace, []string{"api.example.com", "other.example.com"})
		if err != nil {
			t.Fatalf("get domain mTLS status: %v", err)
		}
		if len(statuses) != 2 || !statuses[0].Enabled || statuses[0].ClientCASecretName != caName || statuses[1].Enabled {
			t.Fatalf("domain mTLS statuses = %#v", statuses)
		}

		if err := action.DisableGatewayDomainMTLS(namespace, "api.example.com"); err != nil {
			t.Fatalf("disable domain mTLS: %v", err)
		}
		updated, err = action.apisixClient.ApisixV2().ApisixTlses(namespace).Get(context.Background(), "server-tls", metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get disabled ApisixTls: %v", err)
		}
		if updated.Spec.Client != nil {
			t.Fatalf("mTLS client config was not removed: %#v", updated.Spec.Client)
		}
	})

	t.Run("splits and restores one domain from a multi host certificate", func(t *testing.T) {
		tls := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{Name: "server-tls", Namespace: namespace},
			Spec: &v2.ApisixTlsSpec{
				Hosts:  []v2.HostType{"api.example.com", "www.example.com"},
				Secret: v2.ApisixSecret{Name: "server-secret", Namespace: namespace},
			},
		}
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(clientCASecret.DeepCopy()), apisixfake.NewSimpleClientset(tls))

		if err := action.ConfigureGatewayDomainMTLS(namespace, &apimodel.GatewayDomainMTLS{
			Domain: "api.example.com", ClientCASecretName: caName,
		}); err != nil {
			t.Fatalf("configure split domain mTLS: %v", err)
		}
		list, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).List(context.Background(), metav1.ListOptions{})
		if err != nil {
			t.Fatalf("list split ApisixTls resources: %v", err)
		}
		if len(list.Items) != 2 {
			t.Fatalf("ApisixTls resource count = %d, expected 2", len(list.Items))
		}
		base, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).Get(context.Background(), "server-tls", metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get base ApisixTls: %v", err)
		}
		if !reflect.DeepEqual(tlsHostsForTest(base), []string{"www.example.com"}) {
			t.Fatalf("base hosts = %#v", base.Spec.Hosts)
		}
		var split *v2.ApisixTls
		for i := range list.Items {
			candidate := &list.Items[i]
			if candidate.Name != "server-tls" {
				split = candidate
			}
		}
		if split == nil || !reflect.DeepEqual(tlsHostsForTest(split), []string{"api.example.com"}) || split.Spec.Client == nil {
			t.Fatalf("split ApisixTls = %#v", split)
		}
		if split.Spec.Secret != tls.Spec.Secret {
			t.Fatalf("split server secret = %#v, expected %#v", split.Spec.Secret, tls.Spec.Secret)
		}

		if err := action.DisableGatewayDomainMTLS(namespace, "api.example.com"); err != nil {
			t.Fatalf("disable split domain mTLS: %v", err)
		}
		list, err = action.apisixClient.ApisixV2().ApisixTlses(namespace).List(context.Background(), metav1.ListOptions{})
		if err != nil {
			t.Fatalf("list restored ApisixTls resources: %v", err)
		}
		if len(list.Items) != 1 || list.Items[0].Name != "server-tls" {
			t.Fatalf("restored ApisixTls resources = %#v", list.Items)
		}
		if !reflect.DeepEqual(tlsHostsForTest(&list.Items[0]), []string{"api.example.com", "www.example.com"}) {
			t.Fatalf("restored base hosts = %#v", list.Items[0].Spec.Hosts)
		}
	})

	t.Run("creates a specific override for a wildcard certificate", func(t *testing.T) {
		tls := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{Name: "wildcard-tls", Namespace: namespace},
			Spec: &v2.ApisixTlsSpec{
				Hosts:  []v2.HostType{"*.example.com"},
				Secret: v2.ApisixSecret{Name: "wildcard-secret", Namespace: namespace},
			},
		}
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(clientCASecret.DeepCopy()), apisixfake.NewSimpleClientset(tls))

		if err := action.ConfigureGatewayDomainMTLS(namespace, &apimodel.GatewayDomainMTLS{
			Domain: "api.example.com", ClientCASecretName: caName,
		}); err != nil {
			t.Fatalf("configure wildcard domain mTLS: %v", err)
		}
		list, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).List(context.Background(), metav1.ListOptions{})
		if err != nil {
			t.Fatalf("list wildcard ApisixTls resources: %v", err)
		}
		if len(list.Items) != 2 {
			t.Fatalf("wildcard ApisixTls resource count = %d, expected 2", len(list.Items))
		}
		base, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).Get(context.Background(), "wildcard-tls", metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get wildcard base ApisixTls: %v", err)
		}
		if !reflect.DeepEqual(tlsHostsForTest(base), []string{"*.example.com"}) || base.Spec.Client != nil {
			t.Fatalf("wildcard base was modified: %#v", base.Spec)
		}

		if err := action.DisableGatewayDomainMTLS(namespace, "api.example.com"); err != nil {
			t.Fatalf("disable wildcard domain mTLS: %v", err)
		}
		list, err = action.apisixClient.ApisixV2().ApisixTlses(namespace).List(context.Background(), metav1.ListOptions{})
		if err != nil {
			t.Fatalf("list wildcard resources after disable: %v", err)
		}
		if len(list.Items) != 1 || list.Items[0].Name != "wildcard-tls" {
			t.Fatalf("wildcard resources after disable = %#v", list.Items)
		}
	})

	t.Run("configures a wildcard domain", func(t *testing.T) {
		tls := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{Name: "wildcard-tls", Namespace: namespace},
			Spec: &v2.ApisixTlsSpec{
				Hosts:  []v2.HostType{"*.example.com"},
				Secret: v2.ApisixSecret{Name: "wildcard-secret", Namespace: namespace},
			},
		}
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(clientCASecret.DeepCopy()), apisixfake.NewSimpleClientset(tls))

		if err := action.ConfigureGatewayDomainMTLS(namespace, &apimodel.GatewayDomainMTLS{
			Domain: "*.example.com", ClientCASecretName: caName,
		}); err != nil {
			t.Fatalf("configure wildcard mTLS: %v", err)
		}
		updated, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).Get(
			context.Background(), "wildcard-tls", metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get configured wildcard ApisixTls: %v", err)
		}
		if updated.Spec.Client == nil || updated.Spec.Client.CASecret.Name != caName {
			t.Fatalf("wildcard mTLS client config = %#v", updated.Spec.Client)
		}
	})

	t.Run("rejects missing client CA and missing server TLS", func(t *testing.T) {
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(), apisixfake.NewSimpleClientset())
		if err := action.ConfigureGatewayDomainMTLS(namespace, &apimodel.GatewayDomainMTLS{
			Domain: "api.example.com", ClientCASecretName: caName,
		}); err == nil {
			t.Fatal("expected missing client CA to fail")
		}

		action = gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(clientCASecret.DeepCopy()), apisixfake.NewSimpleClientset())
		if err := action.ConfigureGatewayDomainMTLS(namespace, &apimodel.GatewayDomainMTLS{
			Domain: "api.example.com", ClientCASecretName: caName,
		}); err == nil {
			t.Fatal("expected missing server TLS to fail")
		}
	})
}

func assertGatewayCertificateResources(t *testing.T, action *GatewayAction, req *apimodel.GatewayCertificate, domain string) {
	t.Helper()

	secret, err := action.kubeClient.CoreV1().Secrets(req.Namespace).Get(context.Background(), req.Name, metav1.GetOptions{})
	if err != nil {
		t.Fatalf("get reconciled Secret: %v", err)
	}
	if !bytes.Equal(secret.Data[corev1.TLSCertKey], []byte(req.Certificate)) {
		t.Fatal("reconciled Secret certificate does not match request")
	}
	if !bytes.Equal(secret.Data[corev1.TLSPrivateKeyKey], []byte(req.PrivateKey)) {
		t.Fatal("reconciled Secret private key does not match request")
	}

	tls, err := action.apisixClient.ApisixV2().ApisixTlses(req.Namespace).Get(context.Background(), req.Name, metav1.GetOptions{})
	if err != nil {
		t.Fatalf("get reconciled ApisixTls: %v", err)
	}
	if tls.Spec == nil {
		t.Fatal("reconciled ApisixTls has no spec")
	}
	if tls.Spec.IngressClassName != "apisix" {
		t.Fatalf("ingress class = %q, expected apisix", tls.Spec.IngressClassName)
	}
	if !reflect.DeepEqual(tls.Spec.Hosts, []v2.HostType{v2.HostType(domain)}) {
		t.Fatalf("hosts = %#v, expected %q", tls.Spec.Hosts, domain)
	}
	if tls.Spec.Secret.Name != req.Name || tls.Spec.Secret.Namespace != req.Namespace {
		t.Fatalf("secret reference = %s/%s, expected %s/%s", tls.Spec.Secret.Namespace, tls.Spec.Secret.Name, req.Namespace, req.Name)
	}
}

// capability_id: rainbond.gateway.certificate-resource-consistency
func TestGatewayCertificateResourceConsistency(t *testing.T) {
	const (
		name      = "gateway-cert"
		namespace = "tenant-ns"
	)

	t.Run("add creates Secret and ApisixTls", func(t *testing.T) {
		req := gatewayCertificateRequestForTest(t, name, namespace, "add.example.com")
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(), apisixfake.NewSimpleClientset())

		if err := action.AddGatewayCertificate(req); err != nil {
			t.Fatalf("add gateway certificate: %v", err)
		}

		assertGatewayCertificateResources(t, action, req, "add.example.com")
	})

	t.Run("add repairs an orphan Secret", func(t *testing.T) {
		req := gatewayCertificateRequestForTest(t, name, namespace, "repaired.example.com")
		existing := &corev1.Secret{
			ObjectMeta: metav1.ObjectMeta{
				Name:            name,
				Namespace:       namespace,
				ResourceVersion: "7",
				Labels:          map[string]string{"keep": "label"},
			},
			Data: map[string][]byte{
				corev1.TLSCertKey:       []byte("old certificate"),
				corev1.TLSPrivateKeyKey: []byte("old private key"),
			},
			Type: corev1.SecretTypeTLS,
		}
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(existing), apisixfake.NewSimpleClientset())

		if err := action.AddGatewayCertificate(req); err != nil {
			t.Fatalf("repair gateway certificate: %v", err)
		}

		assertGatewayCertificateResources(t, action, req, "repaired.example.com")
		secret, err := action.kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get repaired Secret: %v", err)
		}
		if secret.Labels["keep"] != "label" {
			t.Fatalf("existing Secret metadata was not retained: %#v", secret.Labels)
		}
	})

	t.Run("new Secret is removed after ApisixTls failure", func(t *testing.T) {
		req := gatewayCertificateRequestForTest(t, name, namespace, "new-rollback.example.com")
		kubeClient := k8sfake.NewSimpleClientset()
		apisixClient := apisixfake.NewSimpleClientset()
		apisixClient.PrependReactor("create", "apisixtlses", func(k8stesting.Action) (bool, runtime.Object, error) {
			return true, nil, errors.New("apisix create failed")
		})
		action := gatewayActionForCertificateTest(kubeClient, apisixClient)

		if err := action.AddGatewayCertificate(req); err == nil {
			t.Fatal("expected add to fail")
		}

		_, err := kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{})
		if !k8serrors.IsNotFound(err) {
			t.Fatalf("new Secret was not rolled back, get error: %v", err)
		}
	})

	t.Run("updated Secret is restored after ApisixTls failure", func(t *testing.T) {
		req := gatewayCertificateRequestForTest(t, name, namespace, "updated-rollback.example.com")
		existing := &corev1.Secret{
			ObjectMeta: metav1.ObjectMeta{
				Name:            name,
				Namespace:       namespace,
				ResourceVersion: "9",
				Annotations:     map[string]string{"keep": "annotation"},
			},
			Data: map[string][]byte{
				corev1.TLSCertKey:       []byte("original certificate"),
				corev1.TLSPrivateKeyKey: []byte("original private key"),
			},
			Type: corev1.SecretTypeTLS,
		}
		kubeClient := k8sfake.NewSimpleClientset(existing)
		apisixClient := apisixfake.NewSimpleClientset()
		apisixClient.PrependReactor("create", "apisixtlses", func(k8stesting.Action) (bool, runtime.Object, error) {
			return true, nil, errors.New("apisix create failed")
		})
		action := gatewayActionForCertificateTest(kubeClient, apisixClient)

		if err := action.UpdateGatewayCertificate(req); err == nil {
			t.Fatal("expected update to fail")
		}

		secret, err := kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get restored Secret: %v", err)
		}
		if !reflect.DeepEqual(secret.Data, existing.Data) {
			t.Fatalf("restored Secret data = %#v, expected %#v", secret.Data, existing.Data)
		}
		if !reflect.DeepEqual(secret.Annotations, existing.Annotations) {
			t.Fatalf("restored Secret annotations = %#v, expected %#v", secret.Annotations, existing.Annotations)
		}
	})

	t.Run("update creates both resources when absent", func(t *testing.T) {
		req := gatewayCertificateRequestForTest(t, name, namespace, "update.example.com")
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(), apisixfake.NewSimpleClientset())

		if err := action.UpdateGatewayCertificate(req); err != nil {
			t.Fatalf("update missing gateway certificate: %v", err)
		}

		assertGatewayCertificateResources(t, action, req, "update.example.com")
	})

	t.Run("update preserves existing mutual TLS client config", func(t *testing.T) {
		req := gatewayCertificateRequestForTest(t, name, namespace, "mtls.example.com")
		existingTLS := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
			Spec: &v2.ApisixTlsSpec{
				Client: &v2.ApisixMutualTlsClientConfig{
					CASecret: v2.ApisixSecret{Name: "client-ca", Namespace: namespace},
					Depth:    2,
				},
			},
		}
		action := gatewayActionForCertificateTest(
			k8sfake.NewSimpleClientset(),
			apisixfake.NewSimpleClientset(existingTLS),
		)

		if err := action.UpdateGatewayCertificate(req); err != nil {
			t.Fatalf("update gateway certificate: %v", err)
		}

		updatedTLS, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).Get(
			context.Background(), name, metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get updated ApisixTls: %v", err)
		}
		if !reflect.DeepEqual(updatedTLS.Spec.Client, existingTLS.Spec.Client) {
			t.Fatalf("mutual TLS client config = %#v, expected %#v", updatedTLS.Spec.Client, existingTLS.Spec.Client)
		}
	})

	t.Run("update keeps detached mTLS domains out of the base resource", func(t *testing.T) {
		req := gatewayCertificateRequestForDomains(t, name, namespace, []string{"api.example.com", "www.example.com"})
		baseTLS := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
			Spec: &v2.ApisixTlsSpec{
				Hosts:  []v2.HostType{"www.example.com"},
				Secret: v2.ApisixSecret{Name: name, Namespace: namespace},
			},
		}
		childTLS := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{
				Name:      gatewayMTLSResourceName("api.example.com"),
				Namespace: namespace,
				Labels:    map[string]string{gatewayMTLSManagedLabel: "true"},
				Annotations: map[string]string{
					gatewayMTLSDomainAnnotation:   "api.example.com",
					gatewayMTLSParentAnnotation:   name,
					gatewayMTLSDetachedAnnotation: "true",
					gatewayMTLSInPlaceAnnotation:  "false",
				},
			},
			Spec: &v2.ApisixTlsSpec{
				Hosts:  []v2.HostType{"api.example.com"},
				Secret: v2.ApisixSecret{Name: name, Namespace: namespace},
				Client: gatewayMTLSClient(namespace, "client-ca"),
			},
		}
		action := gatewayActionForCertificateTest(
			k8sfake.NewSimpleClientset(),
			apisixfake.NewSimpleClientset(baseTLS, childTLS),
		)

		if err := action.UpdateGatewayCertificate(req); err != nil {
			t.Fatalf("update gateway certificate with detached mTLS domain: %v", err)
		}
		updatedBase, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).Get(context.Background(), name, metav1.GetOptions{})
		if err != nil {
			t.Fatalf("get updated base ApisixTls: %v", err)
		}
		if !reflect.DeepEqual(tlsHostsForTest(updatedBase), []string{"www.example.com"}) {
			t.Fatalf("updated base hosts = %#v, expected detached domain to stay excluded", updatedBase.Spec.Hosts)
		}
	})

	t.Run("delete ignores missing resources", func(t *testing.T) {
		action := gatewayActionForCertificateTest(k8sfake.NewSimpleClientset(), apisixfake.NewSimpleClientset())

		if err := action.DeleteGatewayCertificate(name, namespace); err != nil {
			t.Fatalf("delete missing gateway certificate: %v", err)
		}
	})

	t.Run("delete removes ApisixTls before Secret", func(t *testing.T) {
		secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}}
		tls := &v2.ApisixTls{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}}
		kubeClient := k8sfake.NewSimpleClientset(secret)
		apisixClient := apisixfake.NewSimpleClientset(tls)
		var deletionOrder []string
		apisixClient.PrependReactor("delete", "apisixtlses", func(k8stesting.Action) (bool, runtime.Object, error) {
			deletionOrder = append(deletionOrder, "ApisixTls")
			return false, nil, nil
		})
		kubeClient.PrependReactor("delete", "secrets", func(k8stesting.Action) (bool, runtime.Object, error) {
			deletionOrder = append(deletionOrder, "Secret")
			return false, nil, nil
		})
		action := gatewayActionForCertificateTest(kubeClient, apisixClient)

		if err := action.DeleteGatewayCertificate(name, namespace); err != nil {
			t.Fatalf("delete gateway certificate: %v", err)
		}
		if !reflect.DeepEqual(deletionOrder, []string{"ApisixTls", "Secret"}) {
			t.Fatalf("deletion order = %#v, expected ApisixTls then Secret", deletionOrder)
		}
	})

	t.Run("delete removes managed mTLS resources that reference the server certificate", func(t *testing.T) {
		secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}}
		baseTLS := &v2.ApisixTls{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}}
		childTLS := &v2.ApisixTls{
			ObjectMeta: metav1.ObjectMeta{
				Name:      gatewayMTLSResourceName("api.example.com"),
				Namespace: namespace,
				Labels:    map[string]string{gatewayMTLSManagedLabel: "true"},
				Annotations: map[string]string{
					gatewayMTLSDomainAnnotation: "api.example.com",
					gatewayMTLSParentAnnotation: name,
				},
			},
			Spec: &v2.ApisixTlsSpec{Secret: v2.ApisixSecret{Name: name, Namespace: namespace}},
		}
		action := gatewayActionForCertificateTest(
			k8sfake.NewSimpleClientset(secret),
			apisixfake.NewSimpleClientset(baseTLS, childTLS),
		)

		if err := action.DeleteGatewayCertificate(name, namespace); err != nil {
			t.Fatalf("delete gateway certificate with managed mTLS child: %v", err)
		}
		list, err := action.apisixClient.ApisixV2().ApisixTlses(namespace).List(context.Background(), metav1.ListOptions{})
		if err != nil {
			t.Fatalf("list ApisixTls resources after certificate deletion: %v", err)
		}
		if len(list.Items) != 0 {
			t.Fatalf("orphan ApisixTls resources remain: %#v", list.Items)
		}
	})
}

// capability_id: rainbond.gateway.http-route-delete-component-event
func TestCreateGatewayHTTPRouteDeleteEvents(t *testing.T) {
	eventDao := &gatewayRouteDeleteEventDao{}
	portDao := &gatewayRouteDeleteEventPortDao{
		portsByName: map[string]*dbmodel.TenantServicesPort{
			"svc-a": {
				TenantID:      "tenant-a",
				ServiceID:     "component-a",
				ContainerPort: 80,
			},
			"svc-b": {
				TenantID:      "tenant-b",
				ServiceID:     "component-b",
				ContainerPort: 8080,
			},
		},
	}
	db.SetTestManager(gatewayRouteDeleteEventTestManager{portDao: portDao, eventDao: eventDao})
	defer db.SetTestManager(nil)

	route := &apimodel.GatewayHTTPRouteStruct{
		Name:  "route-a",
		Hosts: []string{"example.com"},
		Rules: []*apimodel.Rules{
			{
				BackendRefsRules: []*apimodel.BackendRefsRule{
					{Name: "svc-a", Kind: apimodel.Service, Port: 80},
					{Name: "svc-b", Kind: apimodel.Service, Port: 8080},
					{Name: "svc-a", Kind: apimodel.Service, Port: 80},
				},
			},
		},
	}

	events, err := (&GatewayAction{}).createGatewayHTTPRouteDeleteEvents(route, "alice")

	if err != nil {
		t.Fatalf("createGatewayHTTPRouteDeleteEvents returned error: %v", err)
	}
	if len(events) != 2 {
		t.Fatalf("expected 2 events, got %d", len(events))
	}
	if len(eventDao.events) != 2 {
		t.Fatalf("expected 2 persisted events, got %d", len(eventDao.events))
	}
	for _, event := range eventDao.events {
		if event.Target != dbmodel.TargetTypeService {
			t.Fatalf("event target = %s, expected %s", event.Target, dbmodel.TargetTypeService)
		}
		if event.OptType != "delete-gateway-http-route" {
			t.Fatalf("event opt_type = %s, expected delete-gateway-http-route", event.OptType)
		}
		if event.UserName != "alice" {
			t.Fatalf("event user = %s, expected alice", event.UserName)
		}
		if event.SynType != dbmodel.SYNEVENTTYPE {
			t.Fatalf("event syn_type = %d, expected %d", event.SynType, dbmodel.SYNEVENTTYPE)
		}
	}
	if eventDao.events[0].TargetID != "component-a" || eventDao.events[1].TargetID != "component-b" {
		t.Fatalf("unexpected event target IDs: %s, %s", eventDao.events[0].TargetID, eventDao.events[1].TargetID)
	}
}