@@ -398,6 +398,32 @@ func TestReconciler_Reconcile(t *testing.T) {
398398 },
399399 expectedResult : common .ResultNormal ,
400400 },
401+ {
402+ name : "IPAddressType incompatible with Subnet" ,
403+ objects : []client.Object {& v1alpha1.SubnetIPReservation {
404+ ObjectMeta : metav1.ObjectMeta {Name : "ipr-1" , Namespace : "ns-1" },
405+ Spec : v1alpha1.SubnetIPReservationSpec {
406+ Subnet : "subnet-1" ,
407+ NumberOfIPs : 5 ,
408+ // Use the CRD enum value ("IPV6") that Kubernetes actually stores,
409+ // not the Go constant IPAddressTypeIPv6 = "IPv6".
410+ IPAddressType : "IPV6" ,
411+ },
412+ }},
413+ preparedFunc : func (r * Reconciler ) * gomonkey.Patches {
414+ patches := gomonkey .ApplyPrivateMethod (reflect .TypeOf (r ), "validateSubnet" , func (_ * Reconciler , ctx context.Context , ns string , name string ) (* v1alpha1.Subnet , * errorWithRetry ) {
415+ return & v1alpha1.Subnet {
416+ // Use the CRD enum value ("IPV4") that Kubernetes actually stores.
417+ Spec : v1alpha1.SubnetSpec {IPAddressType : "IPV4" },
418+ }, nil
419+ })
420+ patches .ApplyMethod (reflect .TypeOf (r .IPReservationService .NSXClient ), "NSXCheckVersion" , func (_ * nsx.Client , feature int ) bool {
421+ return true
422+ })
423+ return patches
424+ },
425+ expectedResult : common .ResultNormal ,
426+ },
401427 }
402428 for _ , tc := range tests {
403429 r := createFakeReconciler (tc .objects ... )
@@ -508,6 +534,111 @@ func TestReconcile_validateSubnet(t *testing.T) {
508534 }
509535}
510536
537+ func TestValidateIPAddressTypeCompatibility (t * testing.T ) {
538+ // All inputs use the CRD enum values ("IPV4"/"IPV6"/"IPV4IPV6", all-caps) that Kubernetes
539+ // actually stores — NOT the v1alpha1 Go constants which use a different casing ("IPv4", etc.).
540+ tests := []struct {
541+ name string
542+ subnetType v1alpha1.IPAddressType
543+ reservationType v1alpha1.IPAddressType
544+ expectedErrSubstr string
545+ }{
546+ {
547+ name : "IPv4 subnet with IPv4 reservation" ,
548+ subnetType : "IPV4" ,
549+ reservationType : "IPV4" ,
550+ },
551+ {
552+ name : "IPv4 subnet with empty reservation defaults to IPv4" ,
553+ subnetType : "IPV4" ,
554+ reservationType : "" ,
555+ },
556+ {
557+ name : "IPv4 subnet with IPv6 reservation - incompatible" ,
558+ subnetType : "IPV4" ,
559+ reservationType : "IPV6" ,
560+ expectedErrSubstr : "incompatible" ,
561+ },
562+ {
563+ name : "IPv4 subnet with dual-stack reservation - incompatible" ,
564+ subnetType : "IPV4" ,
565+ reservationType : "IPV4IPV6" ,
566+ expectedErrSubstr : "incompatible" ,
567+ },
568+ {
569+ name : "IPv6 subnet with IPv6 reservation" ,
570+ subnetType : "IPV6" ,
571+ reservationType : "IPV6" ,
572+ },
573+ {
574+ name : "IPv6 subnet with IPv4 reservation - incompatible" ,
575+ subnetType : "IPV6" ,
576+ reservationType : "IPV4" ,
577+ expectedErrSubstr : "incompatible" ,
578+ },
579+ {
580+ name : "Dual-stack subnet with IPv4 reservation" ,
581+ subnetType : "IPV4IPV6" ,
582+ reservationType : "IPV4" ,
583+ },
584+ {
585+ name : "Dual-stack subnet with IPv6 reservation" ,
586+ subnetType : "IPV4IPV6" ,
587+ reservationType : "IPV6" ,
588+ },
589+ {
590+ name : "Dual-stack subnet with dual-stack reservation" ,
591+ subnetType : "IPV4IPV6" ,
592+ reservationType : "IPV4IPV6" ,
593+ },
594+ {
595+ name : "Empty subnet type defaults to IPv4, IPv4 reservation" ,
596+ subnetType : "" ,
597+ reservationType : "IPV4" ,
598+ },
599+ {
600+ name : "Empty subnet type defaults to IPv4, IPv6 reservation - incompatible" ,
601+ subnetType : "" ,
602+ reservationType : "IPV6" ,
603+ expectedErrSubstr : "incompatible" ,
604+ },
605+ // Cross-casing: legacy all-caps Subnet vs mixed-case SubnetIPReservation (the real-world bug).
606+ {
607+ name : "Legacy IPV6 subnet (all-caps) with mixed-case IPv6 reservation" ,
608+ subnetType : "IPV6" ,
609+ reservationType : "IPv6" ,
610+ },
611+ {
612+ name : "Legacy IPV4 subnet (all-caps) with mixed-case IPv4 reservation" ,
613+ subnetType : "IPV4" ,
614+ reservationType : "IPv4" ,
615+ },
616+ {
617+ name : "Legacy IPV4 subnet (all-caps) with mixed-case IPv6 reservation - incompatible" ,
618+ subnetType : "IPV4" ,
619+ reservationType : "IPv6" ,
620+ expectedErrSubstr : "incompatible" ,
621+ },
622+ {
623+ name : "Legacy IPV4IPV6 subnet (all-caps) with mixed-case IPv6 reservation" ,
624+ subnetType : "IPV4IPV6" ,
625+ reservationType : "IPv6" ,
626+ },
627+ }
628+
629+ for _ , tc := range tests {
630+ t .Run (tc .name , func (t * testing.T ) {
631+ err := validateIPAddressTypeCompatibility (tc .subnetType , tc .reservationType )
632+ if tc .expectedErrSubstr != "" {
633+ assert .Error (t , err )
634+ assert .Contains (t , err .Error (), tc .expectedErrSubstr )
635+ } else {
636+ assert .NoError (t , err )
637+ }
638+ })
639+ }
640+ }
641+
511642func TestReconcile_CollectGarbage (t * testing.T ) {
512643 ipr1 := & v1alpha1.SubnetIPReservation {
513644 ObjectMeta : metav1.ObjectMeta {
0 commit comments