diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 368ea61..f9b5b27 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -36,6 +36,8 @@ jobs: run: go get github.com/ory/go-acc - name: Run tests run: go-acc ./pkg/... + - name: Run example tests + run: go-acc ./examples/... - name: Codecov uses: codecov/codecov-action@v1.2.1 with: diff --git a/examples/invalid-pod.yaml b/examples/invalid-pod.yaml new file mode 100644 index 0000000..85979d8 --- /dev/null +++ b/examples/invalid-pod.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Pod +metadata: + name: cat +spec: + containers: + - name: cat \ No newline at end of file diff --git a/examples/mutator_test.go b/examples/mutator_test.go index 330a0e7..2fe076a 100644 --- a/examples/mutator_test.go +++ b/examples/mutator_test.go @@ -3,25 +3,41 @@ package examples_test import ( "github.com/dbsystel/kewl/examples" "github.com/dbsystel/kewl/pkg/mutation" + "github.com/dbsystel/kewl/pkg/webhook/integtest" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("PodMutator", func() { sut := examples.PodMutator - It("should change a pod with name cat", func() { - Expect(mutation.Mutate(NewPod("cat"), nil, sut)). - To(BeEquivalentTo(NewPod("dog"))) + Context("unit test", func() { + It("should change a pod with name cat", func() { + Expect(mutation.Mutate(NewPod("cat"), nil, sut)). + To(BeEquivalentTo(NewPod("dog"))) + }) + It("should not change a pod with name dog", func() { + Expect(mutation.Mutate(NewPod("dog"), nil, sut)).To(BeNil()) + }) + It("should change a pod label value cat", func() { + Expect(mutation.Mutate(NewPod("rabbit", "dog", "cat"), nil, sut)). + To(BeEquivalentTo(NewPod("rabbit", "dog", "dog"))) + }) + It("should not change a pod labels not cat", func() { + Expect(mutation.Mutate(NewPod("rabbit", "dog", "dog"), nil, sut)). + To(BeNil()) + }) }) - It("should not change a pod with name dog", func() { - Expect(mutation.Mutate(NewPod("dog"), nil, sut)).To(BeNil()) - }) - It("should change a pod label value cat", func() { - Expect(mutation.Mutate(NewPod("rabbit", "dog", "cat"), nil, sut)). - To(BeEquivalentTo(NewPod("rabbit", "dog", "dog"))) - }) - It("should not change a pod labels not cat", func() { - Expect(mutation.Mutate(NewPod("rabbit", "dog", "dog"), nil, sut)). - To(BeNil()) + Context("integration test", func() { + it, err := integtest.NewMutation("test-namespace", sut) + Expect(err).NotTo(HaveOccurred()) + It("should mutate the pod", func() { + response, err := it.InvokeFromFile("invalid-pod.yaml", "") + Expect(response, err).NotTo(BeNil()) + Expect(response.PatchMaps()).To(ConsistOf(map[string]string{ + "op": "replace", + "path": "/metadata/name", + "value": "dog", + })) + }) }) }) diff --git a/examples/validator_test.go b/examples/validator_test.go index f75fd17..e3dfb45 100644 --- a/examples/validator_test.go +++ b/examples/validator_test.go @@ -3,25 +3,37 @@ package examples_test import ( "github.com/dbsystel/kewl/examples" "github.com/dbsystel/kewl/pkg/validation" + "github.com/dbsystel/kewl/pkg/webhook/integtest" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("PodValidator", func() { sut := examples.PodValidator - It("should add an error for name cat", func() { - Expect(validation.Validate(NewPod("cat"), nil, sut)). - To(HaveLen(1)) + Context("unit test", func() { + It("should add an error for name cat", func() { + Expect(validation.Validate(NewPod("cat"), nil, sut)). + To(HaveLen(1)) + }) + It("should not find errors for name dog", func() { + Expect(validation.Validate(NewPod("dog"), nil, sut)).To(BeEmpty()) + }) + It("should add an error for label value cat", func() { + Expect(validation.Validate(NewPod("rabbit", "dog", "cat"), nil, sut)). + To(HaveLen(1)) + }) + It("should add an error for label value dog", func() { + Expect(validation.Validate(NewPod("rabbit", "dog", "dog"), nil, sut)). + To(BeEmpty()) + }) }) - It("should not find errors for name dog", func() { - Expect(validation.Validate(NewPod("dog"), nil, sut)).To(BeEmpty()) - }) - It("should add an error for label value cat", func() { - Expect(validation.Validate(NewPod("rabbit", "dog", "cat"), nil, sut)). - To(HaveLen(1)) - }) - It("should add an error for label value dog", func() { - Expect(validation.Validate(NewPod("rabbit", "dog", "dog"), nil, sut)). - To(BeEmpty()) + Context("integration test", func() { + it, err := integtest.NewValidation("test-namespace", sut) + Expect(err).NotTo(HaveOccurred()) + It("should mutate the pod", func() { + response, err := it.InvokeFromFile("invalid-pod.yaml", "") + Expect(response, err).NotTo(BeNil()) + Expect(response.Allowed).To(BeFalse()) + }) }) }) diff --git a/pkg/webhook/integtest/admission_response.go b/pkg/webhook/integtest/admission_response.go new file mode 100644 index 0000000..a35a6ed --- /dev/null +++ b/pkg/webhook/integtest/admission_response.go @@ -0,0 +1,21 @@ +package integtest + +import ( + "encoding/json" + + admissionv1 "k8s.io/api/admission/v1" +) + +// AdmissionResponse is a decorator for admissionv1.AdmissionResponse +type AdmissionResponse struct { + admissionv1.AdmissionResponse +} + +// PatchMaps returns the patches as maps +func (a *AdmissionResponse) PatchMaps() (result []map[string]string, err error) { + if a.PatchType == nil || len(a.Patch) == 0 { + return nil, nil + } + err = json.Unmarshal(a.Patch, &result) + return result, err +} diff --git a/pkg/webhook/integtest/api.go b/pkg/webhook/integtest/api.go new file mode 100644 index 0000000..c38f084 --- /dev/null +++ b/pkg/webhook/integtest/api.go @@ -0,0 +1,48 @@ +package integtest + +import ( + "reflect" + + "github.com/dbsystel/kewl/pkg/mutation" + "github.com/dbsystel/kewl/pkg/validation" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" +) + +// Interface is the interface for an integration test fixture +type Interface interface { + Invoke(obj, oldObj runtime.Object) (*AdmissionResponse, error) + InvokeFromFile(objPath, oldObjPath string) (*AdmissionResponse, error) +} + +// NewMutation creates a new Interface for mutation testing +func NewMutation(defaultNamespace string, mutators ...mutation.Mutator) (Interface, error) { + result, err := newIntegrationWithPath(defaultNamespace, "/mutate") + if err != nil { + return nil, err + } + for _, mutator := range mutators { + if err = result.sut.AddMutator(mutator); err != nil { + return nil, errors.Wrapf(err, "could not add mutator: %v", reflect.TypeOf(mutator)) + } + // Note: error is handled in server already + _ = mutator.AddToScheme(result.scheme) + } + return result, nil +} + +// NewValidation creates a new Interface for validation testing +func NewValidation(defaultNamespace string, validators ...validation.Validator) (Interface, error) { + result, err := newIntegrationWithPath(defaultNamespace, "/validate") + if err != nil { + return nil, err + } + for _, validator := range validators { + if err = result.sut.AddValidator(validator); err != nil { + return nil, errors.Wrapf(err, "could not add validator: %v", reflect.TypeOf(validators)) + } + // Note: error is handled in server already + _ = validator.AddToScheme(result.scheme) + } + return result, err +} diff --git a/pkg/webhook/integtest/fixture.go b/pkg/webhook/integtest/fixture.go new file mode 100644 index 0000000..fbee9bf --- /dev/null +++ b/pkg/webhook/integtest/fixture.go @@ -0,0 +1,165 @@ +package integtest + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "reflect" + + "github.com/dbsystel/kewl/pkg/httpext" + "github.com/dbsystel/kewl/pkg/webhook" + "github.com/go-logr/logr" + "github.com/pkg/errors" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/util/uuid" +) + +var _ Interface = &fixture{} + +type fixture struct { + sut *webhook.Server + scheme *runtime.Scheme + path string + defaultNamespace string +} + +func newIntegrationWithPath(defaultNamespace, path string) (*fixture, error) { + cfg := &httpext.Config{} + server, err := webhook.NewServer(logr.DiscardLogger{}, cfg) + if err != nil { + return nil, errors.Wrap(err, "could not create servers") + } + return &fixture{sut: server, path: path, scheme: runtime.NewScheme(), defaultNamespace: defaultNamespace}, nil +} + +func (i *fixture) Invoke(obj, oldObj runtime.Object) (*AdmissionResponse, error) { + if obj == nil && oldObj == nil { + return nil, errors.New("at least one object must be provided") + } + operation := i.operation(obj, oldObj) + var ( + err error + marshalledObj []byte + marshalledOldObj []byte + ) + if obj != nil { + if marshalledObj, err = json.Marshal(obj); err != nil { + return nil, errors.Wrapf(err, "could not marshal obj of type: %v", reflect.TypeOf(obj)) + } + } + if oldObj != nil { + if marshalledOldObj, err = json.Marshal(oldObj); err != nil { + return nil, errors.Wrapf(err, "could not marshal obj of type: %v", reflect.TypeOf(oldObj)) + } + } + review := &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{Kind: "AdmissionReview", APIVersion: admissionv1.SchemeGroupVersion.Identifier()}, + Request: &admissionv1.AdmissionRequest{ + UID: uuid.NewUUID(), + Kind: i.kind(obj, oldObj), + Name: "test-review", + Namespace: i.namespace(obj, oldObj), + Operation: operation, + Object: runtime.RawExtension{Raw: marshalledObj}, + OldObject: runtime.RawExtension{Raw: marshalledOldObj}, + }, + } + recorder := httptest.NewRecorder() + body, err := json.Marshal(review) + if err != nil { + return nil, errors.Wrap(err, "could not marshal the review") + } + request := httptest.NewRequest("POST", i.path, bytes.NewBuffer(body)) + i.sut.Server.Server.Handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + return nil, errors.Errorf("review invocation response code invalid: %v", recorder.Code) + } + body, err = ioutil.ReadAll(recorder.Body) + if err != nil { + return nil, errors.Wrap(err, "could not read response body") + } + review = &admissionv1.AdmissionReview{} + if err = json.Unmarshal(body, &review); err != nil { + return nil, errors.Wrap(err, "could not unmarshal response body") + } + if review.Request != nil { + return nil, errors.Wrap(err, "review should not have the request set") + } + var result *AdmissionResponse + if review.Response != nil { + result = &AdmissionResponse{*review.Response} + } + return result, nil +} + +func (i *fixture) InvokeFromFile(objPath, oldObjPath string) (*AdmissionResponse, error) { + obj, err := i.load(objPath) + if err != nil { + return nil, errors.Wrapf(err, "could not load object from path: %v", objPath) + } + oldObj, err := i.load(oldObjPath) + if err != nil { + return nil, errors.Wrapf(err, "could not load old object from path: %v", oldObjPath) + } + return i.Invoke(obj, oldObj) +} + +func (i *fixture) load(path string) (runtime.Object, error) { + if len(path) == 0 { + return nil, nil + } + contents, err := ioutil.ReadFile(path) + if err != nil { + return nil, errors.Wrapf(err, "could not read from path: %v", path) + } + result, _, err := serializer.NewCodecFactory(i.scheme).UniversalDeserializer().Decode(contents, nil, nil) + return result, errors.Wrapf(err, "could not decode object from path: %v", path) +} + +func (i *fixture) operation(obj, oldObj runtime.Object) admissionv1.Operation { + if obj == nil { + if oldObj == nil { + return "" + } + return admissionv1.Delete + } + if oldObj != nil { + return admissionv1.Update + } + return admissionv1.Create +} + +func (i *fixture) kind(objs ...runtime.Object) (result metav1.GroupVersionKind) { + for _, candidate := range objs { + if candidate != nil { + result.Group, result.Version, result.Kind = i.gvk(candidate) + break + } + } + return result +} + +func (i *fixture) gvk(obj runtime.Object) (string, string, string) { + gvk := obj.GetObjectKind().GroupVersionKind() + return gvk.Group, gvk.Version, gvk.Kind +} + +func (i *fixture) namespace(objs ...runtime.Object) string { + for _, obj := range objs { + metaAcc, ok := obj.(metav1.ObjectMetaAccessor) + if !ok { + continue + } + objMeta := metaAcc.GetObjectMeta() + if objMeta == nil { + continue + } + return objMeta.GetNamespace() + } + return i.defaultNamespace +}