diff --git a/pkg/webhook/multiclusterservice/mutating_test.go b/pkg/webhook/multiclusterservice/mutating_test.go new file mode 100644 index 000000000000..a4edf66652cd --- /dev/null +++ b/pkg/webhook/multiclusterservice/mutating_test.go @@ -0,0 +1,204 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package multiclusterservice + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "reflect" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + networkingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/networking/v1alpha1" +) + +type fakeMutationDecoder struct { + err error + obj runtime.Object +} + +// Decode mocks the Decode method of admission.Decoder. +func (f *fakeMutationDecoder) Decode(_ admission.Request, obj runtime.Object) error { + if f.err != nil { + return f.err + } + if f.obj != nil { + reflect.ValueOf(obj).Elem().Set(reflect.ValueOf(f.obj).Elem()) + } + return nil +} + +// DecodeRaw mocks the DecodeRaw method of admission.Decoder. +func (f *fakeMutationDecoder) DecodeRaw(_ runtime.RawExtension, obj runtime.Object) error { + if f.err != nil { + return f.err + } + if f.obj != nil { + reflect.ValueOf(obj).Elem().Set(reflect.ValueOf(f.obj).Elem()) + } + return nil +} + +func TestMutatingAdmission_Handle(t *testing.T) { + tests := []struct { + name string + decoder admission.Decoder + req admission.Request + want admission.Response + }{ + { + name: "Handle_DecodeError_DeniesAdmission", + decoder: &fakeValidationDecoder{ + err: errors.New("decode error"), + }, + req: admission.Request{}, + want: admission.Errored(http.StatusBadRequest, errors.New("decode error")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := MutatingAdmission{ + Decoder: tt.decoder, + } + got := m.Handle(context.Background(), tt.req) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Handle() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMutatingAdmission_Handle_FullCoverage(t *testing.T) { + // Define the multi-cluster service (mcs) name and namespace to be used in the test. + name := "test-mcs" + namespace := "test-namespace" + + // Mock a request with a specific namespace. + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: name, + Namespace: namespace, + }, + } + + // Create the initial mcs with default values for testing. + mcsObj := &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + ResourceVersion: "1001", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Ports: []networkingv1alpha1.ExposurePort{ + { + Name: "foo", + Port: 16312, + }, + { + Name: "bar", + Port: 16313, + }, + }, + ProviderClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + ConsumerClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + } + + // Define the expected mcs object after mutations. + wantMCSObj := &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + ResourceVersion: "1001", + Labels: map[string]string{ + networkingv1alpha1.MultiClusterServicePermanentIDLabel: "some-unique-id", + }, + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Ports: []networkingv1alpha1.ExposurePort{ + { + Name: "foo", + Port: 16312, + }, + { + Name: "bar", + Port: 16313, + }, + }, + ProviderClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + ConsumerClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + } + + // Mock decoder that decodes the request into the mcs object. + decoder := &fakeMutationDecoder{ + obj: mcsObj, + } + + // Marshal the expected policy to simulate the final mutated object. + wantBytes, err := json.Marshal(wantMCSObj) + if err != nil { + t.Fatalf("Failed to marshal expected policy: %v", err) + } + req.Object.Raw = wantBytes + + // Instantiate the mutating handler. + mutatingHandler := MutatingAdmission{ + Decoder: decoder, + } + + // Call the Handle function. + got := mutatingHandler.Handle(context.Background(), req) + + // Verify that the only patch applied is for the UUID label. If any other patches are present, it indicates that the mcs object was not handled as expected. + if len(got.Patches) > 0 { + firstPatch := got.Patches[0] + if firstPatch.Operation != "replace" || firstPatch.Path != "/metadata/labels/multiclusterservice.karmada.io~1permanent-id" { + t.Errorf("Handle() returned unexpected patches. Only the UUID patch was expected. Received patches: %v", got.Patches) + } + } + + // Check if the admission request was allowed. + if !got.Allowed { + t.Errorf("Handle() got.Allowed = false, want true") + } +} diff --git a/pkg/webhook/multiclusterservice/validating_test.go b/pkg/webhook/multiclusterservice/validating_test.go old mode 100755 new mode 100644 index 3f722c6c9091..4519ca9b24ff --- a/pkg/webhook/multiclusterservice/validating_test.go +++ b/pkg/webhook/multiclusterservice/validating_test.go @@ -17,15 +17,281 @@ limitations under the License. package multiclusterservice import ( + "context" + "errors" + "net/http" "reflect" + "strings" "testing" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" networkingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/networking/v1alpha1" ) +// ResponseType represents the type of admission response. +type ResponseType string + +const ( + Denied ResponseType = "Denied" + Allowed ResponseType = "Allowed" + Errored ResponseType = "Errored" +) + +// TestResponse is used to define expected response in a test case. +type TestResponse struct { + Type ResponseType + Message string +} + +type fakeValidationDecoder struct { + err error + obj runtime.Object +} + +// Decode mocks the Decode method of admission.Decoder. +func (f *fakeValidationDecoder) Decode(_ admission.Request, obj runtime.Object) error { + if f.err != nil { + return f.err + } + if f.obj != nil { + reflect.ValueOf(obj).Elem().Set(reflect.ValueOf(f.obj).Elem()) + } + return nil +} + +// DecodeRaw mocks the DecodeRaw method of admission.Decoder. +func (f *fakeValidationDecoder) DecodeRaw(rawObject runtime.RawExtension, obj runtime.Object) error { + if f.err != nil { + return f.err + } + if rawObject.Object != nil { + reflect.ValueOf(obj).Elem().Set(reflect.ValueOf(rawObject.Object).Elem()) + } + return nil +} + +func TestValidatingAdmission_Handle(t *testing.T) { + tests := []struct { + name string + decoder admission.Decoder + req admission.Request + want TestResponse + }{ + { + name: "Handle_DecodeError_DeniesAdmission", + decoder: &fakeValidationDecoder{ + err: errors.New("decode error"), + }, + req: admission.Request{}, + want: TestResponse{ + Type: Errored, + Message: "decode error", + }, + }, + { + name: "Handle_DecodeOldObjectError_DeniesAdmission", + decoder: &fakeValidationDecoder{ + err: errors.New("decode raw error"), + }, + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + OldObject: runtime.RawExtension{ + Object: nil, + }, + }, + }, + want: TestResponse{ + Type: Errored, + Message: "decode raw error", + }, + }, + { + name: "Handle_UpdateMCSWithInvalidSpec_DeniesAdmission", + decoder: &fakeValidationDecoder{ + obj: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1001", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Ports: []networkingv1alpha1.ExposurePort{ + { + Name: "foo.withdot", + Port: 16312, + }, + { + Name: "bar", + Port: 16313, + }, + }, + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + ProviderClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + ConsumerClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + }, + }, + }, + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + OldObject: runtime.RawExtension{ + Object: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1000", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + }, + }, + }, + }, + want: TestResponse{ + Type: Denied, + Message: "Invalid value: \"foo.withdot\": must not contain dots", + }, + }, + { + name: "Handle_CreateMCSWithInvalidSpec_DeniesAdmission", + decoder: &fakeValidationDecoder{ + obj: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Ports: []networkingv1alpha1.ExposurePort{ + { + Name: "foo.withdot", + Port: 16312, + }, + { + Name: "bar", + Port: 16313, + }, + }, + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + ProviderClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + ConsumerClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + }, + }, + }, + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + }, + }, + want: TestResponse{ + Type: Denied, + Message: "Invalid value: \"foo.withdot\": must not contain dots", + }, + }, + { + name: "Handle_ValidationSucceeds_AllowsAdmission", + decoder: &fakeValidationDecoder{ + obj: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1001", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Ports: []networkingv1alpha1.ExposurePort{ + { + Name: "foo", + Port: 16312, + }, + { + Name: "bar", + Port: 16313, + }, + }, + ProviderClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + ConsumerClusters: []networkingv1alpha1.ClusterSelector{ + {Name: "member1"}, + {Name: "member2"}, + }, + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + }, + }, + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + OldObject: runtime.RawExtension{ + Object: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1000", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + }, + }, + }, + }, + want: TestResponse{ + Type: Allowed, + Message: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &ValidatingAdmission{ + Decoder: tt.decoder, + } + got := v.Handle(context.Background(), tt.req) + + // Extract type and message from the actual response. + gotType := extractResponseType(got) + gotMessage := extractErrorMessage(got) + + if gotType != tt.want.Type || !strings.Contains(gotMessage, tt.want.Message) { + t.Errorf("Handle() = {Type: %v, Message: %v}, want {Type: %v, Message: %v}", gotType, gotMessage, tt.want.Type, tt.want.Message) + } + }) + } +} + func TestValidateMultiClusterServiceSpec(t *testing.T) { validator := &ValidatingAdmission{} specFld := field.NewPath("spec") @@ -191,12 +457,12 @@ func TestValidateMultiClusterServiceSpec(t *testing.T) { networkingv1alpha1.ExposureTypeCrossCluster, }, ProviderClusters: []networkingv1alpha1.ClusterSelector{ - {Name: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {Name: strings.Repeat("a", 49)}, }, ConsumerClusters: []networkingv1alpha1.ClusterSelector{}, }, }, - expectedErr: field.ErrorList{field.Invalid(specFld.Child("range").Child("providerClusters").Index(0), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "must be no more than 48 characters")}, + expectedErr: field.ErrorList{field.Invalid(specFld.Child("range").Child("providerClusters").Index(0), strings.Repeat("a", 49), "must be no more than 48 characters")}, }, } for _, tt := range tests { @@ -207,3 +473,152 @@ func TestValidateMultiClusterServiceSpec(t *testing.T) { }) } } + +func TestValidatingSpec_validateMCSUpdate(t *testing.T) { + tests := []struct { + name string + oldMcs *networkingv1alpha1.MultiClusterService + newMcs *networkingv1alpha1.MultiClusterService + wantErr bool + errMsg string + }{ + { + name: "validateMCSUpdate_ValidMetadataUpdate_NoError", + oldMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + Labels: map[string]string{"key": "oldValue"}, + ResourceVersion: "1000", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + }, + newMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + Labels: map[string]string{"key": "newValue"}, + ResourceVersion: "1001", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + }, + wantErr: false, + }, + { + name: "validateMCSUpdate_InvalidMetadataUpdate_Error", + oldMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1000", + }, + }, + newMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-name", + Namespace: "test-namespace", + ResourceVersion: "1001", + }, + }, + wantErr: true, + errMsg: "metadata.name: Invalid value: \"invalid-name\"", + }, + { + name: "validateMCSUpdate_InvalidTypesUpdate_Error", + oldMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1000", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeLoadBalancer, + }, + }, + }, + newMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1001", + }, + Spec: networkingv1alpha1.MultiClusterServiceSpec{ + Types: []networkingv1alpha1.ExposureType{ + networkingv1alpha1.ExposureTypeCrossCluster, + }, + }, + }, + wantErr: true, + errMsg: "MultiClusterService types are immutable", + }, + { + name: "validateMCSUpdate_InvalidLoadBalancerStatus_Error", + oldMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1000", + }, + }, + newMcs: &networkingv1alpha1.MultiClusterService{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcs", + Namespace: "test-namespace", + ResourceVersion: "1001", + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {IP: "invalid IP"}, + }, + }, + }, + }, + wantErr: true, + errMsg: "Invalid value: \"invalid IP\": must be a valid IP address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &ValidatingAdmission{} + errs := v.validateMCSUpdate(tt.oldMcs, tt.newMcs) + if (len(errs) > 0) != tt.wantErr { + t.Errorf("validateMCSUpdate() gotErr = %v, wantErr %v", len(errs) > 0, tt.wantErr) + } + if tt.wantErr && !strings.Contains(errs.ToAggregate().Error(), tt.errMsg) { + t.Errorf("Expected error message: %v, got: %v", tt.errMsg, errs.ToAggregate().Error()) + } + }) + } +} + +// extractResponseType extracts the type of admission response. +func extractResponseType(resp admission.Response) ResponseType { + if resp.Allowed { + return Allowed + } + if resp.Result != nil { + if resp.Result.Code == http.StatusBadRequest { + return Errored + } + } + return Denied +} + +// extractErrorMessage extracts the error message from a Denied/Errored response. +func extractErrorMessage(resp admission.Response) string { + if !resp.Allowed && resp.Result != nil { + return resp.Result.Message + } + return "" +}