karmada/pkg/webhook/interpreter/http_test.go

347 lines
11 KiB
Go

/*
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
}