package endpoint import ( "bytes" "crypto/tls" "crypto/x509" "errors" "io" "net/http" "strings" "testing" "time" "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/config/endpoint/dns" "github.com/TwiN/gatus/v5/config/endpoint/ssh" "github.com/TwiN/gatus/v5/config/endpoint/ui" "github.com/TwiN/gatus/v5/config/gontext" "github.com/TwiN/gatus/v5/config/maintenance" "github.com/TwiN/gatus/v5/test" ) func TestEndpoint(t *testing.T) { defer client.InjectHTTPClient(nil) scenarios := []struct { Name string Endpoint Endpoint ExpectedResult *Result MockRoundTripper test.MockRoundTripper }{ { Name: "success", Endpoint: Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP", "[CERTIFICATE_EXPIRATION] > 24h"}, }, ExpectedResult: &Result{ Success: true, Connected: true, Hostname: "twin.sh", ConditionResults: []*ConditionResult{ {Condition: "[STATUS] == 200", Success: true}, {Condition: "[BODY].status == UP", Success: true}, {Condition: "[CERTIFICATE_EXPIRATION] > 24h", Success: true}, }, DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0. }, MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`)), TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(9999 * time.Hour)}}}, } }), }, { Name: "failed-body-condition", Endpoint: Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"}, }, ExpectedResult: &Result{ Success: false, Connected: true, Hostname: "twin.sh", ConditionResults: []*ConditionResult{ {Condition: "[STATUS] == 200", Success: true}, {Condition: "[BODY].status (DOWN) == UP", Success: false}, }, DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0. }, MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "DOWN"}`))} }), }, { Name: "failed-status-condition", Endpoint: Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{"[STATUS] == 200"}, }, ExpectedResult: &Result{ Success: false, Connected: true, Hostname: "twin.sh", ConditionResults: []*ConditionResult{ {Condition: "[STATUS] (502) == 200", Success: false}, }, DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0. }, MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody} }), }, { Name: "failed-status-condition-with-hidden-conditions", Endpoint: Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{"[STATUS] == 200"}, UIConfig: &ui.Config{HideConditions: true}, }, ExpectedResult: &Result{ Success: false, Connected: true, Hostname: "twin.sh", ConditionResults: []*ConditionResult{}, // Because UIConfig.HideConditions is true, the condition results should not be shown. DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0. }, MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody} }), }, { Name: "condition-with-failed-certificate-expiration", Endpoint: Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{"[CERTIFICATE_EXPIRATION] > 100h"}, UIConfig: &ui.Config{DontResolveFailedConditions: true}, }, ExpectedResult: &Result{ Success: false, Connected: true, Hostname: "twin.sh", ConditionResults: []*ConditionResult{ // Because UIConfig.DontResolveFailedConditions is true, the values in the condition should not be resolved {Condition: "[CERTIFICATE_EXPIRATION] > 100h", Success: false}, }, DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0. }, MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusOK, Body: http.NoBody, TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(5 * time.Hour)}}}, } }), }, { Name: "domain-expiration", Endpoint: Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"}, Interval: 5 * time.Minute, }, ExpectedResult: &Result{ Success: true, Connected: true, Hostname: "twin.sh", ConditionResults: []*ConditionResult{ {Condition: "[DOMAIN_EXPIRATION] > 100h", Success: true}, }, DomainExpiration: 999999 * time.Hour, // Note that this test only checks if it's non-zero. }, MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} }), }, { Name: "endpoint-that-will-time-out-and-hidden-hostname", Endpoint: Endpoint{ Name: "endpoint-that-will-time-out", URL: "https://twin.sh:9999/health", Conditions: []Condition{"[CONNECTED] == true"}, UIConfig: &ui.Config{HideHostname: true, HidePort: true}, ClientConfig: &client.Config{Timeout: time.Millisecond}, }, ExpectedResult: &Result{ Success: false, Connected: false, Hostname: "", // Because Endpoint.UIConfig.HideHostname is true, this should be empty. ConditionResults: []*ConditionResult{ {Condition: "[CONNECTED] (false) == true", Success: false}, }, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0. DomainExpiration: 0, // Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by . Errors: []string{`Get "https://:/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`}, }, MockRoundTripper: nil, }, { Name: "endpoint-that-will-time-out-and-hidden-url", Endpoint: Endpoint{ Name: "endpoint-that-will-time-out", URL: "https://twin.sh/health", Conditions: []Condition{"[CONNECTED] == true"}, UIConfig: &ui.Config{HideURL: true}, ClientConfig: &client.Config{Timeout: time.Millisecond}, }, ExpectedResult: &Result{ Success: false, Connected: false, Hostname: "twin.sh", ConditionResults: []*ConditionResult{ {Condition: "[CONNECTED] (false) == true", Success: false}, }, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0. DomainExpiration: 0, // Because Endpoint.UIConfig.HideURL is true, the URL should be replaced by . Errors: []string{`Get "": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`}, }, MockRoundTripper: nil, }, } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { if scenario.MockRoundTripper != nil { mockClient := &http.Client{Transport: scenario.MockRoundTripper} if scenario.Endpoint.ClientConfig != nil && scenario.Endpoint.ClientConfig.Timeout > 0 { mockClient.Timeout = scenario.Endpoint.ClientConfig.Timeout } client.InjectHTTPClient(mockClient) } else { client.InjectHTTPClient(nil) } err := scenario.Endpoint.ValidateAndSetDefaults() if err != nil { t.Error("did not expect an error, got", err) } result := scenario.Endpoint.EvaluateHealth() if result.Success != scenario.ExpectedResult.Success { t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success) } if result.Connected != scenario.ExpectedResult.Connected { t.Errorf("Expected connected to be %v, got %v", scenario.ExpectedResult.Connected, result.Connected) } if result.Hostname != scenario.ExpectedResult.Hostname { t.Errorf("Expected hostname to be %v, got %v", scenario.ExpectedResult.Hostname, result.Hostname) } if len(result.ConditionResults) != len(scenario.ExpectedResult.ConditionResults) { t.Errorf("Expected %v condition results, got %v", len(scenario.ExpectedResult.ConditionResults), len(result.ConditionResults)) } else { for i, conditionResult := range result.ConditionResults { if conditionResult.Condition != scenario.ExpectedResult.ConditionResults[i].Condition { t.Errorf("Expected condition to be %v, got %v", scenario.ExpectedResult.ConditionResults[i].Condition, conditionResult.Condition) } if conditionResult.Success != scenario.ExpectedResult.ConditionResults[i].Success { t.Errorf("Expected success of condition '%s' to be %v, got %v", conditionResult.Condition, scenario.ExpectedResult.ConditionResults[i].Success, conditionResult.Success) } } } if len(result.Errors) != len(scenario.ExpectedResult.Errors) { t.Errorf("Expected %v errors, got %v", len(scenario.ExpectedResult.Errors), len(result.Errors)) } else { for i, err := range result.Errors { if err != scenario.ExpectedResult.Errors[i] { t.Errorf("Expected error to be %v, got %v", scenario.ExpectedResult.Errors[i], err) } } } if result.DomainExpiration != scenario.ExpectedResult.DomainExpiration { // Note that DomainExpiration is only resolved if there's a condition with the DomainExpirationPlaceholder in it. // In other words, if there's no condition with [DOMAIN_EXPIRATION] in it, the DomainExpiration field will be 0. // Because this is a live call, mocking it would be too much of a pain, so we're just going to check if // the actual value is non-zero when the expected result is non-zero. if scenario.ExpectedResult.DomainExpiration.Hours() > 0 && !(result.DomainExpiration.Hours() > 0) { t.Errorf("Expected domain expiration to be non-zero, got %v", result.DomainExpiration) } } }) } } func TestEndpoint_IsEnabled(t *testing.T) { if !(&Endpoint{Enabled: nil}).IsEnabled() { t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil") } if value := false; (&Endpoint{Enabled: &value}).IsEnabled() { t.Error("endpoint.IsEnabled() should've returned false, because Enabled was set to false") } if value := true; !(&Endpoint{Enabled: &value}).IsEnabled() { t.Error("Endpoint.IsEnabled() should've returned true, because Enabled was set to true") } } func TestEndpoint_Type(t *testing.T) { type args struct { URL string DNS *dns.Config SSH *ssh.Config } tests := []struct { args args want Type }{ { args: args{ URL: "8.8.8.8", DNS: &dns.Config{ QueryType: "A", QueryName: "example.com", }, }, want: TypeDNS, }, { args: args{ URL: "tcp://127.0.0.1:6379", }, want: TypeTCP, }, { args: args{ URL: "icmp://example.com", }, want: TypeICMP, }, { args: args{ URL: "sctp://example.com", }, want: TypeSCTP, }, { args: args{ URL: "udp://example.com", }, want: TypeUDP, }, { args: args{ URL: "starttls://smtp.gmail.com:587", }, want: TypeSTARTTLS, }, { args: args{ URL: "tls://example.com:443", }, want: TypeTLS, }, { args: args{ URL: "https://twin.sh/health", }, want: TypeHTTP, }, { args: args{ URL: "wss://example.com/", }, want: TypeWS, }, { args: args{ URL: "ws://example.com/", }, want: TypeWS, }, { args: args{ URL: "ssh://example.com:22", SSH: &ssh.Config{ Username: "root", Password: "password", }, }, want: TypeSSH, }, { args: args{ URL: "invalid://example.org", }, want: TypeUNKNOWN, }, { args: args{ URL: "no-scheme", }, want: TypeUNKNOWN, }, } for _, tt := range tests { t.Run(string(tt.want), func(t *testing.T) { endpoint := Endpoint{ URL: tt.args.URL, DNSConfig: tt.args.DNS, } if got := endpoint.Type(); got != tt.want { t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want) } }) } } func TestEndpoint_ValidateAndSetDefaults(t *testing.T) { endpoint := Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{Condition("[STATUS] == 200")}, Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}}, MaintenanceWindows: []*maintenance.Config{{Start: "03:50", Duration: 4 * time.Hour}}, } if err := endpoint.ValidateAndSetDefaults(); err != nil { t.Errorf("Expected no error, got %v", err) } if endpoint.ClientConfig == nil { t.Error("client configuration should've been set to the default configuration") } else { if endpoint.ClientConfig.Insecure != client.GetDefaultConfig().Insecure { t.Errorf("Default client configuration should've set Insecure to %v, got %v", client.GetDefaultConfig().Insecure, endpoint.ClientConfig.Insecure) } if endpoint.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect { t.Errorf("Default client configuration should've set IgnoreRedirect to %v, got %v", client.GetDefaultConfig().IgnoreRedirect, endpoint.ClientConfig.IgnoreRedirect) } if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout { t.Errorf("Default client configuration should've set Timeout to %v, got %v", client.GetDefaultConfig().Timeout, endpoint.ClientConfig.Timeout) } } if endpoint.Method != "GET" { t.Error("Endpoint method should've defaulted to GET") } if endpoint.Interval != time.Minute { t.Error("Endpoint interval should've defaulted to 1 minute") } if endpoint.Headers == nil { t.Error("Endpoint headers should've defaulted to an empty map") } if len(endpoint.Alerts) != 1 { t.Error("Endpoint should've had 1 alert") } if !endpoint.Alerts[0].IsEnabled() { t.Error("Endpoint alert should've defaulted to true") } if endpoint.Alerts[0].SuccessThreshold != 2 { t.Error("Endpoint alert should've defaulted to a success threshold of 2") } if endpoint.Alerts[0].FailureThreshold != 3 { t.Error("Endpoint alert should've defaulted to a failure threshold of 3") } if len(endpoint.MaintenanceWindows) != 1 { t.Error("Endpoint should've had 1 maintenance window") } if !endpoint.MaintenanceWindows[0].IsEnabled() { t.Error("Endpoint maintenance should've defaulted to true") } if endpoint.MaintenanceWindows[0].Timezone != "UTC" { t.Error("Endpoint maintenance should've defaulted to UTC") } } func TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) { endpoint := Endpoint{ Name: "invalid-condition", URL: "https://twin.sh/health", Conditions: []Condition{"[STATUS] invalid 200"}, } if err := endpoint.ValidateAndSetDefaults(); err == nil { t.Error("endpoint validation should've returned an error, but didn't") } } func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) { endpoint := Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{Condition("[STATUS] == 200")}, ClientConfig: &client.Config{ Insecure: true, IgnoreRedirect: true, Timeout: 0, }, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } if endpoint.ClientConfig == nil { t.Error("client configuration should've been set to the default configuration") } else { if !endpoint.ClientConfig.Insecure { t.Error("endpoint.ClientConfig.Insecure should've been set to true") } if !endpoint.ClientConfig.IgnoreRedirect { t.Error("endpoint.ClientConfig.IgnoreRedirect should've been set to true") } if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout { t.Error("endpoint.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid") } } } func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) { endpoint := &Endpoint{ Name: "dns-test", URL: "https://example.com", DNSConfig: &dns.Config{ QueryType: "A", QueryName: "example.com", }, Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")}, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Error("did not expect an error, got", err) } if endpoint.DNSConfig.QueryName != "example.com." { t.Error("Endpoint.dns.query-name should be formatted with . suffix") } } func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) { scenarios := []struct { name string username string password string privateKey string expectedErr error }{ { name: "fail when has no user but has password", username: "", password: "password", expectedErr: ssh.ErrEndpointWithoutSSHUsername, }, { name: "fail when has no user but has private key", username: "", privateKey: "-----BEGIN", expectedErr: ssh.ErrEndpointWithoutSSHUsername, }, { name: "fail when has no password or private key", username: "username", password: "", privateKey: "", expectedErr: ssh.ErrEndpointWithoutSSHAuth, }, { name: "success when username and password are set", username: "username", password: "password", expectedErr: nil, }, { name: "success when username and private key are set", username: "username", privateKey: "-----BEGIN", expectedErr: nil, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { endpoint := &Endpoint{ Name: "ssh-test", URL: "https://example.com", SSHConfig: &ssh.Config{ Username: scenario.username, Password: scenario.password, PrivateKey: scenario.privateKey, }, Conditions: []Condition{Condition("[STATUS] == 0")}, } err := endpoint.ValidateAndSetDefaults() if !errors.Is(err, scenario.expectedErr) { t.Errorf("expected error %v, got %v", scenario.expectedErr, err) } }) } } func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) { scenarios := []struct { endpoint *Endpoint expectedErr error }{ { endpoint: &Endpoint{ Name: "", URL: "https://example.com", Conditions: []Condition{Condition("[STATUS] == 200")}, }, expectedErr: ErrEndpointWithNoName, }, { endpoint: &Endpoint{ Name: "endpoint-with-no-url", URL: "", Conditions: []Condition{Condition("[STATUS] == 200")}, }, expectedErr: ErrEndpointWithNoURL, }, { endpoint: &Endpoint{ Name: "endpoint-with-no-conditions", URL: "https://example.com", Conditions: nil, }, expectedErr: ErrEndpointWithNoCondition, }, { endpoint: &Endpoint{ Name: "domain-expiration-with-bad-interval", URL: "https://example.com", Interval: time.Minute, Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")}, }, expectedErr: ErrInvalidEndpointIntervalForDomainExpirationPlaceholder, }, { endpoint: &Endpoint{ Name: "domain-expiration-with-good-interval", URL: "https://example.com", Interval: 5 * time.Minute, Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")}, }, expectedErr: nil, }, } for _, scenario := range scenarios { t.Run(scenario.endpoint.Name, func(t *testing.T) { if err := scenario.endpoint.ValidateAndSetDefaults(); err != scenario.expectedErr { t.Errorf("Expected error %v, got %v", scenario.expectedErr, err) } }) } } func TestEndpoint_buildHTTPRequest(t *testing.T) { condition := Condition("[STATUS] == 200") endpoint := Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{condition}, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } request := endpoint.buildHTTPRequest() if request.Method != "GET" { t.Error("request.Method should've been GET, but was", request.Method) } if request.Host != "twin.sh" { t.Error("request.Host should've been twin.sh, but was", request.Host) } if userAgent := request.Header.Get("User-Agent"); userAgent != GatusUserAgent { t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", GatusUserAgent, userAgent) } } func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) { condition := Condition("[STATUS] == 200") endpoint := Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{condition}, Headers: map[string]string{ "User-Agent": "Test/2.0", }, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } request := endpoint.buildHTTPRequest() if request.Method != "GET" { t.Error("request.Method should've been GET, but was", request.Method) } if request.Host != "twin.sh" { t.Error("request.Host should've been twin.sh, but was", request.Host) } if userAgent := request.Header.Get("User-Agent"); userAgent != "Test/2.0" { t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", "Test/2.0", userAgent) } } func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) { condition := Condition("[STATUS] == 200") endpoint := Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Method: "POST", Conditions: []Condition{condition}, Headers: map[string]string{ "Host": "example.com", }, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } request := endpoint.buildHTTPRequest() if request.Method != "POST" { t.Error("request.Method should've been POST, but was", request.Method) } if request.Host != "example.com" { t.Error("request.Host should've been example.com, but was", request.Host) } } func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) { condition := Condition("[STATUS] == 200") endpoint := Endpoint{ Name: "website-graphql", URL: "https://twin.sh/graphql", Method: "POST", Conditions: []Condition{condition}, GraphQL: true, Body: `{ users(gender: "female") { id name gender avatar } }`, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } request := endpoint.buildHTTPRequest() if request.Method != "POST" { t.Error("request.Method should've been POST, but was", request.Method) } if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" { t.Error("request.Header.Content-Type should've been application/json, but was", contentType) } body, _ := io.ReadAll(request.Body) if !strings.HasPrefix(string(body), "{\"query\":") { t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body)) } } func TestIntegrationEvaluateHealth(t *testing.T) { condition := Condition("[STATUS] == 200") bodyCondition := Condition("[BODY].status == UP") endpoint := Endpoint{ Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{condition, bodyCondition}, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } result := endpoint.EvaluateHealth() if !result.ConditionResults[0].Success { t.Errorf("Condition '%s' should have been a success", condition) } if !result.Connected { t.Error("Because the connection has been established, result.Connected should've been true") } if !result.Success { t.Error("Because all conditions passed, this should have been a success") } if result.Hostname != "twin.sh" { t.Error("result.Hostname should've been twin.sh, but was", result.Hostname) } } func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) { endpoint := Endpoint{ Name: "invalid-url", URL: "https://httpstat.us/200?sleep=100", Conditions: []Condition{Condition("[STATUS] == 200")}, ClientConfig: &client.Config{ Timeout: 1 * time.Millisecond, }, UIConfig: &ui.Config{ HideURL: true, }, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } result := endpoint.EvaluateHealth() if result.Success { t.Error("Because one of the conditions was invalid, result.Success should have been false") } if len(result.Errors) == 0 { t.Error("There should've been an error") } if !strings.Contains(result.Errors[0], "") || strings.Contains(result.Errors[0], endpoint.URL) { t.Error("result.Errors[0] should've had the URL redacted because ui.hide-url is set to true") } } func TestIntegrationEvaluateHealthForDNS(t *testing.T) { conditionSuccess := Condition("[DNS_RCODE] == NOERROR") conditionBody := Condition("[BODY] == pat(*.*.*.*)") endpoint := Endpoint{ Name: "example", URL: "8.8.8.8", DNSConfig: &dns.Config{ QueryType: "A", QueryName: "example.com.", }, Conditions: []Condition{conditionSuccess, conditionBody}, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } result := endpoint.EvaluateHealth() if !result.ConditionResults[0].Success { t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody) } if !result.Connected { t.Error("Because the connection has been established, result.Connected should've been true") } if !result.Success { t.Error("Because all conditions passed, this should have been a success") } } func TestIntegrationEvaluateHealthForSSH(t *testing.T) { scenarios := []struct { name string endpoint Endpoint conditions []Condition success bool }{ { name: "ssh-success", endpoint: Endpoint{ Name: "ssh-success", URL: "ssh://localhost", SSHConfig: &ssh.Config{ Username: "scenario", Password: "scenario", }, Body: "{ \"command\": \"uptime\" }", }, conditions: []Condition{Condition("[STATUS] == 0")}, success: true, }, { name: "ssh-failure", endpoint: Endpoint{ Name: "ssh-failure", URL: "ssh://localhost", SSHConfig: &ssh.Config{ Username: "scenario", Password: "scenario", }, Body: "{ \"command\": \"uptime\" }", }, conditions: []Condition{Condition("[STATUS] == 1")}, success: false, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { scenario.endpoint.ValidateAndSetDefaults() scenario.endpoint.Conditions = scenario.conditions result := scenario.endpoint.EvaluateHealth() if result.Success != scenario.success { t.Errorf("Expected success to be %v, but was %v", scenario.success, result.Success) } }) } } func TestIntegrationEvaluateHealthForICMP(t *testing.T) { endpoint := Endpoint{ Name: "icmp-test", URL: "icmp://127.0.0.1", Conditions: []Condition{"[CONNECTED] == true"}, } err := endpoint.ValidateAndSetDefaults() if err != nil { t.Fatal("did not expect an error, got", err) } result := endpoint.EvaluateHealth() if !result.ConditionResults[0].Success { t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0]) } if !result.Connected { t.Error("Because the connection has been established, result.Connected should've been true") } if !result.Success { t.Error("Because all conditions passed, this should have been a success") } } func TestEndpoint_DisplayName(t *testing.T) { if endpoint := (Endpoint{Name: "n"}); endpoint.DisplayName() != "n" { t.Error("endpoint.DisplayName() should've been 'n', but was", endpoint.DisplayName()) } if endpoint := (Endpoint{Group: "g", Name: "n"}); endpoint.DisplayName() != "g/n" { t.Error("endpoint.DisplayName() should've been 'g/n', but was", endpoint.DisplayName()) } } func TestEndpoint_getIP(t *testing.T) { endpoint := Endpoint{ Name: "invalid-url-test", URL: "", Conditions: []Condition{"[CONNECTED] == true"}, } result := &Result{} endpoint.getIP(result) if len(result.Errors) == 0 { t.Error("endpoint.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed") } } func TestEndpoint_needsToReadBody(t *testing.T) { statusCondition := Condition("[STATUS] == 200") bodyCondition := Condition("[BODY].status == UP") bodyConditionWithLength := Condition("len([BODY].tags) > 0") if (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() { t.Error("expected false, got true") } if !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() { t.Error("expected true, got false") } if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() { t.Error("expected true, got false") } if !(&Endpoint{Conditions: []Condition{statusCondition, bodyCondition}}).needsToReadBody() { t.Error("expected true, got false") } if !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() { t.Error("expected true, got false") } if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() { t.Error("expected true, got false") } // Test store configuration with body placeholder storeWithBodyPlaceholder := map[string]string{ "token": "[BODY].accessToken", } if !(&Endpoint{ Conditions: []Condition{statusCondition}, Store: storeWithBodyPlaceholder, }).needsToReadBody() { t.Error("expected true when store has body placeholder, got false") } // Test store configuration without body placeholder storeWithoutBodyPlaceholder := map[string]string{ "status": "[STATUS]", } if (&Endpoint{ Conditions: []Condition{statusCondition}, Store: storeWithoutBodyPlaceholder, }).needsToReadBody() { t.Error("expected false when store has no body placeholder, got true") } // Test empty store if (&Endpoint{ Conditions: []Condition{statusCondition}, Store: map[string]string{}, }).needsToReadBody() { t.Error("expected false when store is empty, got true") } // Test nil store if (&Endpoint{ Conditions: []Condition{statusCondition}, Store: nil, }).needsToReadBody() { t.Error("expected false when store is nil, got true") } } func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) { if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveDomainExpiration() { t.Error("expected false, got true") } if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[DOMAIN_EXPIRATION] < 720h"}}).needsToRetrieveDomainExpiration() { t.Error("expected true, got false") } } func TestEndpoint_needsToRetrieveIP(t *testing.T) { if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveIP() { t.Error("expected false, got true") } if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[IP] == 127.0.0.1"}}).needsToRetrieveIP() { t.Error("expected true, got false") } } func TestEndpoint_preprocessWithContext(t *testing.T) { // Import the gontext package for creating test contexts // This test thoroughly exercises the replaceContextPlaceholders function tests := []struct { name string endpoint *Endpoint context map[string]interface{} expectedURL string expectedBody string expectedHeaders map[string]string expectedErrorCount int expectedErrorContains []string }{ { name: "successful_url_replacement", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].userId", Body: "", }, context: map[string]interface{}{ "userId": "12345", }, expectedURL: "https://api.example.com/users/12345", expectedBody: "", expectedErrorCount: 0, }, { name: "successful_body_replacement", endpoint: &Endpoint{ URL: "https://api.example.com", Body: `{"userId": "[CONTEXT].userId", "action": "update"}`, }, context: map[string]interface{}{ "userId": "67890", }, expectedURL: "https://api.example.com", expectedBody: `{"userId": "67890", "action": "update"}`, expectedErrorCount: 0, }, { name: "successful_header_replacement", endpoint: &Endpoint{ URL: "https://api.example.com", Body: "", Headers: map[string]string{ "Authorization": "Bearer [CONTEXT].token", "X-User-ID": "[CONTEXT].userId", }, }, context: map[string]interface{}{ "token": "abc123token", "userId": "user123", }, expectedURL: "https://api.example.com", expectedBody: "", expectedHeaders: map[string]string{ "Authorization": "Bearer abc123token", "X-User-ID": "user123", }, expectedErrorCount: 0, }, { name: "multiple_placeholders_in_url", endpoint: &Endpoint{ URL: "https://[CONTEXT].host/api/v[CONTEXT].version/users/[CONTEXT].userId", Body: "", }, context: map[string]interface{}{ "host": "api.example.com", "version": "2", "userId": "12345", }, expectedURL: "https://api.example.com/api/v2/users/12345", expectedBody: "", expectedErrorCount: 0, }, { name: "nested_context_path", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].user.id", Body: `{"name": "[CONTEXT].user.name"}`, }, context: map[string]interface{}{ "user": map[string]interface{}{ "id": "nested123", "name": "John Doe", }, }, expectedURL: "https://api.example.com/users/nested123", expectedBody: `{"name": "John Doe"}`, expectedErrorCount: 0, }, { name: "url_context_not_found", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].missingUserId", Body: "", }, context: map[string]interface{}{ "userId": "12345", // different key }, expectedURL: "https://api.example.com/users/[CONTEXT].missingUserId", expectedBody: "", expectedErrorCount: 1, expectedErrorContains: []string{"path 'missingUserId' not found"}, }, { name: "body_context_not_found", endpoint: &Endpoint{ URL: "https://api.example.com", Body: `{"userId": "[CONTEXT].missingUserId"}`, }, context: map[string]interface{}{ "userId": "12345", // different key }, expectedURL: "https://api.example.com", expectedBody: `{"userId": "[CONTEXT].missingUserId"}`, expectedErrorCount: 1, expectedErrorContains: []string{"path 'missingUserId' not found"}, }, { name: "header_context_not_found", endpoint: &Endpoint{ URL: "https://api.example.com", Body: "", Headers: map[string]string{ "Authorization": "Bearer [CONTEXT].missingToken", }, }, context: map[string]interface{}{ "token": "validtoken", // different key }, expectedURL: "https://api.example.com", expectedBody: "", expectedHeaders: map[string]string{ "Authorization": "Bearer [CONTEXT].missingToken", }, expectedErrorCount: 1, expectedErrorContains: []string{"path 'missingToken' not found"}, }, { name: "multiple_missing_context_paths", endpoint: &Endpoint{ URL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId", Body: `{"token": "[CONTEXT].missingToken"}`, }, context: map[string]interface{}{ "validKey": "validValue", }, expectedURL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId", expectedBody: `{"token": "[CONTEXT].missingToken"}`, expectedErrorCount: 2, // 1 for URL (both placeholders), 1 for Body expectedErrorContains: []string{ "path 'missingHost' not found", "path 'missingUserId' not found", "path 'missingToken' not found", }, }, { name: "mixed_valid_and_invalid_placeholders", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].userId/posts/[CONTEXT].missingPostId", Body: `{"userId": "[CONTEXT].userId", "action": "[CONTEXT].missingAction"}`, }, context: map[string]interface{}{ "userId": "12345", }, expectedURL: "https://api.example.com/users/12345/posts/[CONTEXT].missingPostId", expectedBody: `{"userId": "12345", "action": "[CONTEXT].missingAction"}`, expectedErrorCount: 2, expectedErrorContains: []string{ "path 'missingPostId' not found", "path 'missingAction' not found", }, }, { name: "nil_context", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].userId", Body: "", }, context: nil, expectedURL: "https://api.example.com/users/[CONTEXT].userId", expectedBody: "", expectedErrorCount: 0, }, { name: "empty_context", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].userId", Body: "", }, context: map[string]interface{}{}, expectedURL: "https://api.example.com/users/[CONTEXT].userId", expectedBody: "", expectedErrorCount: 1, expectedErrorContains: []string{"path 'userId' not found"}, }, { name: "special_characters_in_context_values", endpoint: &Endpoint{ URL: "https://api.example.com/search?q=[CONTEXT].query", Body: "", }, context: map[string]interface{}{ "query": "hello world & special chars!", }, expectedURL: "https://api.example.com/search?q=hello world & special chars!", expectedBody: "", expectedErrorCount: 0, }, { name: "numeric_context_values", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].userId/limit/[CONTEXT].limit", Body: "", }, context: map[string]interface{}{ "userId": 12345, "limit": 100, }, expectedURL: "https://api.example.com/users/12345/limit/100", expectedBody: "", expectedErrorCount: 0, }, { name: "boolean_context_values", endpoint: &Endpoint{ URL: "https://api.example.com", Body: `{"enabled": [CONTEXT].enabled, "active": [CONTEXT].active}`, }, context: map[string]interface{}{ "enabled": true, "active": false, }, expectedURL: "https://api.example.com", expectedBody: `{"enabled": true, "active": false}`, expectedErrorCount: 0, }, { name: "no_context_placeholders", endpoint: &Endpoint{ URL: "https://api.example.com/health", Body: `{"status": "check"}`, Headers: map[string]string{ "Content-Type": "application/json", }, }, context: map[string]interface{}{ "userId": "12345", }, expectedURL: "https://api.example.com/health", expectedBody: `{"status": "check"}`, expectedHeaders: map[string]string{ "Content-Type": "application/json", }, expectedErrorCount: 0, }, { name: "deeply_nested_context_path", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].response.data.user.id", Body: "", }, context: map[string]interface{}{ "response": map[string]interface{}{ "data": map[string]interface{}{ "user": map[string]interface{}{ "id": "deep123", }, }, }, }, expectedURL: "https://api.example.com/users/deep123", expectedBody: "", expectedErrorCount: 0, }, { name: "invalid_nested_context_path", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].response.missing.path", Body: "", }, context: map[string]interface{}{ "response": map[string]interface{}{ "data": "value", }, }, expectedURL: "https://api.example.com/users/[CONTEXT].response.missing.path", expectedBody: "", expectedErrorCount: 1, expectedErrorContains: []string{"path 'response.missing.path' not found"}, }, { name: "hyphen_support_in_simple_keys", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].user-id", Body: `{"api-key": "[CONTEXT].api-key", "user-name": "[CONTEXT].user-name"}`, }, context: map[string]interface{}{ "user-id": "user-12345", "api-key": "key-abcdef", "user-name": "john-doe", }, expectedURL: "https://api.example.com/users/user-12345", expectedBody: `{"api-key": "key-abcdef", "user-name": "john-doe"}`, expectedErrorCount: 0, }, { name: "hyphen_support_in_headers", endpoint: &Endpoint{ URL: "https://api.example.com", Body: "", Headers: map[string]string{ "X-API-Key": "[CONTEXT].api-key", "X-User-ID": "[CONTEXT].user-id", "Content-Type": "[CONTEXT].content-type", }, }, context: map[string]interface{}{ "api-key": "secret-key-123", "user-id": "user-456", "content-type": "application-json", }, expectedURL: "https://api.example.com", expectedBody: "", expectedHeaders: map[string]string{ "X-API-Key": "secret-key-123", "X-User-ID": "user-456", "Content-Type": "application-json", }, expectedErrorCount: 0, }, { name: "mixed_hyphens_underscores_and_dots", endpoint: &Endpoint{ URL: "https://api.example.com/[CONTEXT].service-name/[CONTEXT].user_data.user-id", Body: `{"tenant-id": "[CONTEXT].tenant_config.tenant-id"}`, }, context: map[string]interface{}{ "service-name": "auth-service", "user_data": map[string]interface{}{ "user-id": "user-789", }, "tenant_config": map[string]interface{}{ "tenant-id": "tenant-abc-123", }, }, expectedURL: "https://api.example.com/auth-service/user-789", expectedBody: `{"tenant-id": "tenant-abc-123"}`, expectedErrorCount: 0, }, { name: "hyphen_in_nested_paths", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].auth-response.user-data.profile-id", Body: "", }, context: map[string]interface{}{ "auth-response": map[string]interface{}{ "user-data": map[string]interface{}{ "profile-id": "profile-xyz-789", }, }, }, expectedURL: "https://api.example.com/users/profile-xyz-789", expectedBody: "", expectedErrorCount: 0, }, { name: "missing_hyphenated_context_key", endpoint: &Endpoint{ URL: "https://api.example.com/users/[CONTEXT].missing-user-id", Body: `{"api-key": "[CONTEXT].missing-api-key"}`, }, context: map[string]interface{}{ "user-id": "valid-user", // different key }, expectedURL: "https://api.example.com/users/[CONTEXT].missing-user-id", expectedBody: `{"api-key": "[CONTEXT].missing-api-key"}`, expectedErrorCount: 2, expectedErrorContains: []string{"path 'missing-user-id' not found", "path 'missing-api-key' not found"}, }, { name: "multiple_hyphens_in_single_key", endpoint: &Endpoint{ URL: "https://api.example.com/[CONTEXT].multi-hyphen-key-name", Body: "", }, context: map[string]interface{}{ "multi-hyphen-key-name": "value-with-multiple-hyphens", }, expectedURL: "https://api.example.com/value-with-multiple-hyphens", expectedBody: "", expectedErrorCount: 0, }, { name: "hyphens_with_numeric_values", endpoint: &Endpoint{ URL: "https://api.example.com/limit/[CONTEXT].max-items", Body: `{"timeout-ms": [CONTEXT].timeout-ms, "retry-count": [CONTEXT].retry-count}`, }, context: map[string]interface{}{ "max-items": 100, "timeout-ms": 5000, "retry-count": 3, }, expectedURL: "https://api.example.com/limit/100", expectedBody: `{"timeout-ms": 5000, "retry-count": 3}`, expectedErrorCount: 0, }, { name: "hyphens_with_boolean_values", endpoint: &Endpoint{ URL: "https://api.example.com", Body: `{"enable-feature": [CONTEXT].enable-feature, "disable-cache": [CONTEXT].disable-cache}`, }, context: map[string]interface{}{ "enable-feature": true, "disable-cache": false, }, expectedURL: "https://api.example.com", expectedBody: `{"enable-feature": true, "disable-cache": false}`, expectedErrorCount: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Import gontext package for creating context var ctx *gontext.Gontext if tt.context != nil { ctx = gontext.New(tt.context) } // Create a new Result to capture errors result := &Result{} // Call preprocessWithContext processed := tt.endpoint.preprocessWithContext(result, ctx) // Verify URL if processed.URL != tt.expectedURL { t.Errorf("URL mismatch:\nexpected: %s\nactual: %s", tt.expectedURL, processed.URL) } // Verify Body if processed.Body != tt.expectedBody { t.Errorf("Body mismatch:\nexpected: %s\nactual: %s", tt.expectedBody, processed.Body) } // Verify Headers if tt.expectedHeaders != nil { if processed.Headers == nil { t.Error("Expected headers but got nil") } else { for key, expectedValue := range tt.expectedHeaders { if actualValue, exists := processed.Headers[key]; !exists { t.Errorf("Expected header %s not found", key) } else if actualValue != expectedValue { t.Errorf("Header %s mismatch:\nexpected: %s\nactual: %s", key, expectedValue, actualValue) } } } } // Verify error count if len(result.Errors) != tt.expectedErrorCount { t.Errorf("Error count mismatch:\nexpected: %d\nactual: %d\nerrors: %v", tt.expectedErrorCount, len(result.Errors), result.Errors) } // Verify error messages contain expected strings if tt.expectedErrorContains != nil { actualErrors := strings.Join(result.Errors, " ") for _, expectedError := range tt.expectedErrorContains { if !strings.Contains(actualErrors, expectedError) { t.Errorf("Expected error containing '%s' not found in: %v", expectedError, result.Errors) } } } // Verify original endpoint is not modified if tt.endpoint.URL != ((&Endpoint{URL: tt.endpoint.URL, Body: tt.endpoint.Body, Headers: tt.endpoint.Headers}).URL) { t.Error("Original endpoint was modified") } }) } } func TestEndpoint_HideUIFeatures(t *testing.T) { defer client.InjectHTTPClient(nil) tests := []struct { name string endpoint Endpoint mockResponse test.MockRoundTripper checkHostname bool expectHostname string checkErrors bool expectErrors bool checkConditions bool expectConditions bool checkErrorContent string }{ { name: "hide-conditions", endpoint: Endpoint{ Name: "test-endpoint", URL: "https://example.com/health", Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"}, UIConfig: &ui.Config{HideConditions: true}, }, mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`))} }), checkConditions: true, expectConditions: false, }, { name: "hide-hostname", endpoint: Endpoint{ Name: "test-endpoint", URL: "https://example.com/health", Conditions: []Condition{"[STATUS] == 200"}, UIConfig: &ui.Config{HideHostname: true}, }, mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} }), checkHostname: true, expectHostname: "", }, { name: "hide-url-in-errors", endpoint: Endpoint{ Name: "test-endpoint", URL: "https://example.com/health", Conditions: []Condition{"[CONNECTED] == true"}, UIConfig: &ui.Config{HideURL: true}, ClientConfig: &client.Config{Timeout: time.Millisecond}, }, mockResponse: nil, checkErrors: true, expectErrors: true, checkErrorContent: "", }, { name: "hide-port-in-errors", endpoint: Endpoint{ Name: "test-endpoint", URL: "https://example.com:9999/health", Conditions: []Condition{"[CONNECTED] == true"}, UIConfig: &ui.Config{HidePort: true}, ClientConfig: &client.Config{Timeout: time.Millisecond}, }, mockResponse: nil, checkErrors: true, expectErrors: true, checkErrorContent: "", }, { name: "hide-errors", endpoint: Endpoint{ Name: "test-endpoint", URL: "https://example.com/health", Conditions: []Condition{"[CONNECTED] == true"}, UIConfig: &ui.Config{HideErrors: true}, ClientConfig: &client.Config{Timeout: time.Millisecond}, }, mockResponse: nil, checkErrors: true, expectErrors: false, }, { name: "dont-resolve-failed-conditions", endpoint: Endpoint{ Name: "test-endpoint", URL: "https://example.com/health", Conditions: []Condition{"[STATUS] == 200"}, UIConfig: &ui.Config{DontResolveFailedConditions: true}, }, mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody} }), checkConditions: true, expectConditions: true, }, { name: "multiple-hide-features", endpoint: Endpoint{ Name: "test-endpoint", URL: "https://example.com/health", Conditions: []Condition{"[STATUS] == 200"}, UIConfig: &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true}, }, mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response { return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} }), checkConditions: true, expectConditions: false, checkHostname: true, expectHostname: "", checkErrors: true, expectErrors: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.mockResponse != nil { mockClient := &http.Client{Transport: tt.mockResponse} if tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 { mockClient.Timeout = tt.endpoint.ClientConfig.Timeout } client.InjectHTTPClient(mockClient) } else { client.InjectHTTPClient(nil) } err := tt.endpoint.ValidateAndSetDefaults() if err != nil { t.Fatalf("ValidateAndSetDefaults failed: %v", err) } result := tt.endpoint.EvaluateHealth() if tt.checkHostname { if result.Hostname != tt.expectHostname { t.Errorf("Expected hostname '%s', got '%s'", tt.expectHostname, result.Hostname) } } if tt.checkErrors { hasErrors := len(result.Errors) > 0 if hasErrors != tt.expectErrors { t.Errorf("Expected errors=%v, got errors=%v (actual errors: %v)", tt.expectErrors, hasErrors, result.Errors) } if tt.checkErrorContent != "" && len(result.Errors) > 0 { found := false for _, err := range result.Errors { if strings.Contains(err, tt.checkErrorContent) { found = true break } } if !found { t.Errorf("Expected error to contain '%s', but got: %v", tt.checkErrorContent, result.Errors) } } } if tt.checkConditions { hasConditions := len(result.ConditionResults) > 0 if hasConditions != tt.expectConditions { t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults) } } }) } }