diff --git a/pkg/webhook/interpreter/decode.go b/pkg/webhook/interpreter/decode.go index f10c5a416489..ea4bce532967 100644 --- a/pkg/webhook/interpreter/decode.go +++ b/pkg/webhook/interpreter/decode.go @@ -43,7 +43,7 @@ func NewDecoder(scheme *runtime.Scheme) *Decoder { // It errors out if req.Object.Raw is empty i.e. containing 0 raw bytes. func (d *Decoder) Decode(req Request, into runtime.Object) error { if len(req.Object.Raw) == 0 { - return fmt.Errorf("there is no context to decode") + return fmt.Errorf("there is no content to decode") } return d.DecodeRaw(req.Object, into) } diff --git a/pkg/webhook/interpreter/decode_test.go b/pkg/webhook/interpreter/decode_test.go new file mode 100644 index 000000000000..9b3580389056 --- /dev/null +++ b/pkg/webhook/interpreter/decode_test.go @@ -0,0 +1,277 @@ +/* +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 interpreter + +import ( + "fmt" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + + configv1alpha1 "github.com/karmada-io/karmada/pkg/apis/config/v1alpha1" +) + +type Interface interface { + GetAPIVersion() string + GetKind() string + GetName() string +} + +// MyTestPod represents a simplified version of a Kubernetes Pod for testing purposes. +// It includes basic fields such as API version, kind, and metadata. +type MyTestPod struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` +} + +// DeepCopyObject creates a deep copy of the MyTestPod instance. +// This method is part of the runtime.Object interface and ensures that modifications +// to the copy do not affect the original object. +func (p *MyTestPod) DeepCopyObject() runtime.Object { + return &MyTestPod{ + APIVersion: p.APIVersion, + Kind: p.Kind, + Metadata: p.Metadata, + } +} + +// GetObjectKind returns the schema.ObjectKind for the MyTestPod instance. +// This method is part of the runtime.Object interface and provides the API version +// and kind of the object, which is used for object identification in Kubernetes. +func (p *MyTestPod) GetObjectKind() schema.ObjectKind { + return &metav1.TypeMeta{ + APIVersion: p.APIVersion, + Kind: p.Kind, + } +} + +// GetAPIVersion returns the API version of the MyTestPod. +func (p *MyTestPod) GetAPIVersion() string { + return p.APIVersion +} + +// GetKind returns the kind of the MyTestPod. +func (p *MyTestPod) GetKind() string { + return p.Kind +} + +// GetName returns the name of the MyTestPod. +func (p *MyTestPod) GetName() string { + return p.Metadata.Name +} + +func TestNewDecoder(t *testing.T) { + tests := []struct { + name string + }{ + { + name: "NewDecoder_ValidDecoder_DecoderIsValid", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + decoder := NewDecoder(scheme) + if decoder == nil { + t.Errorf("expected decoder to not be nil") + } + }) + } +} + +func TestDecodeRaw(t *testing.T) { + tests := []struct { + name string + apiVersion string + kind string + objName string + rawObj *runtime.RawExtension + into Interface + prep func(re *runtime.RawExtension, apiVersion, kind, name string) error + verify func(into Interface, apiVersion, kind, name string) error + wantErr bool + errMsg string + }{ + { + name: "DecodeRaw_ValidRaw_DecodeRawIsSuccessful", + objName: "test-pod", + kind: "Pod", + apiVersion: "v1", + rawObj: &runtime.RawExtension{ + Raw: []byte{}, + }, + into: &unstructured.Unstructured{}, + prep: func(re *runtime.RawExtension, apiVersion, kind, name string) error { + re.Raw = []byte(fmt.Sprintf(`{"apiVersion": "%s", "kind": "%s", "metadata": {"name": "%s"}}`, apiVersion, kind, name)) + return nil + }, + verify: verifyRuntimeObject, + wantErr: false, + }, + { + name: "DecodeRaw_IntoNonUnstructuredType_RawDecoded", + objName: "test-pod", + kind: "Pod", + apiVersion: "v1", + rawObj: &runtime.RawExtension{ + Raw: []byte{}, + }, + into: &MyTestPod{}, + prep: func(re *runtime.RawExtension, apiVersion, kind, name string) error { + re.Raw = []byte(fmt.Sprintf(`{"apiVersion": "%s", "kind": "%s", "metadata": {"name": "%s"}}`, apiVersion, kind, name)) + return nil + }, + verify: verifyRuntimeObject, + wantErr: false, + }, + { + name: "DecodeRaw_EmptyRaw_NoContentToDecode", + rawObj: &runtime.RawExtension{ + Raw: []byte{}, + }, + into: &unstructured.Unstructured{}, + prep: func(*runtime.RawExtension, string, string, string) error { return nil }, + verify: func(Interface, string, string, string) error { return nil }, + wantErr: true, + errMsg: "there is no content to decode", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.prep(test.rawObj, test.apiVersion, test.kind, test.objName); err != nil { + t.Errorf("failed to prep the runtime raw extension object: %v", err) + } + scheme := runtime.NewScheme() + decoder := NewDecoder(scheme) + intoObj, ok := test.into.(runtime.Object) + if !ok { + t.Errorf("failed to type assert into object into runtime rawextension") + } + err := decoder.DecodeRaw(*test.rawObj, intoObj) + if err != nil && !test.wantErr { + t.Errorf("unexpected error while decoding the raw: %v", err) + } + if err == nil && test.wantErr { + t.Errorf("expected an error, but got none") + } + if err != nil && test.wantErr && !strings.Contains(err.Error(), test.errMsg) { + t.Errorf("expected %s error msg to contain %s", err.Error(), test.errMsg) + } + if err := test.verify(test.into, test.apiVersion, test.kind, test.objName); err != nil { + t.Errorf("failed to verify decoding the raw: %v", err) + } + }) + } +} + +func TestDecode(t *testing.T) { + tests := []struct { + name string + apiVersion string + kind string + objName string + req *Request + into Interface + prep func(re *Request, apiVersion, kind, name string) error + verify func(into Interface, apiVersion, kind, name string) error + wantErr bool + errMsg string + }{ + { + name: "Decode_ValidRequest_DecodeRequestIsSuccessful", + objName: "test-pod", + kind: "Pod", + apiVersion: "v1", + req: &Request{ + ResourceInterpreterRequest: configv1alpha1.ResourceInterpreterRequest{ + Object: runtime.RawExtension{}, + }, + }, + into: &unstructured.Unstructured{}, + prep: func(re *Request, apiVersion, kind, name string) error { + re.ResourceInterpreterRequest.Object.Raw = []byte(fmt.Sprintf(`{"apiVersion": "%s", "kind": "%s", "metadata": {"name": "%s"}}`, apiVersion, kind, name)) + return nil + }, + verify: verifyRuntimeObject, + wantErr: false, + }, + { + name: "Decode_EmptyRaw_NoContentToDecode", + req: &Request{ + ResourceInterpreterRequest: configv1alpha1.ResourceInterpreterRequest{ + Object: runtime.RawExtension{}, + }, + }, + into: &unstructured.Unstructured{}, + prep: func(*Request, string, string, string) error { return nil }, + verify: func(Interface, string, string, string) error { return nil }, + wantErr: true, + errMsg: "there is no content to decode", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.prep(test.req, test.apiVersion, test.kind, test.objName); err != nil { + t.Errorf("failed to prep the runtime raw extension object: %v", err) + } + scheme := runtime.NewScheme() + decoder := NewDecoder(scheme) + if decoder == nil { + t.Errorf("expected decoder to not be nil") + } + intoObj, ok := test.into.(runtime.Object) + if !ok { + t.Errorf("failed to type assert into object into runtime rawextension") + } + err := decoder.Decode(*test.req, intoObj) + if err != nil && !test.wantErr { + t.Errorf("unexpected error while decoding the raw: %v", err) + } + if err == nil && test.wantErr { + t.Errorf("expected an error, but got none") + } + if err != nil && test.wantErr && !strings.Contains(err.Error(), test.errMsg) { + t.Errorf("expected %s error msg to contain %s", err.Error(), test.errMsg) + } + if err := test.verify(test.into, test.apiVersion, test.kind, test.objName); err != nil { + t.Errorf("failed to verify decoding the raw: %v", err) + } + }) + } +} + +// verifyRuntimeObject checks if the runtime object (`into`) matches the given +// `apiVersion`, `kind`, and `name`. It returns an error if any field doesn't match. +func verifyRuntimeObject(into Interface, apiVersion, kind, name string) error { + if got := into.GetAPIVersion(); got != apiVersion { + return fmt.Errorf("expected API version '%s', got '%s'", apiVersion, got) + } + if got := into.GetKind(); got != kind { + return fmt.Errorf("expected kind '%s', got '%s'", kind, got) + } + if got := into.GetName(); got != name { + return fmt.Errorf("expected name '%s', got '%s'", name, got) + } + return nil +} diff --git a/pkg/webhook/interpreter/http_test.go b/pkg/webhook/interpreter/http_test.go new file mode 100644 index 000000000000..04443821b0da --- /dev/null +++ b/pkg/webhook/interpreter/http_test.go @@ -0,0 +1,346 @@ +/* +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 interpreter + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/util/json" + + configv1alpha1 "github.com/karmada-io/karmada/pkg/apis/config/v1alpha1" +) + +// HTTPMockHandler implements the Handler and DecoderInjector interfaces for testing. +type HTTPMockHandler struct { + response Response + decoder *Decoder +} + +// Handle implements the Handler interface for HTTPMockHandler. +func (m *HTTPMockHandler) Handle(_ context.Context, _ Request) Response { + return m.response +} + +// InjectDecoder implements the DecoderInjector interface by setting the decoder. +func (m *HTTPMockHandler) InjectDecoder(decoder *Decoder) { + m.decoder = decoder +} + +// mockBody simulates an error when reading the request body. +type mockBody struct{} + +func (m *mockBody) Read(_ []byte) (n int, err error) { + return 0, errors.New("mock read error") +} + +func (m *mockBody) Close() error { + return nil +} + +// limitedBadResponseWriter is a custom io.Writer implementation that simulates +// write errors for a specified number of attempts. After a certain number of failures, +// it allows the write operation to succeed. +type limitedBadResponseWriter struct { + failCount int + maxFailures int +} + +// Write simulates writing data to the writer. It forces an error response for +// a limited number of attempts, specified by maxFailures. Once failCount reaches +// maxFailures, it allows the write to succeed. +func (b *limitedBadResponseWriter) Write(p []byte) (n int, err error) { + if b.failCount < b.maxFailures { + b.failCount++ + return 0, errors.New("forced write error") + } + // After reaching maxFailures, allow the write to succeed to stop the infinite loop. + return len(p), nil +} + +func TestServeHTTP(t *testing.T) { + tests := []struct { + name string + req *http.Request + mockHandler *HTTPMockHandler + contentType string + res configv1alpha1.ResourceInterpreterContext + prep func(*http.Request, string) error + want *configv1alpha1.ResourceInterpreterResponse + }{ + { + name: "ServeHTTP_EmptyBody_RequestFailed", + req: httptest.NewRequest(http.MethodPost, "/", nil), + mockHandler: &HTTPMockHandler{}, + contentType: "application/json", + prep: func(req *http.Request, contentType string) error { + req.Header.Set("Content-Type", contentType) + req.Body = nil + return nil + }, + want: &configv1alpha1.ResourceInterpreterResponse{ + UID: "", + Successful: false, + Status: &configv1alpha1.RequestStatus{ + Message: "request body is empty", + Code: http.StatusBadRequest, + }, + }, + }, + { + name: "ServeHTTP_InvalidContentType_ContentTypeIsInvalid", + req: httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer([]byte(`{}`))), + mockHandler: &HTTPMockHandler{}, + contentType: "text/plain", + prep: func(req *http.Request, contentType string) error { + req.Header.Set("Content-Type", contentType) + return nil + }, + want: &configv1alpha1.ResourceInterpreterResponse{ + UID: "", + Successful: false, + Status: &configv1alpha1.RequestStatus{ + Message: "contentType=text/plain, expected application/json", + Code: http.StatusBadRequest, + }, + }, + }, + { + name: "ServeHTTP_InvalidBodyJSON_JSONBodyIsInvalid", + req: httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer([]byte(`invalid-json`))), + mockHandler: &HTTPMockHandler{}, + contentType: "application/json", + prep: func(req *http.Request, contentType string) error { + req.Header.Set("Content-Type", contentType) + return nil + }, + want: &configv1alpha1.ResourceInterpreterResponse{ + UID: "", + Successful: false, + Status: &configv1alpha1.RequestStatus{ + Message: "json parse error", + Code: http.StatusBadRequest, + }, + }, + }, + { + name: "ServeHTTP_ReadBodyError_FailedToReadBody", + req: httptest.NewRequest(http.MethodPost, "/", &mockBody{}), + mockHandler: &HTTPMockHandler{}, + contentType: "application/json", + prep: func(req *http.Request, contentType string) error { + req.Header.Set("Content-Type", contentType) + return nil + }, + want: &configv1alpha1.ResourceInterpreterResponse{ + UID: "", + Successful: false, + Status: &configv1alpha1.RequestStatus{ + Message: "mock read error", + Code: http.StatusBadRequest, + }, + }, + }, + { + name: "ServeHTTP_ValidRequest_RequestIsValid", + req: httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer([]byte(`{}`))), + mockHandler: &HTTPMockHandler{ + response: Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + Successful: true, + Status: &configv1alpha1.RequestStatus{Code: http.StatusOK}, + }, + }, + }, + contentType: "application/json", + prep: func(req *http.Request, contentType string) error { + req.Header.Set("Content-Type", contentType) + requestBody := configv1alpha1.ResourceInterpreterContext{ + Request: &configv1alpha1.ResourceInterpreterRequest{ + UID: "test-uid", + }, + } + body, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("failed to marshal request body: %v", err) + } + req.Body = io.NopCloser(bytes.NewBuffer(body)) + return nil + }, + want: &configv1alpha1.ResourceInterpreterResponse{ + UID: "test-uid", + Successful: true, + Status: &configv1alpha1.RequestStatus{ + Message: "", + Code: http.StatusOK, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + if err := test.prep(test.req, test.contentType); err != nil { + t.Errorf("failed to prep serving http: %v", err) + } + webhook := NewWebhook(test.mockHandler, &Decoder{}) + webhook.ServeHTTP(recorder, test.req) + if err := verifyResourceInterpreterResponse(recorder.Body.Bytes(), test.want); err != nil { + t.Errorf("failed to verify resource interpreter response: %v", err) + } + }) + } +} + +func TestWriteResponse(t *testing.T) { + tests := []struct { + name string + res Response + rec *httptest.ResponseRecorder + mockHandler *HTTPMockHandler + decoder *Decoder + verify func([]byte, *configv1alpha1.ResourceInterpreterResponse) error + want *configv1alpha1.ResourceInterpreterResponse + }{ + { + name: "WriteResponse_ValidValues_IsSucceeded", + res: Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + UID: "test-uid", + Successful: true, + Status: &configv1alpha1.RequestStatus{Code: http.StatusOK}, + }, + }, + rec: httptest.NewRecorder(), + mockHandler: &HTTPMockHandler{}, + decoder: &Decoder{}, + verify: verifyResourceInterpreterResponse, + want: &configv1alpha1.ResourceInterpreterResponse{ + UID: "test-uid", + Successful: true, + Status: &configv1alpha1.RequestStatus{ + Message: "", + Code: http.StatusOK, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + webhook := NewWebhook(test.mockHandler, test.decoder) + webhook.writeResponse(test.rec, test.res) + if err := test.verify(test.rec.Body.Bytes(), test.want); err != nil { + t.Errorf("failed to verify resource interpreter response: %v", err) + } + }) + } +} + +func TestWriteResourceInterpreterResponse(t *testing.T) { + tests := []struct { + name string + mockHandler *HTTPMockHandler + rec io.Writer + res configv1alpha1.ResourceInterpreterContext + verify func(io.Writer, *configv1alpha1.ResourceInterpreterResponse) error + want *configv1alpha1.ResourceInterpreterResponse + }{ + { + name: "WriteResourceInterpreterResponse_ValidValues_WriteIsSuccessful", + mockHandler: &HTTPMockHandler{}, + rec: httptest.NewRecorder(), + res: configv1alpha1.ResourceInterpreterContext{ + Response: &configv1alpha1.ResourceInterpreterResponse{ + UID: "test-uid", + Successful: true, + Status: &configv1alpha1.RequestStatus{Code: http.StatusOK}, + }, + }, + verify: func(writer io.Writer, rir *configv1alpha1.ResourceInterpreterResponse) error { + data, ok := writer.(*httptest.ResponseRecorder) + if !ok { + return fmt.Errorf("expected writer of type httptest.ResponseRecorder but got %T", writer) + } + return verifyResourceInterpreterResponse(data.Body.Bytes(), rir) + }, + want: &configv1alpha1.ResourceInterpreterResponse{ + UID: "test-uid", + Successful: true, + Status: &configv1alpha1.RequestStatus{ + Message: "", + Code: http.StatusOK, + }, + }, + }, + { + name: "WriteResourceInterpreterResponse_FailedToWrite_WriterReachedMaxFailures", + mockHandler: &HTTPMockHandler{}, + res: configv1alpha1.ResourceInterpreterContext{ + Response: &configv1alpha1.ResourceInterpreterResponse{}, + }, + rec: &limitedBadResponseWriter{maxFailures: 3}, + verify: func(writer io.Writer, _ *configv1alpha1.ResourceInterpreterResponse) error { + data, ok := writer.(*limitedBadResponseWriter) + if !ok { + return fmt.Errorf("expected writer of type limitedBadResponseWriter but got %T", writer) + } + if data.failCount != data.maxFailures { + return fmt.Errorf("expected %d write failures, got %d", data.maxFailures, data.failCount) + } + return nil + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + webhook := NewWebhook(test.mockHandler, &Decoder{}) + webhook.writeResourceInterpreterResponse(test.rec, test.res) + if err := test.verify(test.rec, test.want); err != nil { + t.Errorf("failed to verify resource interpreter response: %v", err) + } + }) + } +} + +// verifyResourceInterpreterResponse unmarshals the provided body into a +// ResourceInterpreterContext and verifies it matches the expected values in res2. +func verifyResourceInterpreterResponse(body []byte, res2 *configv1alpha1.ResourceInterpreterResponse) error { + var resContext configv1alpha1.ResourceInterpreterContext + if err := json.Unmarshal(body, &resContext); err != nil { + return fmt.Errorf("failed to unmarshal body: %v", err) + } + if resContext.Response.UID != res2.UID { + return fmt.Errorf("expected UID %s, but got %s", res2.UID, resContext.Response.UID) + } + if resContext.Response.Successful != res2.Successful { + return fmt.Errorf("expected success status %t, but got %t", res2.Successful, resContext.Response.Successful) + } + if !strings.Contains(resContext.Response.Status.Message, res2.Status.Message) { + return fmt.Errorf("expected message %s to be subset, but got %s", res2.Status.Message, resContext.Response.Status.Message) + } + if resContext.Response.Status.Code != res2.Status.Code { + return fmt.Errorf("expected status code %d, but got %d", res2.Status.Code, resContext.Response.Status.Code) + } + return nil +} diff --git a/pkg/webhook/interpreter/inject_test.go b/pkg/webhook/interpreter/inject_test.go new file mode 100644 index 000000000000..c61d30cf00db --- /dev/null +++ b/pkg/webhook/interpreter/inject_test.go @@ -0,0 +1,69 @@ +/* +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 interpreter + +import ( + "testing" +) + +// MockDecoderInjector is a mock struct implementing the DecoderInjector interface for testing purposes. +type MockDecoderInjector struct { + decoder *Decoder +} + +// InjectDecoder implements the DecoderInjector interface by setting the decoder. +func (m *MockDecoderInjector) InjectDecoder(decoder *Decoder) { + m.decoder = decoder +} + +func TestInjectDecoder(t *testing.T) { + tests := []struct { + name string + mockInjector interface{} + decoder *Decoder + wantToBeInjected bool + }{ + { + name: "InjectDecoder_ObjectImplementsDecoderInjector_Injected", + mockInjector: &MockDecoderInjector{}, + decoder: &Decoder{}, + wantToBeInjected: true, + }, + { + name: "InjectDecoder_ObjectNotImplementDecoderInjector_NotInjected", + mockInjector: struct{}{}, + decoder: &Decoder{}, + wantToBeInjected: false, + }, + { + name: "InjectDecoder_ObjectImplementsDecoderInjector_Injected", + mockInjector: &MockDecoderInjector{}, + wantToBeInjected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := InjectDecoderInto(test.decoder, test.mockInjector); got != test.wantToBeInjected { + t.Errorf("expected status injection to be %t, but got %t", test.wantToBeInjected, got) + } + if test.wantToBeInjected && test.mockInjector.(*MockDecoderInjector).decoder != test.decoder { + t.Errorf("failed to inject the correct decoder, expected %v but got %v", test.decoder, test.mockInjector.(*MockDecoderInjector).decoder) + } + }) + } +} diff --git a/pkg/webhook/interpreter/response_test.go b/pkg/webhook/interpreter/response_test.go new file mode 100644 index 000000000000..8a5aeebf979f --- /dev/null +++ b/pkg/webhook/interpreter/response_test.go @@ -0,0 +1,179 @@ +/* +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 interpreter + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" + + "gomodules.xyz/jsonpatch/v2" + + configv1alpha1 "github.com/karmada-io/karmada/pkg/apis/config/v1alpha1" +) + +// customError is a helper struct for simulating errors. +type customError struct { + msg string +} + +func (e *customError) Error() string { + return e.msg +} + +func TestErrored(t *testing.T) { + err := &customError{"Test Error"} + code := int32(500) + response := Errored(code, err) + + expectedResponse := Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + Successful: false, + Status: &configv1alpha1.RequestStatus{ + Code: code, + Message: err.Error(), + }, + }, + } + + if !reflect.DeepEqual(expectedResponse, response) { + t.Errorf("response mismatch: expected %v, got %v", expectedResponse, response) + } +} + +func TestSucceeded(t *testing.T) { + message := "Operation succeeded" + response := Succeeded(message) + + expectedResponse := ValidationResponse(true, message) + + if !reflect.DeepEqual(expectedResponse, response) { + t.Errorf("response mismatch: expected %v, got %v", expectedResponse, response) + } +} + +func TestValidationResponse(t *testing.T) { + tests := []struct { + name string + msg string + isSuccessful bool + want Response + }{ + { + name: "ValidationResponse_IsSuccessful_Succeeded", + msg: "Success", + isSuccessful: true, + want: Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + Successful: true, + Status: &configv1alpha1.RequestStatus{ + Code: int32(http.StatusOK), + Message: "Success", + }, + }, + }, + }, + { + name: "ValidationResponse_IsFailed_Failed", + msg: "Failed", + isSuccessful: false, + want: Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + Successful: false, + Status: &configv1alpha1.RequestStatus{ + Code: int32(http.StatusForbidden), + Message: "Failed", + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := ValidationResponse(test.isSuccessful, test.msg) + if !reflect.DeepEqual(response, test.want) { + t.Errorf("expected response %v but got response %v", test.want, response) + } + }) + } +} + +func TestPatchResponseFromRaw(t *testing.T) { + tests := []struct { + name string + original, current []byte + expectedPatch []jsonpatch.Operation + res Response + want Response + prep func(wantRes *Response) error + }{ + { + name: "PatchResponseFromRaw_ReplacePatch_ReplacePatchExpected", + original: []byte(fmt.Sprintf(`{"name": "%s"}`, "original")), + current: []byte(fmt.Sprintf(`{"name": "%s"}`, "current")), + prep: func(wantRes *Response) error { + expectedPatch := []jsonpatch.Operation{ + { + Operation: "replace", + Path: "/name", + Value: "current", + }, + } + expectedPatchJSON, err := json.Marshal(expectedPatch) + if err != nil { + return fmt.Errorf("marshal failure: %v", err) + } + wantRes.ResourceInterpreterResponse.Patch = expectedPatchJSON + + return nil + }, + want: Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + Successful: true, + PatchType: func() *configv1alpha1.PatchType { pt := configv1alpha1.PatchTypeJSONPatch; return &pt }(), + }, + }, + }, + { + name: "PatchResponseFromRaw_OriginalSameAsCurrentValue_NoPatchExpected", + original: []byte(fmt.Sprintf(`{"name": "%s"}`, "same")), + current: []byte(fmt.Sprintf(`{"name": "%s"}`, "same")), + prep: func(*Response) error { return nil }, + want: Succeeded(""), + }, + { + name: "PatchResponseFromRaw_InvalidJSONDocument_JSONDocumentIsInvalid", + original: []byte(nil), + current: []byte("updated"), + prep: func(*Response) error { return nil }, + want: Errored(http.StatusInternalServerError, &customError{"invalid JSON Document"}), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.prep(&test.want); err != nil { + t.Errorf("failed to prep for patching response from raw: %v", err) + } + patchResponse := PatchResponseFromRaw(test.original, test.current) + if !reflect.DeepEqual(patchResponse, test.want) { + t.Errorf("unexpected error, patch responses not matched; expected %v, but got %v", patchResponse, test.want) + } + }) + } +} diff --git a/pkg/webhook/interpreter/webhook_test.go b/pkg/webhook/interpreter/webhook_test.go new file mode 100644 index 000000000000..5b5e7e6712f1 --- /dev/null +++ b/pkg/webhook/interpreter/webhook_test.go @@ -0,0 +1,158 @@ +/* +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 interpreter + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/types" + + configv1alpha1 "github.com/karmada-io/karmada/pkg/apis/config/v1alpha1" +) + +// WebhookMockHandler implements the Handler and DecoderInjector interfaces for testing. +type WebhookMockHandler struct { + response Response + decoder *Decoder +} + +// Handle implements the Handler interface for WebhookMockHandler. +func (m *WebhookMockHandler) Handle(_ context.Context, _ Request) Response { + return m.response +} + +// InjectDecoder implements the DecoderInjector interface by setting the decoder. +func (m *WebhookMockHandler) InjectDecoder(decoder *Decoder) { + m.decoder = decoder +} + +func TestNewWebhook(t *testing.T) { + mockHandler := &WebhookMockHandler{} + decoder := &Decoder{} + + webhook := NewWebhook(mockHandler, decoder) + if webhook == nil { + t.Fatalf("webhook returned by NewWebhook() is nil") + } + if webhook.handler != mockHandler { + t.Errorf("webhook has incorrect handler: expected %v, got %v", mockHandler, webhook.handler) + } +} + +func TestHandle(t *testing.T) { + var uid types.UID = "test-uid" + + expectedResponse := Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + Successful: true, + Status: &configv1alpha1.RequestStatus{ + Code: http.StatusOK, + }, + UID: uid, + }, + } + + mockHandler := &WebhookMockHandler{response: expectedResponse} + webhook := NewWebhook(mockHandler, &Decoder{}) + req := Request{ + ResourceInterpreterRequest: configv1alpha1.ResourceInterpreterRequest{ + UID: uid, + }, + } + + resp := webhook.Handle(context.TODO(), req) + if !reflect.DeepEqual(resp, expectedResponse) { + t.Errorf("response mismatch in Handle(): expected %v, got %v", expectedResponse, resp) + } + if resp.UID != req.UID { + t.Errorf("uid was not set as expected: expected %v, got %v", req.UID, resp.UID) + } +} + +func TestComplete(t *testing.T) { + tests := []struct { + name string + req Request + res Response + verify func(*Response, *Request) error + }{ + { + name: "TestComplete_StatusAndStatusCodeAreUnset_FieldsArePopulated", + req: Request{ + ResourceInterpreterRequest: configv1alpha1.ResourceInterpreterRequest{ + UID: "test-uid", + }, + }, + res: Response{}, + verify: verifyResourceInterpreterCompleteResponse, + }, + { + name: "TestComplete_OverrideResponseUIDAndStatusCode_ResponseUIDAndStatusCodeAreOverrided", + req: Request{ + ResourceInterpreterRequest: configv1alpha1.ResourceInterpreterRequest{ + UID: "test-uid", + }, + }, + res: Response{ + ResourceInterpreterResponse: configv1alpha1.ResourceInterpreterResponse{ + UID: "existing-uid", + Status: &configv1alpha1.RequestStatus{ + Code: http.StatusForbidden, + }, + }, + }, + verify: func(resp *Response, req *Request) error { + if resp.UID != req.UID { + return fmt.Errorf("uid should be overridden if it's already set in the request: expected %v, got %v", req.UID, resp.UID) + } + if resp.Status.Code != http.StatusForbidden { + return fmt.Errorf("status code should not be overridden if it's already set: expected %v, got %v", http.StatusForbidden, resp.Status.Code) + } + return nil + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.res.Complete(test.req) + if err := test.verify(&test.res, &test.req); err != nil { + t.Errorf("failed to verify complete resource interpreter response: %v", err) + } + }) + } +} + +// verifyResourceInterpreterCompleteResponse checks if the response from +// the resource interpreter's Complete method is valid. +// It ensures the response UID matches the request UID, the Status is initialized, +// and the Status code is set to http.StatusOK. Returns an error if any check fails. +func verifyResourceInterpreterCompleteResponse(res *Response, req *Request) error { + if res.UID != req.UID { + return fmt.Errorf("uid was not set as expected: expected %v, got %v", req.UID, res.UID) + } + if res.Status == nil { + return fmt.Errorf("status should be initialized if it's nil") + } + if res.Status.Code != http.StatusOK { + return fmt.Errorf("status code should be set to %v if it was 0, got %v", http.StatusOK, res.Status.Code) + } + return nil +}