diff --git a/pkg/handlers/cluster.go b/pkg/handlers/cluster.go index ee25e6d..a17665c 100644 --- a/pkg/handlers/cluster.go +++ b/pkg/handlers/cluster.go @@ -35,6 +35,7 @@ func (h clusterHandler) Create(w http.ResponseWriter, r *http.Request) { validateEmpty(&req, "Id", "id"), validateName(&req, "Name", "name", 3, 53), validateKind(&req, "Kind", "kind", "Cluster"), + validateSpec(&req, "Spec", "spec"), }, func() (interface{}, *errors.ServiceError) { ctx := r.Context() diff --git a/pkg/handlers/cluster_nodepools.go b/pkg/handlers/cluster_nodepools.go index 612949a..353c8bd 100644 --- a/pkg/handlers/cluster_nodepools.go +++ b/pkg/handlers/cluster_nodepools.go @@ -141,6 +141,7 @@ func (h clusterNodePoolsHandler) Create(w http.ResponseWriter, r *http.Request) validateEmpty(&req, "Id", "id"), validateName(&req, "Name", "name", 3, 15), validateKind(&req, "Kind", "kind", "NodePool"), + validateSpec(&req, "Spec", "spec"), }, func() (interface{}, *errors.ServiceError) { ctx := r.Context() diff --git a/pkg/handlers/validation.go b/pkg/handlers/validation.go index 4a9bdfc..933a050 100755 --- a/pkg/handlers/validation.go +++ b/pkg/handlers/validation.go @@ -113,3 +113,23 @@ func validateKind(i interface{}, fieldName string, field string, expectedKind st return nil } } + +// validateSpec validates that the spec field is not nil +func validateSpec(i interface{}, fieldName string, field string) validate { + return func() *errors.ServiceError { + value := reflect.ValueOf(i).Elem().FieldByName(fieldName) + + if value.Kind() == reflect.Ptr { + if value.IsNil() { + return errors.Validation("%s is required", field) + } + value = value.Elem() + } + + if !value.IsValid() || value.IsNil() { + return errors.Validation("%s is required", field) + } + + return nil + } +} diff --git a/pkg/handlers/validation_test.go b/pkg/handlers/validation_test.go index e47ef2b..a548a60 100644 --- a/pkg/handlers/validation_test.go +++ b/pkg/handlers/validation_test.go @@ -212,13 +212,13 @@ func TestValidateNodePoolName_InvalidCharacters(t *testing.T) { RegisterTestingT(t) invalidNames := []string{ - "TEST", // uppercase - "Test", // mixed case - "test_pool", // underscore - "test.pool", // dot - "test pool", // space - "-test", // starts with hyphen - "test-", // ends with hyphen + "TEST", // uppercase + "Test", // mixed case + "test_pool", // underscore + "test.pool", // dot + "test pool", // space + "-test", // starts with hyphen + "test-", // ends with hyphen } for _, name := range invalidNames { @@ -231,3 +231,37 @@ func TestValidateNodePoolName_InvalidCharacters(t *testing.T) { Expect(err.Reason).To(ContainSubstring("lowercase letters, numbers, and hyphens")) } } + +func TestValidateSpec_Valid(t *testing.T) { + RegisterTestingT(t) + + req := openapi.ClusterCreateRequest{ + Spec: map[string]interface{}{"test": "value"}, + } + validator := validateSpec(&req, "Spec", "spec") + err := validator() + Expect(err).To(BeNil(), "Expected existing spec to be valid") +} + +func TestValidateSpec_EmptyMap(t *testing.T) { + RegisterTestingT(t) + + req := openapi.ClusterCreateRequest{ + Spec: map[string]interface{}{}, + } + validator := validateSpec(&req, "Spec", "spec") + err := validator() + Expect(err).To(BeNil(), "Expected empty map spec to be valid") +} + +func TestValidateSpec_Nil(t *testing.T) { + RegisterTestingT(t) + + req := openapi.ClusterCreateRequest{ + Spec: nil, + } + validator := validateSpec(&req, "Spec", "spec") + err := validator() + Expect(err).ToNot(BeNil(), "Expected nil spec to be invalid") + Expect(err.Reason).To(ContainSubstring("spec is required")) +} diff --git a/test/integration/clusters_test.go b/test/integration/clusters_test.go index c6ca6e8..7257718 100644 --- a/test/integration/clusters_test.go +++ b/test/integration/clusters_test.go @@ -555,8 +555,8 @@ func TestClusterList_DefaultSorting(t *testing.T) { } resp, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), - ) + ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), + ) Expect(err).NotTo(HaveOccurred(), "Failed to create cluster %d", i) createdClusters = append(createdClusters, *resp.JSON201) @@ -778,3 +778,76 @@ func TestClusterPost_WrongKind(t *testing.T) { Expect(ok).To(BeTrue()) Expect(detail).To(ContainSubstring("kind must be 'Cluster'")) } + +// TestClusterPost_NullSpec tests that null spec field returns 400 +func TestClusterPost_NullSpec(t *testing.T) { + h, _ := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + jwtToken := test.GetAccessTokenFromContext(ctx) + + // Send request with null spec + invalidInput := `{ + "kind": "Cluster", + "name": "test-cluster", + "spec": null + }` + + restyResp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). + SetBody(invalidInput). + Post(h.RestURL("/clusters")) + + Expect(err).ToNot(HaveOccurred()) + Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) + + var errorResponse map[string]interface{} + err = json.Unmarshal(restyResp.Body(), &errorResponse) + Expect(err).ToNot(HaveOccurred()) + + code, ok := errorResponse["code"].(string) + Expect(ok).To(BeTrue()) + Expect(code).To(Equal("HYPERFLEET-VAL-000")) + + detail, ok := errorResponse["detail"].(string) + Expect(ok).To(BeTrue()) + Expect(detail).To(ContainSubstring("spec field must be an object")) +} + +// TestClusterPost_MissingSpec tests that missing spec field returns 400 +func TestClusterPost_MissingSpec(t *testing.T) { + h, _ := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + jwtToken := test.GetAccessTokenFromContext(ctx) + + // Send request without spec field + invalidInput := `{ + "kind": "Cluster", + "name": "test-cluster" + }` + + restyResp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). + SetBody(invalidInput). + Post(h.RestURL("/clusters")) + + Expect(err).ToNot(HaveOccurred()) + Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) + + var errorResponse map[string]interface{} + err = json.Unmarshal(restyResp.Body(), &errorResponse) + Expect(err).ToNot(HaveOccurred()) + + code, ok := errorResponse["code"].(string) + Expect(ok).To(BeTrue()) + Expect(code).To(Equal("HYPERFLEET-VAL-000")) + + detail, ok := errorResponse["detail"].(string) + Expect(ok).To(BeTrue()) + Expect(detail).To(ContainSubstring("spec is required")) +} diff --git a/test/integration/node_pools_test.go b/test/integration/node_pools_test.go index 915f261..9f6f1a1 100644 --- a/test/integration/node_pools_test.go +++ b/test/integration/node_pools_test.go @@ -368,3 +368,82 @@ func TestNodePoolDuplicateNames(t *testing.T) { Expect(*problemDetail.Detail).To(ContainSubstring("already exists"), "Expected error detail to mention that resource already exists") } + +// TestNodePoolPost_NullSpec tests that null spec field returns 400 +func TestNodePoolPost_NullSpec(t *testing.T) { + h, _ := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + jwtToken := test.GetAccessTokenFromContext(ctx) + + cluster, err := h.Factories.NewClusters(h.NewID()) + Expect(err).NotTo(HaveOccurred()) + + // Send request with null spec + invalidInput := `{ + "kind": "NodePool", + "name": "test-nodepool", + "spec": null + }` + + restyResp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). + SetBody(invalidInput). + Post(h.RestURL(fmt.Sprintf("/clusters/%s/nodepools", cluster.ID))) + + Expect(err).ToNot(HaveOccurred()) + Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) + + var errorResponse map[string]interface{} + err = json.Unmarshal(restyResp.Body(), &errorResponse) + Expect(err).ToNot(HaveOccurred()) + + code, ok := errorResponse["code"].(string) + Expect(ok).To(BeTrue()) + Expect(code).To(Equal("HYPERFLEET-VAL-000")) + + detail, ok := errorResponse["detail"].(string) + Expect(ok).To(BeTrue()) + Expect(detail).To(ContainSubstring("spec field must be an object")) +} + +// TestNodePoolPost_MissingSpec tests that missing spec field returns 400 +func TestNodePoolPost_MissingSpec(t *testing.T) { + h, _ := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + jwtToken := test.GetAccessTokenFromContext(ctx) + + cluster, err := h.Factories.NewClusters(h.NewID()) + Expect(err).NotTo(HaveOccurred()) + + // Send request without spec field + invalidInput := `{ + "kind": "NodePool", + "name": "test-nodepool" + }` + + restyResp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). + SetBody(invalidInput). + Post(h.RestURL(fmt.Sprintf("/clusters/%s/nodepools", cluster.ID))) + + Expect(err).ToNot(HaveOccurred()) + Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) + + var errorResponse map[string]interface{} + err = json.Unmarshal(restyResp.Body(), &errorResponse) + Expect(err).ToNot(HaveOccurred()) + + code, ok := errorResponse["code"].(string) + Expect(ok).To(BeTrue()) + Expect(code).To(Equal("HYPERFLEET-VAL-000")) + + detail, ok := errorResponse["detail"].(string) + Expect(ok).To(BeTrue()) + Expect(detail).To(ContainSubstring("spec is required")) +}