diff --git a/README.md b/README.md index bbc5de5..87c3652 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,34 @@ func main() { } ``` +## Validate Support + +V2 supports validating a test payload against the Synthetics API without saving the test +or triggering a run. Validate calls always respond `HTTP 200` and return a `ValidateResponse` +with `Valid`, `Message`, and `Details` (field-level errors, empty when `Valid` is `true`). +Use `ValidateResponse.FieldErrors()` to get a `map[string][]string` of field-level errors +regardless of whether the API returned an empty array or an object for `Details`. + +Two flows are supported per test type: +- **Create-style** (`ValidateNew*`): validates a payload as if creating a new test. +- **Update-style** (`Validate*(id, ...)`): validates a payload as if updating the existing + test identified by `id`. + +| Test type | Create-style | Update-style | +| --- | --- | --- | +| API | `ValidateNewApiCheckV2` | `ValidateApiCheckV2` | +| Browser | `ValidateNewBrowserCheckV2` | `ValidateBrowserCheckV2` | +| HTTP | `ValidateNewHttpCheckV2` / `ValidateNewHttpCheckV2WithNullablePort` | `ValidateHttpCheckV2` / `ValidateHttpCheckV2WithNullablePort` | +| Port | `ValidateNewPortCheckV2` | `ValidatePortCheckV2` | +| SSL | `ValidateNewSslCheckV2` | `ValidateSslCheckV2` | + +SSL update-style validation takes a `SslCheckV2UpdateInput`, matching `UpdateSslCheckV2`'s +partial-update semantics, while SSL create-style validation takes a `SslCheckV2Input`. + +Validate support is not available for the deprecated V1 client, and there is no Synthetics +API validate endpoint for Location, Variable, TOTP variable, downtime configuration, CA +certificate, or client certificate resources. + ## API Documentation API Docs are [available here](https://dev.splunk.com/observability/reference) diff --git a/syntheticsclientv2/validate.go b/syntheticsclientv2/validate.go new file mode 100644 index 0000000..141e60a --- /dev/null +++ b/syntheticsclientv2/validate.go @@ -0,0 +1,67 @@ +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "encoding/json" + "strings" +) + +// ValidateResponse is the response returned by the Synthetics API's test +// validate endpoints (POST .../validate for new test definitions and +// PUT/PATCH .../{id}/validate for existing tests). Validate checks whether a +// test payload would be accepted by the API without saving any changes or +// triggering a test run. +// +// The API always responds HTTP 200 for validate calls, using Valid to signal +// whether the payload passed. Details is a JSON array when Valid is true and +// a JSON object of per-attribute error messages when Valid is false; use +// FieldErrors to normalize both shapes. +type ValidateResponse struct { + Valid bool `json:"valid"` + Message string `json:"message"` + Details json.RawMessage `json:"details"` +} + +func parseValidateResponse(response string) (*ValidateResponse, error) { + var validateResponse ValidateResponse + if response == "" { + return &validateResponse, nil + } + + if err := json.Unmarshal([]byte(response), &validateResponse); err != nil { + return nil, err + } + + return &validateResponse, nil +} + +// FieldErrors returns field-level validation error messages keyed by +// attribute name. It returns an empty map when validation succeeded, since +// the API responds with an empty array rather than an object in that case. +func (v *ValidateResponse) FieldErrors() (map[string][]string, error) { + fieldErrors := map[string][]string{} + + trimmed := strings.TrimSpace(string(v.Details)) + if trimmed == "" || trimmed == "null" || strings.HasPrefix(trimmed, "[") { + return fieldErrors, nil + } + + if err := json.Unmarshal(v.Details, &fieldErrors); err != nil { + return nil, err + } + + return fieldErrors, nil +} diff --git a/syntheticsclientv2/validate_apicheckv2.go b/syntheticsclientv2/validate_apicheckv2.go new file mode 100644 index 0000000..e17d2f1 --- /dev/null +++ b/syntheticsclientv2/validate_apicheckv2.go @@ -0,0 +1,80 @@ +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ValidateNewApiCheckV2 checks whether an API check payload would be accepted +// when creating a new test, without saving the test or triggering a run. +func (c Client) ValidateNewApiCheckV2(ApiCheckV2Details *ApiCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + if ApiCheckV2Details.Test.Requests[0].Setup == nil { + ApiCheckV2Details.Test.Requests[0].Setup = make([]Setup, 0) + } + + if ApiCheckV2Details.Test.Requests[0].Validations == nil { + ApiCheckV2Details.Test.Requests[0].Validations = make([]Validations, 0) + } + + body, err := json.Marshal(ApiCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("POST", "/v2/tests/api/validate", bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateApiCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateApiCheckV2, details, nil +} + +// ValidateApiCheckV2 checks whether an API check payload would be accepted +// when updating the existing test identified by id, without saving the +// change or triggering a run. +func (c Client) ValidateApiCheckV2(id int, ApiCheckV2Details *ApiCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + if ApiCheckV2Details.Test.Requests[0].Setup == nil { + ApiCheckV2Details.Test.Requests[0].Setup = make([]Setup, 0) + } + + if ApiCheckV2Details.Test.Requests[0].Validations == nil { + ApiCheckV2Details.Test.Requests[0].Validations = make([]Validations, 0) + } + + body, err := json.Marshal(ApiCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("PUT", fmt.Sprintf("/v2/tests/api/%d/validate", id), bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateApiCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateApiCheckV2, details, nil +} diff --git a/syntheticsclientv2/validate_apicheckv2_test.go b/syntheticsclientv2/validate_apicheckv2_test.go new file mode 100644 index 0000000..0a263ab --- /dev/null +++ b/syntheticsclientv2/validate_apicheckv2_test.go @@ -0,0 +1,151 @@ +//go:build unit_tests +// +build unit_tests + +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestValidateNewApiCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := ApiCheckV2Input{} + err := json.Unmarshal([]byte(createApiV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/api/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, details, err := testClient.ValidateNewApiCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if details == nil { + t.Fatal("expected request details") + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateNewApiCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := ApiCheckV2Input{} + err := json.Unmarshal([]byte(createApiV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/api/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"name":["can't be blank"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewApiCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["name"]) != 1 || fieldErrors["name"][0] != "can't be blank" { + t.Errorf("FieldErrors()[\"name\"] = %#v, want [\"can't be blank\"]", fieldErrors["name"]) + } +} + +func TestValidateApiCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := ApiCheckV2Input{} + err := json.Unmarshal([]byte(createApiV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/api/489/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateApiCheckV2(489, &input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateApiCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := ApiCheckV2Input{} + err := json.Unmarshal([]byte(createApiV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/api/489/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"requests":["is invalid"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateApiCheckV2(489, &input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["requests"]) != 1 || fieldErrors["requests"][0] != "is invalid" { + t.Errorf("FieldErrors()[\"requests\"] = %#v, want [\"is invalid\"]", fieldErrors["requests"]) + } +} diff --git a/syntheticsclientv2/validate_browsercheckv2.go b/syntheticsclientv2/validate_browsercheckv2.go new file mode 100644 index 0000000..a460406 --- /dev/null +++ b/syntheticsclientv2/validate_browsercheckv2.go @@ -0,0 +1,65 @@ +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ValidateNewBrowserCheckV2 checks whether a browser check payload would be +// accepted when creating a new test, without saving the test or triggering a +// run. +func (c Client) ValidateNewBrowserCheckV2(browserCheckV2Details *BrowserCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + body, err := json.Marshal(browserCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("POST", "/v2/tests/browser/validate", bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateBrowserCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateBrowserCheckV2, details, nil +} + +// ValidateBrowserCheckV2 checks whether a browser check payload would be +// accepted when updating the existing test identified by id, without saving +// the change or triggering a run. +func (c Client) ValidateBrowserCheckV2(id int, browserCheckV2Details *BrowserCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + body, err := json.Marshal(browserCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("PUT", fmt.Sprintf("/v2/tests/browser/%d/validate", id), bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateBrowserCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateBrowserCheckV2, details, nil +} diff --git a/syntheticsclientv2/validate_browsercheckv2_test.go b/syntheticsclientv2/validate_browsercheckv2_test.go new file mode 100644 index 0000000..ec62567 --- /dev/null +++ b/syntheticsclientv2/validate_browsercheckv2_test.go @@ -0,0 +1,148 @@ +//go:build unit_tests +// +build unit_tests + +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestValidateNewBrowserCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := BrowserCheckV2Input{} + err := json.Unmarshal([]byte(createBrowserCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/browser/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewBrowserCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateNewBrowserCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := BrowserCheckV2Input{} + err := json.Unmarshal([]byte(createBrowserCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/browser/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"startUrl":["can't be blank"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewBrowserCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["startUrl"]) != 1 || fieldErrors["startUrl"][0] != "can't be blank" { + t.Errorf("FieldErrors()[\"startUrl\"] = %#v, want [\"can't be blank\"]", fieldErrors["startUrl"]) + } +} + +func TestValidateBrowserCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := BrowserCheckV2Input{} + err := json.Unmarshal([]byte(createBrowserCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/browser/77/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateBrowserCheckV2(77, &input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateBrowserCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := BrowserCheckV2Input{} + err := json.Unmarshal([]byte(createBrowserCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/v2/tests/browser/77/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"transactions":["is invalid"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateBrowserCheckV2(77, &input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["transactions"]) != 1 || fieldErrors["transactions"][0] != "is invalid" { + t.Errorf("FieldErrors()[\"transactions\"] = %#v, want [\"is invalid\"]", fieldErrors["transactions"]) + } +} diff --git a/syntheticsclientv2/validate_httpcheckv2.go b/syntheticsclientv2/validate_httpcheckv2.go new file mode 100644 index 0000000..bf81961 --- /dev/null +++ b/syntheticsclientv2/validate_httpcheckv2.go @@ -0,0 +1,125 @@ +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ValidateNewHttpCheckV2 checks whether an HTTP check payload would be +// accepted when creating a new test, without saving the test or triggering a +// run. +func (c Client) ValidateNewHttpCheckV2(HttpCheckV2Details *HttpCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + if HttpCheckV2Details.Test.Validations == nil { + HttpCheckV2Details.Test.Validations = make([]Validations, 0) + } + + body, err := json.Marshal(HttpCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("POST", "/tests/http/validate", bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateHttpCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateHttpCheckV2, details, nil +} + +// ValidateHttpCheckV2 checks whether an HTTP check payload would be accepted +// when updating the existing test identified by id, without saving the +// change or triggering a run. +func (c Client) ValidateHttpCheckV2(id int, HttpCheckV2Details *HttpCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + if HttpCheckV2Details.Test.Validations == nil { + HttpCheckV2Details.Test.Validations = make([]Validations, 0) + } + + body, err := json.Marshal(HttpCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("PUT", fmt.Sprintf("/tests/http/%d/validate", id), bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateHttpCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateHttpCheckV2, details, nil +} + +// ValidateNewHttpCheckV2WithNullablePort checks whether an HTTP check payload +// with a nullable port would be accepted when creating a new test, without +// saving the test or triggering a run. +func (c Client) ValidateNewHttpCheckV2WithNullablePort(HttpCheckV2Details *HttpCheckV2InputWithNullablePort) (*ValidateResponse, *RequestDetails, error) { + if HttpCheckV2Details.Test.Validations == nil { + HttpCheckV2Details.Test.Validations = make([]Validations, 0) + } + + body, err := json.Marshal(HttpCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("POST", "/tests/http/validate", bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateHttpCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateHttpCheckV2, details, nil +} + +// ValidateHttpCheckV2WithNullablePort checks whether an HTTP check payload +// with a nullable port would be accepted when updating the existing test +// identified by id, without saving the change or triggering a run. +func (c Client) ValidateHttpCheckV2WithNullablePort(id int, HttpCheckV2Details *HttpCheckV2InputWithNullablePort) (*ValidateResponse, *RequestDetails, error) { + if HttpCheckV2Details.Test.Validations == nil { + HttpCheckV2Details.Test.Validations = make([]Validations, 0) + } + + body, err := json.Marshal(HttpCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("PUT", fmt.Sprintf("/tests/http/%d/validate", id), bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateHttpCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateHttpCheckV2, details, nil +} diff --git a/syntheticsclientv2/validate_httpcheckv2_test.go b/syntheticsclientv2/validate_httpcheckv2_test.go new file mode 100644 index 0000000..f19fcf1 --- /dev/null +++ b/syntheticsclientv2/validate_httpcheckv2_test.go @@ -0,0 +1,220 @@ +//go:build unit_tests +// +build unit_tests + +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestValidateNewHttpCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := HttpCheckV2Input{} + err := json.Unmarshal([]byte(createHttpCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/http/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewHttpCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateNewHttpCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := HttpCheckV2Input{} + err := json.Unmarshal([]byte(createHttpCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/http/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"url":["is not a valid URL"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewHttpCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["url"]) != 1 || fieldErrors["url"][0] != "is not a valid URL" { + t.Errorf("FieldErrors()[\"url\"] = %#v, want [\"is not a valid URL\"]", fieldErrors["url"]) + } +} + +func TestValidateHttpCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := HttpCheckV2Input{} + err := json.Unmarshal([]byte(createHttpCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/http/21/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateHttpCheckV2(21, &input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateHttpCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := HttpCheckV2Input{} + err := json.Unmarshal([]byte(createHttpCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/http/21/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"requestMethod":["is not included in the list"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateHttpCheckV2(21, &input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["requestMethod"]) != 1 || fieldErrors["requestMethod"][0] != "is not included in the list" { + t.Errorf("FieldErrors()[\"requestMethod\"] = %#v, want [\"is not included in the list\"]", fieldErrors["requestMethod"]) + } +} + +func TestValidateNewHttpCheckV2WithNullablePortSuccess(t *testing.T) { + setup() + defer teardown() + + input := minimalHttpCheckV2InputWithNullablePort() + input.Test.Port = *NewNullInt() + + testMux.HandleFunc("/tests/http/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + fields := readHttpV2NullablePortRequestFields(t, r) + rawPort, ok := fields["port"] + if !ok { + t.Fatal("request body missing test.port") + } + if string(rawPort) != "null" { + t.Fatalf("request body test.port = %s, want null", rawPort) + } + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewHttpCheckV2WithNullablePort(&input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateHttpCheckV2WithNullablePortFailure(t *testing.T) { + setup() + defer teardown() + + input := minimalHttpCheckV2InputWithNullablePort() + input.Test.Port = *NewNullableInt(0) + + testMux.HandleFunc("/tests/http/22/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + fields := readHttpV2NullablePortRequestFields(t, r) + rawPort, ok := fields["port"] + if !ok { + t.Fatal("request body missing test.port") + } + if string(rawPort) != "0" { + t.Fatalf("request body test.port = %s, want 0", rawPort) + } + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"port":["is not included in the list"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateHttpCheckV2WithNullablePort(22, &input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["port"]) != 1 || fieldErrors["port"][0] != "is not included in the list" { + t.Errorf("FieldErrors()[\"port\"] = %#v, want [\"is not included in the list\"]", fieldErrors["port"]) + } +} diff --git a/syntheticsclientv2/validate_portcheckv2.go b/syntheticsclientv2/validate_portcheckv2.go new file mode 100644 index 0000000..12f0e9b --- /dev/null +++ b/syntheticsclientv2/validate_portcheckv2.go @@ -0,0 +1,65 @@ +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ValidateNewPortCheckV2 checks whether a port check payload would be +// accepted when creating a new test, without saving the test or triggering a +// run. +func (c Client) ValidateNewPortCheckV2(PortCheckV2Details *PortCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + body, err := json.Marshal(PortCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("POST", "/tests/port/validate", bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validatePortCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validatePortCheckV2, details, nil +} + +// ValidatePortCheckV2 checks whether a port check payload would be accepted +// when updating the existing test identified by id, without saving the +// change or triggering a run. +func (c Client) ValidatePortCheckV2(id int, PortCheckV2Details *PortCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + body, err := json.Marshal(PortCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("PUT", fmt.Sprintf("/tests/port/%d/validate", id), bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validatePortCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validatePortCheckV2, details, nil +} diff --git a/syntheticsclientv2/validate_portcheckv2_test.go b/syntheticsclientv2/validate_portcheckv2_test.go new file mode 100644 index 0000000..476d3c8 --- /dev/null +++ b/syntheticsclientv2/validate_portcheckv2_test.go @@ -0,0 +1,148 @@ +//go:build unit_tests +// +build unit_tests + +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestValidateNewPortCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := PortCheckV2Input{} + err := json.Unmarshal([]byte(createPortCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/port/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewPortCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateNewPortCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := PortCheckV2Input{} + err := json.Unmarshal([]byte(createPortCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/port/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"port":["is not a number"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewPortCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["port"]) != 1 || fieldErrors["port"][0] != "is not a number" { + t.Errorf("FieldErrors()[\"port\"] = %#v, want [\"is not a number\"]", fieldErrors["port"]) + } +} + +func TestValidatePortCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := PortCheckV2Input{} + err := json.Unmarshal([]byte(createPortCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/port/33/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidatePortCheckV2(33, &input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidatePortCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := PortCheckV2Input{} + err := json.Unmarshal([]byte(createPortCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/port/33/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"host":["can't be blank"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidatePortCheckV2(33, &input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["host"]) != 1 || fieldErrors["host"][0] != "can't be blank" { + t.Errorf("FieldErrors()[\"host\"] = %#v, want [\"can't be blank\"]", fieldErrors["host"]) + } +} diff --git a/syntheticsclientv2/validate_sslcheckv2.go b/syntheticsclientv2/validate_sslcheckv2.go new file mode 100644 index 0000000..452e996 --- /dev/null +++ b/syntheticsclientv2/validate_sslcheckv2.go @@ -0,0 +1,68 @@ +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ValidateNewSslCheckV2 checks whether an SSL check payload would be accepted +// when creating a new test, without saving the test or triggering a run. +func (c Client) ValidateNewSslCheckV2(SslCheckV2Details *SslCheckV2Input) (*ValidateResponse, *RequestDetails, error) { + if SslCheckV2Details.Test.Validations == nil { + SslCheckV2Details.Test.Validations = make([]Validations, 0) + } + + body, err := json.Marshal(SslCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("POST", "/tests/ssl/validate", bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateSslCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateSslCheckV2, details, nil +} + +// ValidateSslCheckV2 checks whether an SSL check payload would be accepted +// when updating the existing test identified by id, without saving the +// change or triggering a run. +func (c Client) ValidateSslCheckV2(id int, SslCheckV2Details *SslCheckV2UpdateInput) (*ValidateResponse, *RequestDetails, error) { + body, err := json.Marshal(SslCheckV2Details) + if err != nil { + return nil, nil, err + } + + details, err := c.makePublicAPICall("PUT", fmt.Sprintf("/tests/ssl/%d/validate", id), bytes.NewBuffer(body), nil) + if err != nil { + return nil, details, err + } + + validateSslCheckV2, err := parseValidateResponse(details.ResponseBody) + if err != nil { + return nil, details, err + } + + return validateSslCheckV2, details, nil +} diff --git a/syntheticsclientv2/validate_sslcheckv2_test.go b/syntheticsclientv2/validate_sslcheckv2_test.go new file mode 100644 index 0000000..60935e9 --- /dev/null +++ b/syntheticsclientv2/validate_sslcheckv2_test.go @@ -0,0 +1,144 @@ +//go:build unit_tests +// +build unit_tests + +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestValidateNewSslCheckV2Success(t *testing.T) { + setup() + defer teardown() + + input := SslCheckV2Input{} + err := json.Unmarshal([]byte(createSslCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/ssl/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewSslCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateNewSslCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + input := SslCheckV2Input{} + err := json.Unmarshal([]byte(createSslCheckV2Body), &input) + if err != nil { + t.Fatal(err) + } + + testMux.HandleFunc("/tests/ssl/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"host":["can't be blank"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateNewSslCheckV2(&input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["host"]) != 1 || fieldErrors["host"][0] != "can't be blank" { + t.Errorf("FieldErrors()[\"host\"] = %#v, want [\"can't be blank\"]", fieldErrors["host"]) + } +} + +func TestValidateSslCheckV2Success(t *testing.T) { + setup() + defer teardown() + + active := false + input := SslCheckV2UpdateInput{} + input.Test.Active = &active + + testMux.HandleFunc("/tests/ssl/1655/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateSslCheckV2(1655, &input) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Errorf("resp.Valid = %#v, want true", resp.Valid) + } +} + +func TestValidateSslCheckV2Failure(t *testing.T) { + setup() + defer teardown() + + port := 999999 + input := SslCheckV2UpdateInput{} + input.Test.Port = &port + + testMux.HandleFunc("/tests/ssl/1656/validate", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + _, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"port":["is not included in the list"]}}`)) + if err != nil { + t.Fatal(err) + } + }) + + resp, _, err := testClient.ValidateSslCheckV2(1656, &input) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Errorf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors["port"]) != 1 || fieldErrors["port"][0] != "is not included in the list" { + t.Errorf("FieldErrors()[\"port\"] = %#v, want [\"is not included in the list\"]", fieldErrors["port"]) + } +} diff --git a/syntheticsclientv2/validate_test.go b/syntheticsclientv2/validate_test.go new file mode 100644 index 0000000..410f839 --- /dev/null +++ b/syntheticsclientv2/validate_test.go @@ -0,0 +1,82 @@ +//go:build unit_tests +// +build unit_tests + +// Copyright 2026 Splunk, Inc. +// +// 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 syntheticsclientv2 + +import ( + "reflect" + "testing" +) + +func TestValidateResponseFieldErrorsSuccess(t *testing.T) { + resp, err := parseValidateResponse(`{"valid":true,"message":"Test is valid","details":[]}`) + if err != nil { + t.Fatal(err) + } + if !resp.Valid { + t.Fatalf("resp.Valid = %#v, want true", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors) != 0 { + t.Errorf("FieldErrors() = %#v, want empty map", fieldErrors) + } +} + +func TestValidateResponseFieldErrorsFailure(t *testing.T) { + resp, err := parseValidateResponse(`{"valid":false,"message":"Test is invalid","details":{"name":["can't be blank"],"frequency":["is not included in the list"]}}`) + if err != nil { + t.Fatal(err) + } + if resp.Valid { + t.Fatalf("resp.Valid = %#v, want false", resp.Valid) + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + + want := map[string][]string{ + "name": {"can't be blank"}, + "frequency": {"is not included in the list"}, + } + if !reflect.DeepEqual(fieldErrors, want) { + t.Errorf("FieldErrors() = %#v, want %#v", fieldErrors, want) + } +} + +func TestValidateResponseBlankBody(t *testing.T) { + resp, err := parseValidateResponse("") + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected non-nil response for blank body") + } + + fieldErrors, err := resp.FieldErrors() + if err != nil { + t.Fatal(err) + } + if len(fieldErrors) != 0 { + t.Errorf("FieldErrors() = %#v, want empty map", fieldErrors) + } +}