From 1bcd58a7cd546066b9be5b73f7d2235777e38880 Mon Sep 17 00:00:00 2001 From: Philip Laine Date: Wed, 15 Apr 2026 10:25:45 +0200 Subject: [PATCH] Implement mock for Netbird API and client (#184) This makes testing of the operator a lot simpler by enabling a quick way to implement the crud endpoints for all the resources used. Signed-off-by: Philip Laine --- internal/controller/group_controller_test.go | 70 +-------- .../controller/setupkey_controller_test.go | 81 +--------- internal/netbirdmock/netbirdmock.go | 141 ++++++++++++++++++ 3 files changed, 151 insertions(+), 141 deletions(-) create mode 100644 internal/netbirdmock/netbirdmock.go diff --git a/internal/controller/group_controller_test.go b/internal/controller/group_controller_test.go index ee5ff75..f554729 100644 --- a/internal/controller/group_controller_test.go +++ b/internal/controller/group_controller_test.go @@ -2,16 +2,7 @@ package controller import ( "context" - "encoding/json" - "fmt" - "io" - "math/rand" - "net/http" - "net/http/httptest" - netbird "github.com/netbirdio/netbird/shared/management/client/rest" - "github.com/netbirdio/netbird/shared/management/http/api" - "github.com/netbirdio/netbird/shared/management/http/util" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" kerrors "k8s.io/apimachinery/pkg/api/errors" @@ -20,65 +11,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" nbv1alpha1 "github.com/netbirdio/kubernetes-operator/api/v1alpha1" + "github.com/netbirdio/kubernetes-operator/internal/netbirdmock" ) var _ = Describe("Group Controller", func() { Context("When reconciling a resource", func() { ctx := context.Background() - r := rand.New(rand.NewSource(GinkgoRandomSeed())) - groupStore := map[string]*api.Group{} - mux := &http.ServeMux{} - mux.Handle("POST /api/groups", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - b, err := io.ReadAll(req.Body) - Expect(err).NotTo(HaveOccurred()) - groupReq := api.GroupRequest{} - err = json.Unmarshal(b, &groupReq) - Expect(err).NotTo(HaveOccurred()) - - groupResp := &api.Group{ - Id: fmt.Sprintf("id-%d", r.Int63()), - Name: groupReq.Name, - } - groupStore[groupResp.Id] = groupResp - b, err = json.Marshal(groupResp) - Expect(err).NotTo(HaveOccurred()) - _, err = rw.Write(b) - Expect(err).NotTo(HaveOccurred()) - })) - mux.Handle("PUT /api/groups/{id}", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - id := req.PathValue("id") - groupResp, ok := groupStore[id] - if !ok { - util.WriteErrorResponse("Not Found", http.StatusNotFound, rw) - return - } - - b, err := io.ReadAll(req.Body) - Expect(err).NotTo(HaveOccurred()) - groupReq := api.GroupRequest{} - err = json.Unmarshal(b, &groupReq) - Expect(err).NotTo(HaveOccurred()) - - groupResp.Name = groupReq.Name - - b, err = json.Marshal(groupResp) - Expect(err).NotTo(HaveOccurred()) - _, err = rw.Write(b) - Expect(err).NotTo(HaveOccurred()) - })) - mux.Handle("DELETE /api/groups/{id}", http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - id := req.PathValue("id") - _, ok := groupStore[id] - if !ok { - util.WriteErrorResponse("Not Found", http.StatusNotFound, rw) - return - } - delete(groupStore, id) - })) - server := httptest.NewServer(mux) - nbClient := netbird.New(server.URL, "ABC") - var controllerReconciler *GroupReconciler nn := client.ObjectKey{ Name: "test-resource", @@ -88,13 +27,11 @@ var _ = Describe("Group Controller", func() { BeforeEach(func() { controllerReconciler = &GroupReconciler{ Client: k8sClient, - Netbird: nbClient, + Netbird: netbirdmock.Client(), } }) AfterEach(func() { - groupStore = map[string]*api.Group{} - group := &nbv1alpha1.Group{} err := k8sClient.Get(ctx, nn, group) if kerrors.IsNotFound(err) { @@ -126,7 +63,8 @@ var _ = Describe("Group Controller", func() { Expect(*group.Status.GroupID).NotTo(BeEmpty()) By("crerating a new group when deleted from API") - delete(groupStore, *group.Status.GroupID) + err = controllerReconciler.Netbird.Groups.Delete(ctx, *group.Status.GroupID) + Expect(err).NotTo(HaveOccurred()) _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) Expect(err).NotTo(HaveOccurred()) newGroup := &nbv1alpha1.Group{} diff --git a/internal/controller/setupkey_controller_test.go b/internal/controller/setupkey_controller_test.go index 3bd4edc..811d7af 100644 --- a/internal/controller/setupkey_controller_test.go +++ b/internal/controller/setupkey_controller_test.go @@ -2,15 +2,7 @@ package controller import ( "context" - "encoding/json" - "fmt" - "io" - "math/rand" - "net/http" - "net/http/httptest" - netbird "github.com/netbirdio/netbird/shared/management/client/rest" - "github.com/netbirdio/netbird/shared/management/http/api" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -20,75 +12,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" nbv1alpha1 "github.com/netbirdio/kubernetes-operator/api/v1alpha1" + "github.com/netbirdio/kubernetes-operator/internal/netbirdmock" ) var _ = Describe("SetupKey Controller", func() { Context("When reconciling a resource", func() { ctx := context.Background() - r := rand.New(rand.NewSource(GinkgoRandomSeed())) - setupKeyStore := map[string]*api.SetupKey{} - mux := &http.ServeMux{} - mux.HandleFunc("/api/setup-keys", func(rw http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodPost: - resp := api.SetupKeyClear{ - Id: fmt.Sprintf("id-%d", r.Int63()), - Key: fmt.Sprintf("%d", r.Int63()), - State: "valid", - } - b, err := json.Marshal(resp) - Expect(err).NotTo(HaveOccurred()) - _, err = rw.Write(b) - Expect(err).NotTo(HaveOccurred()) - - setupKey := api.SetupKey{ - Id: resp.Id, - Key: resp.Key, - State: resp.State, - } - setupKeyStore[resp.Id] = &setupKey - default: - rw.WriteHeader(http.StatusNotFound) - } - }) - mux.HandleFunc("/api/setup-keys/{id}", func(rw http.ResponseWriter, req *http.Request) { - id := req.PathValue("id") - setupKey, ok := setupKeyStore[id] - if !ok { - rw.WriteHeader(http.StatusNotFound) - return - } - - switch req.Method { - case http.MethodDelete: - delete(setupKeyStore, id) - rw.WriteHeader(http.StatusOK) - case http.MethodGet: - b, err := json.Marshal(setupKey) - Expect(err).NotTo(HaveOccurred()) - _, err = rw.Write(b) - Expect(err).NotTo(HaveOccurred()) - case http.MethodPut: - b, err := io.ReadAll(req.Body) - Expect(err).NotTo(HaveOccurred()) - putReq := api.SetupKeyRequest{} - err = json.Unmarshal(b, &putReq) - Expect(err).NotTo(HaveOccurred()) - - setupKey.AutoGroups = putReq.AutoGroups - - b, err = json.Marshal(setupKey) - Expect(err).NotTo(HaveOccurred()) - _, err = rw.Write(b) - Expect(err).NotTo(HaveOccurred()) - default: - rw.WriteHeader(http.StatusNotFound) - } - }) - server := httptest.NewServer(mux) - nbClient := netbird.New(server.URL, "ABC") - var controllerReconciler *SetupKeyReconciler nn := client.ObjectKey{ Name: "test-resource", @@ -98,9 +28,8 @@ var _ = Describe("SetupKey Controller", func() { BeforeEach(func() { controllerReconciler = &SetupKeyReconciler{ Client: k8sClient, - Netbird: nbClient, + Netbird: netbirdmock.Client(), } - setupKeyStore = map[string]*api.SetupKey{} }) AfterEach(func() { @@ -138,7 +67,10 @@ var _ = Describe("SetupKey Controller", func() { } err = k8sClient.Get(ctx, client.ObjectKeyFromObject(secret), secret) Expect(err).NotTo(HaveOccurred()) - Expect(string(secret.Data[SetupKeySecretKey])).To(Equal(setupKeyStore[*setupKey.Status.SetupKeyID].Key)) + + resp, err := controllerReconciler.Netbird.SetupKeys.Get(ctx, *setupKey.Status.SetupKeyID) + Expect(err).NotTo(HaveOccurred()) + Expect(string(secret.Data[SetupKeySecretKey])).To(Equal(resp.Key)) }) It("creates a new setup key when the secret is deleted", func() { @@ -182,7 +114,6 @@ var _ = Describe("SetupKey Controller", func() { Expect(err).NotTo(HaveOccurred()) Expect(k8sClient.Delete(ctx, &secondSecret)).To(Succeed()) - Expect(setupKeyStore).To(HaveLen(1)) Expect(*firstSetupKey.Status.SetupKeyID).ToNot(Equal(*secondSetupKey.Status.SetupKeyID)) Expect(firstSecret.Data[SetupKeySecretKey]).ToNot(BeEquivalentTo(secondSecret.Data[SetupKeySecretKey])) }) diff --git a/internal/netbirdmock/netbirdmock.go b/internal/netbirdmock/netbirdmock.go new file mode 100644 index 0000000..f2080a9 --- /dev/null +++ b/internal/netbirdmock/netbirdmock.go @@ -0,0 +1,141 @@ +package netbirdmock + +import ( + "encoding/json" + "fmt" + "io" + "math/rand/v2" + "net/http" + "net/http/httptest" + "sync" + + netbird "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +func Client() *netbird.Client { + mux := &http.ServeMux{} + + addHandler(mux, "groups", func(id string, input api.GroupRequest, output api.Group) api.Group { + output.Id = id + output.Name = input.Name + return output + }) + addHandler(mux, "setup-keys", func(id string, input api.SetupKeyRequest, output api.SetupKeyClear) api.SetupKeyClear { + output.Id = id + output.AutoGroups = input.AutoGroups + output.Revoked = input.Revoked + if output.Key == "" { + output.Key = fmt.Sprintf("%d", rand.Int64()) + } + return output + }) + + srv := httptest.NewServer(mux) + return netbird.New(srv.URL, "ABC") +} + +func addHandler[T, U any](mux *http.ServeMux, resource string, convertFn func(string, U, T) T) { + var itemMx sync.RWMutex + items := map[string]T{} + + mux.Handle(fmt.Sprintf("GET /api/%s/{id}", resource), http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + itemMx.RLock() + defer itemMx.RUnlock() + + id := req.PathValue("id") + respData, ok := items[id] + if !ok { + util.WriteErrorResponse("Not Found", http.StatusNotFound, rw) + return + } + b, err := json.Marshal(respData) + if err != nil { + util.WriteErrorResponse("Marshal Error", http.StatusInternalServerError, rw) + return + } + _, err = rw.Write(b) + if err != nil { + util.WriteErrorResponse("Write Error", http.StatusInternalServerError, rw) + return + } + })) + mux.Handle(fmt.Sprintf("POST /api/%s", resource), http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + itemMx.Lock() + defer itemMx.Unlock() + + b, err := io.ReadAll(req.Body) + if err != nil { + util.WriteErrorResponse("Read Error", http.StatusBadRequest, rw) + return + } + var reqData U + err = json.Unmarshal(b, &reqData) + if err != nil { + util.WriteErrorResponse("Unmarshal Error", http.StatusBadRequest, rw) + return + } + id := fmt.Sprintf("id-%d", rand.Int64()) + var zero T + respData := convertFn(id, reqData, zero) + items[id] = respData + b, err = json.Marshal(respData) + if err != nil { + util.WriteErrorResponse("Marshal Error", http.StatusInternalServerError, rw) + return + } + _, err = rw.Write(b) + if err != nil { + util.WriteErrorResponse("Write Error", http.StatusInternalServerError, rw) + return + } + })) + mux.Handle(fmt.Sprintf("PUT /api/%s/{id}", resource), http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + itemMx.Lock() + defer itemMx.Unlock() + + id := req.PathValue("id") + respData, ok := items[id] + if !ok { + util.WriteErrorResponse("Not Found", http.StatusNotFound, rw) + return + } + + b, err := io.ReadAll(req.Body) + if err != nil { + util.WriteErrorResponse("Read Error", http.StatusBadRequest, rw) + return + } + var reqData U + err = json.Unmarshal(b, &reqData) + if err != nil { + util.WriteErrorResponse("Unmarshal Error", http.StatusBadRequest, rw) + return + } + respData = convertFn(id, reqData, respData) + items[id] = respData + b, err = json.Marshal(respData) + if err != nil { + util.WriteErrorResponse("Marshal Error", http.StatusInternalServerError, rw) + return + } + _, err = rw.Write(b) + if err != nil { + util.WriteErrorResponse("Write Error", http.StatusInternalServerError, rw) + return + } + })) + mux.Handle(fmt.Sprintf("DELETE /api/%s/{id}", resource), http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + itemMx.Lock() + defer itemMx.Unlock() + + id := req.PathValue("id") + _, ok := items[id] + if !ok { + util.WriteErrorResponse("Not Found", http.StatusNotFound, rw) + return + } + delete(items, id) + })) +}