1616 lines
51 KiB
Go
1616 lines
51 KiB
Go
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 <redacted>.
|
|
Errors: []string{`Get "https://<redacted>:<redacted>/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 <redacted>.
|
|
Errors: []string{`Get "<redacted>": 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
|
|
expectedErr error
|
|
}{
|
|
{
|
|
name: "fail when has no user",
|
|
username: "",
|
|
password: "password",
|
|
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
|
},
|
|
{
|
|
name: "fail when has no password",
|
|
username: "username",
|
|
password: "",
|
|
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
|
|
},
|
|
{
|
|
name: "success when all fields are set",
|
|
username: "username",
|
|
password: "password",
|
|
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,
|
|
},
|
|
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], "<redacted>") || 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: "<redacted>",
|
|
},
|
|
{
|
|
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: "<redacted>",
|
|
},
|
|
{
|
|
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 := result.ConditionResults != nil && len(result.ConditionResults) > 0
|
|
if hasConditions != tt.expectConditions {
|
|
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|