package config import ( "errors" "fmt" "os" "path/filepath" "testing" "time" "github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider/awsses" "github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/gitea" "github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/gotify" "github.com/TwiN/gatus/v5/alerting/provider/homeassistant" "github.com/TwiN/gatus/v5/alerting/provider/ifttt" "github.com/TwiN/gatus/v5/alerting/provider/ilert" "github.com/TwiN/gatus/v5/alerting/provider/incidentio" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/line" "github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/messagebird" "github.com/TwiN/gatus/v5/alerting/provider/newrelic" "github.com/TwiN/gatus/v5/alerting/provider/ntfy" "github.com/TwiN/gatus/v5/alerting/provider/opsgenie" "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/rocketchat" "github.com/TwiN/gatus/v5/alerting/provider/sendgrid" "github.com/TwiN/gatus/v5/alerting/provider/signal" "github.com/TwiN/gatus/v5/alerting/provider/signl4" "github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/splunk" "github.com/TwiN/gatus/v5/alerting/provider/squadcast" "github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/vonage" "github.com/TwiN/gatus/v5/alerting/provider/webex" "github.com/TwiN/gatus/v5/alerting/provider/zapier" "github.com/TwiN/gatus/v5/alerting/provider/zulip" "github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/suite" "github.com/TwiN/gatus/v5/config/tunneling" "github.com/TwiN/gatus/v5/config/tunneling/sshtunnel" "github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/storage" "gopkg.in/yaml.v3" ) func TestLoadConfiguration(t *testing.T) { yes := true dir := t.TempDir() scenarios := []struct { name string configPath string // value to pass as the configPath parameter in LoadConfiguration pathAndFiles map[string]string // files to create in dir expectedConfig *Config expectedError error }{ { name: "empty-config-file", configPath: filepath.Join(dir, "config.yaml"), pathAndFiles: map[string]string{ "config.yaml": "", }, expectedError: ErrConfigFileNotFound, }, { name: "config-file-that-does-not-exist", configPath: filepath.Join(dir, "config.yaml"), expectedError: ErrConfigFileNotFound, }, { name: "config-file-with-endpoint-that-has-no-url", configPath: filepath.Join(dir, "config.yaml"), pathAndFiles: map[string]string{ "config.yaml": ` endpoints: - name: website`, }, expectedError: endpoint.ErrEndpointWithNoURL, }, { name: "config-file-with-endpoint-that-has-no-conditions", configPath: filepath.Join(dir, "config.yaml"), pathAndFiles: map[string]string{ "config.yaml": ` endpoints: - name: website url: https://twin.sh/health`, }, expectedError: endpoint.ErrEndpointWithNoCondition, }, { name: "config-file", configPath: filepath.Join(dir, "config.yaml"), pathAndFiles: map[string]string{ "config.yaml": ` endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200"`, }, expectedConfig: &Config{ Endpoints: []*endpoint.Endpoint{ { Name: "website", URL: "https://twin.sh/health", Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, }, { name: "empty-dir", configPath: dir, pathAndFiles: map[string]string{}, expectedError: ErrConfigFileNotFound, }, { name: "dir-with-empty-config-file", configPath: dir, pathAndFiles: map[string]string{ "config.yaml": "", }, expectedError: ErrNoEndpointOrSuiteInConfig, }, { name: "dir-with-two-config-files", configPath: dir, pathAndFiles: map[string]string{ "config.yaml": `endpoints: - name: one url: https://example.com conditions: - "[CONNECTED] == true" - "[STATUS] == 200" - name: two url: https://example.org conditions: - "len([BODY]) > 0"`, "config.yml": `endpoints: - name: three url: https://twin.sh/health conditions: - "[STATUS] == 200" - "[BODY].status == UP"`, }, expectedConfig: &Config{ Endpoints: []*endpoint.Endpoint{ { Name: "one", URL: "https://example.com", Conditions: []endpoint.Condition{"[CONNECTED] == true", "[STATUS] == 200"}, }, { Name: "two", URL: "https://example.org", Conditions: []endpoint.Condition{"len([BODY]) > 0"}, }, { Name: "three", URL: "https://twin.sh/health", Conditions: []endpoint.Condition{"[STATUS] == 200", "[BODY].status == UP"}, }, }, }, }, { name: "dir-with-2-config-files-deep-merge-with-map-slice-and-primitive", configPath: dir, pathAndFiles: map[string]string{ "a.yaml": ` metrics: true alerting: slack: webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz default-alert: enabled: true endpoints: - name: example url: https://example.org interval: 5s conditions: - "[STATUS] == 200"`, "b.yaml": ` alerting: discord: webhook-url: https://discord.com/api/webhooks/xxx/yyy external-endpoints: - name: ext-ep-test token: "potato" alerts: - type: slack endpoints: - name: frontend url: https://example.com conditions: - "[STATUS] == 200"`, }, expectedConfig: &Config{ Metrics: true, Alerting: &alerting.Config{ Discord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}}, Slack: &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, DefaultAlert: &alert.Alert{Enabled: &yes}}, }, ExternalEndpoints: []*endpoint.ExternalEndpoint{ { Name: "ext-ep-test", Token: "potato", Alerts: []*alert.Alert{ { Type: alert.TypeSlack, FailureThreshold: 3, SuccessThreshold: 2, }, }, }, }, Endpoints: []*endpoint.Endpoint{ { Name: "example", URL: "https://example.org", Interval: 5 * time.Second, Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, { Name: "frontend", URL: "https://example.com", Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { for path, content := range scenario.pathAndFiles { if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil { t.Fatalf("[%s] failed to write file: %v", scenario.name, err) } } defer func(pathAndFiles map[string]string) { for path := range pathAndFiles { _ = os.Remove(filepath.Join(dir, path)) } }(scenario.pathAndFiles) config, err := LoadConfiguration(scenario.configPath) if !errors.Is(err, scenario.expectedError) { t.Errorf("[%s] expected error %v, got %v", scenario.name, scenario.expectedError, err) return } else if err != nil && errors.Is(err, scenario.expectedError) { return } // parse the expected output so that expectations are closer to reality (under the right circumstances, even I can be poetic) expectedConfigAsYAML, _ := yaml.Marshal(scenario.expectedConfig) expectedConfigAfterBeingParsedAndValidated, err := parseAndValidateConfigBytes(expectedConfigAsYAML) if err != nil { t.Fatalf("[%s] failed to parse expected config: %v", scenario.name, err) } // Marshal em' before comparing em' so that we don't have to deal with formatting and ordering actualConfigAsYAML, err := yaml.Marshal(config) if err != nil { t.Fatalf("[%s] failed to marshal actual config: %v", scenario.name, err) } expectedConfigAfterBeingParsedAndValidatedAsYAML, _ := yaml.Marshal(expectedConfigAfterBeingParsedAndValidated) if string(actualConfigAsYAML) != string(expectedConfigAfterBeingParsedAndValidatedAsYAML) { t.Errorf("[%s] expected config %s, got %s", scenario.name, string(expectedConfigAfterBeingParsedAndValidatedAsYAML), string(actualConfigAsYAML)) } }) } } func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) { t.Parallel() dir := t.TempDir() configFilePath := filepath.Join(dir, "config.yaml") _ = os.WriteFile(configFilePath, []byte(`endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `), 0o644) t.Run("config-file-as-config-path", func(t *testing.T) { config, err := LoadConfiguration(configFilePath) if err != nil { t.Fatalf("failed to load configuration: %v", err) } if config.HasLoadedConfigurationBeenModified() { t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created") } time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second // Update the config file if err = os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(`endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200"`), 0o644); err != nil { t.Fatalf("failed to overwrite config file: %v", err) } if !config.HasLoadedConfigurationBeenModified() { t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory") } }) t.Run("config-directory-as-config-path", func(t *testing.T) { config, err := LoadConfiguration(dir) if err != nil { t.Fatalf("failed to load configuration: %v", err) } if config.HasLoadedConfigurationBeenModified() { t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created") } time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second // Update the config file if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil { t.Fatalf("failed to overwrite config file: %v", err) } if !config.HasLoadedConfigurationBeenModified() { t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory") } }) } func TestParseAndValidateConfigBytes(t *testing.T) { file := t.TempDir() + "/test.db" config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` storage: type: sqlite path: %s maximum-number-of-results: 10 maximum-number-of-events: 5 maintenance: enabled: true start: 00:00 duration: 4h every: [Monday, Thursday] ui: title: T header: H link: https://example.org buttons: - name: "Home" link: "https://example.org" - name: "Status page" link: "https://status.example.org" external-endpoints: - name: ext-ep-test group: core token: "potato" endpoints: - name: website url: https://twin.sh/health interval: 15s conditions: - "[STATUS] == 200" - name: github url: https://api.github.com/healthz client: insecure: true ignore-redirect: true timeout: 5s conditions: - "[STATUS] != 400" - "[STATUS] != 500" - name: example url: https://example.com/ interval: 30m client: insecure: true conditions: - "[STATUS] == 200" `, file))) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite { t.Error("expected storage to be set to sqlite, got", config.Storage) } if config.Storage == nil || config.Storage.MaximumNumberOfResults != 10 || config.Storage.MaximumNumberOfEvents != 5 { t.Error("expected MaximumNumberOfResults and MaximumNumberOfEvents to be set to 10 and 5, got", config.Storage.MaximumNumberOfResults, config.Storage.MaximumNumberOfEvents) } if config.UI == nil || config.UI.Title != "T" || config.UI.Header != "H" || config.UI.Link != "https://example.org" || len(config.UI.Buttons) != 2 || config.UI.Buttons[0].Name != "Home" || config.UI.Buttons[0].Link != "https://example.org" || config.UI.Buttons[1].Name != "Status page" || config.UI.Buttons[1].Link != "https://status.example.org" { t.Error("expected ui to be set to T, H, https://example.org, 2 buttons, Home and Status page, got", config.UI) } if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 { t.Error("Expected Config.Maintenance to be configured properly") } if len(config.ExternalEndpoints) != 1 { t.Error("Should have returned one external endpoint") } if config.ExternalEndpoints[0].Name != "ext-ep-test" { t.Errorf("Name should have been %s", "ext-ep-test") } if config.ExternalEndpoints[0].Group != "core" { t.Errorf("Group should have been %s", "core") } if config.ExternalEndpoints[0].Token != "potato" { t.Errorf("Token should have been %s", "potato") } if len(config.Endpoints) != 3 { t.Error("Should have returned two endpoints") } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Method != "GET" { t.Errorf("Method should have been %s (default)", "GET") } if config.Endpoints[0].Interval != 15*time.Second { t.Errorf("Interval should have been %s", 15*time.Second) } if config.Endpoints[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure { t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Endpoints[0].ClientConfig.Insecure) } if config.Endpoints[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect { t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Endpoints[0].ClientConfig.IgnoreRedirect) } if config.Endpoints[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout { t.Errorf("ClientConfig.Timeout should have been %v, got %v", client.GetDefaultConfig().Timeout, config.Endpoints[0].ClientConfig.Timeout) } if len(config.Endpoints[0].Conditions) != 1 { t.Errorf("There should have been %d conditions", 1) } if config.Endpoints[1].URL != "https://api.github.com/healthz" { t.Errorf("URL should have been %s", "https://api.github.com/healthz") } if config.Endpoints[1].Method != "GET" { t.Errorf("Method should have been %s (default)", "GET") } if config.Endpoints[1].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if !config.Endpoints[1].ClientConfig.Insecure { t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Endpoints[1].ClientConfig.Insecure) } if !config.Endpoints[1].ClientConfig.IgnoreRedirect { t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Endpoints[1].ClientConfig.IgnoreRedirect) } if config.Endpoints[1].ClientConfig.Timeout != 5*time.Second { t.Errorf("ClientConfig.Timeout should have been %v, got %v", 5*time.Second, config.Endpoints[1].ClientConfig.Timeout) } if len(config.Endpoints[1].Conditions) != 2 { t.Errorf("There should have been %d conditions", 2) } if config.Endpoints[2].URL != "https://example.com/" { t.Errorf("URL should have been %s", "https://example.com/") } if config.Endpoints[2].Method != "GET" { t.Errorf("Method should have been %s (default)", "GET") } if config.Endpoints[2].Interval != 30*time.Minute { t.Errorf("Interval should have been %s, because it is the default value", 30*time.Minute) } if !config.Endpoints[2].ClientConfig.Insecure { t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Endpoints[2].ClientConfig.Insecure) } if config.Endpoints[2].ClientConfig.IgnoreRedirect { t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", false, config.Endpoints[2].ClientConfig.IgnoreRedirect) } if config.Endpoints[2].ClientConfig.Timeout != 10*time.Second { t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", 10*time.Second, config.Endpoints[2].ClientConfig.Timeout) } if len(config.Endpoints[2].Conditions) != 1 { t.Errorf("There should have been %d conditions", 1) } } func TestParseAndValidateConfigBytesDefault(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("DefaultConfig shouldn't have been nil") } if config.Metrics { t.Error("Metrics should've been false by default") } if config.Web.Address != web.DefaultAddress { t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress) } if config.Web.Port != web.DefaultPort { t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort) } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if config.Endpoints[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure { t.Errorf("ClientConfig.Insecure should have been %v by default, got %v", true, config.Endpoints[0].ClientConfig.Insecure) } if config.Endpoints[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect { t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", true, config.Endpoints[0].ClientConfig.IgnoreRedirect) } if config.Endpoints[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout { t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", client.GetDefaultConfig().Timeout, config.Endpoints[0].ClientConfig.Timeout) } } func TestParseAndValidateConfigBytesWithAddress(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` web: address: 127.0.0.1 endpoints: - name: website url: https://twin.sh/actuator/health conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Metrics { t.Error("Metrics should've been false by default") } if config.Endpoints[0].URL != "https://twin.sh/actuator/health" { t.Errorf("URL should have been %s", "https://twin.sh/actuator/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if config.Web.Address != "127.0.0.1" { t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1") } if config.Web.Port != web.DefaultPort { t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort) } } func TestParseAndValidateConfigBytesWithPort(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` web: port: 12345 endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Metrics { t.Error("Metrics should've been false by default") } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if config.Web.Address != web.DefaultAddress { t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress) } if config.Web.Port != 12345 { t.Errorf("Port should have been %d, because it is specified in config", 12345) } } func TestParseAndValidateConfigBytesWithPortAndHost(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` web: port: 12345 address: 127.0.0.1 endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Metrics { t.Error("Metrics should've been false by default") } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if config.Web.Address != "127.0.0.1" { t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1") } if config.Web.Port != 12345 { t.Errorf("Port should have been %d, because it is specified in config", 12345) } } func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(` web: port: 65536 address: 127.0.0.1 endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `)) if err == nil { t.Fatal("Should've returned an error because the configuration specifies an invalid port value") } } func TestParseAndValidateConfigBytesWithMetricsAndCustomUserAgentHeader(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` metrics: true endpoints: - name: website url: https://twin.sh/health headers: User-Agent: Test/2.0 conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if !config.Metrics { t.Error("Metrics should have been true") } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if config.Web.Address != web.DefaultAddress { t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress) } if config.Web.Port != web.DefaultPort { t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort) } if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != "Test/2.0" { t.Errorf("User-Agent should've been %s, got %s", "Test/2.0", userAgent) } } func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` metrics: true web: address: 192.168.0.1 port: 9090 endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if !config.Metrics { t.Error("Metrics should have been true") } if config.Web.Address != "192.168.0.1" { t.Errorf("Bind address should have been %s, because it is the default value", "192.168.0.1") } if config.Web.Port != 9090 { t.Errorf("Port should have been %d, because it is specified in config", 9090) } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != endpoint.GatusUserAgent { t.Errorf("User-Agent should've been %s because it's the default value, got %s", endpoint.GatusUserAgent, userAgent) } } func TestParseAndValidateBadConfigBytes(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(` badconfig: - asdsa: w0w usadasdrl: asdxzczxc asdas: - soup `)) if err == nil { t.Error("An error should've been returned") } if err != ErrNoEndpointOrSuiteInConfig { t.Error("The error returned should have been of type ErrNoEndpointOrSuiteInConfig") } } func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: slack: webhook-url: "http://example.com" discord: webhook-url: "http://example.org" pagerduty: integration-key: "00000000000000000000000000000000" pushover: application-token: "000000000000000000000000000000" user-key: "000000000000000000000000000000" mattermost: webhook-url: "http://example.com" client: insecure: true messagebird: access-key: "1" originator: "31619191918" recipients: "31619191919" telegram: token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 id: 0123456789 twilio: sid: "1234" token: "5678" from: "+1-234-567-8901" to: "+1-234-567-8901" teams: webhook-url: "http://example.com" jetbrainsspace: project: "foo" channel-id: "bar" token: "baz" endpoints: - name: website url: https://twin.sh/health alerts: - type: slack - type: pagerduty failure-threshold: 7 success-threshold: 5 description: "Healthcheck failed 7 times in a row" - type: mattermost - type: messagebird enabled: false - type: discord failure-threshold: 10 - type: telegram enabled: true - type: twilio failure-threshold: 12 success-threshold: 15 - type: teams - type: pushover - type: jetbrainsspace conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } // Alerting providers if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil { t.Fatal("Slack alerting config should've been valid") } // Endpoints if len(config.Endpoints) != 1 { t.Error("There should've been 1 endpoint") } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if len(config.Endpoints[0].Alerts) != 10 { t.Fatal("There should've been 10 alerts configured") } if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type) } if !config.Endpoints[0].Alerts[0].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[0].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[0].FailureThreshold) } if config.Endpoints[0].Alerts[0].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[0].SuccessThreshold) } if config.Endpoints[0].Alerts[1].Type != alert.TypePagerDuty { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePagerDuty, config.Endpoints[0].Alerts[1].Type) } if config.Endpoints[0].Alerts[1].GetDescription() != "Healthcheck failed 7 times in a row" { t.Errorf("The description of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Endpoints[0].Alerts[1].GetDescription()) } if config.Endpoints[0].Alerts[1].FailureThreshold != 7 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 7, config.Endpoints[0].Alerts[1].FailureThreshold) } if config.Endpoints[0].Alerts[1].SuccessThreshold != 5 { t.Errorf("The success threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[1].SuccessThreshold) } if config.Endpoints[0].Alerts[2].Type != alert.TypeMattermost { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMattermost, config.Endpoints[0].Alerts[2].Type) } if !config.Endpoints[0].Alerts[2].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[2].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[2].FailureThreshold) } if config.Endpoints[0].Alerts[2].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[2].SuccessThreshold) } if config.Endpoints[0].Alerts[3].Type != alert.TypeMessagebird { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMessagebird, config.Endpoints[0].Alerts[3].Type) } if config.Endpoints[0].Alerts[3].IsEnabled() { t.Error("The alert should've been disabled") } if config.Endpoints[0].Alerts[4].Type != alert.TypeDiscord { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.Endpoints[0].Alerts[4].Type) } if !config.Endpoints[0].Alerts[4].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[4].FailureThreshold != 10 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold) } if config.Endpoints[0].Alerts[4].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[4].SuccessThreshold) } if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTelegram, config.Endpoints[0].Alerts[5].Type) } if !config.Endpoints[0].Alerts[5].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[5].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[5].FailureThreshold) } if config.Endpoints[0].Alerts[5].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[5].SuccessThreshold) } if config.Endpoints[0].Alerts[6].Type != alert.TypeTwilio { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTwilio, config.Endpoints[0].Alerts[6].Type) } if !config.Endpoints[0].Alerts[6].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[6].FailureThreshold != 12 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 12, config.Endpoints[0].Alerts[6].FailureThreshold) } if config.Endpoints[0].Alerts[6].SuccessThreshold != 15 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Endpoints[0].Alerts[6].SuccessThreshold) } if config.Endpoints[0].Alerts[7].Type != alert.TypeTeams { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Endpoints[0].Alerts[7].Type) } if !config.Endpoints[0].Alerts[7].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[7].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[7].FailureThreshold) } if config.Endpoints[0].Alerts[7].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold) } if config.Endpoints[0].Alerts[8].Type != alert.TypePushover { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type) } if !config.Endpoints[0].Alerts[8].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type) } if !config.Endpoints[0].Alerts[9].IsEnabled() { t.Error("The alert should've been enabled") } } func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: slack: webhook-url: "http://example.com" default-alert: enabled: true discord: webhook-url: "http://example.org" default-alert: enabled: true failure-threshold: 10 success-threshold: 15 pagerduty: integration-key: "00000000000000000000000000000000" default-alert: enabled: true description: default description failure-threshold: 7 success-threshold: 5 pushover: application-token: "000000000000000000000000000000" user-key: "000000000000000000000000000000" default-alert: enabled: true description: default description failure-threshold: 5 success-threshold: 3 mattermost: webhook-url: "http://example.com" default-alert: enabled: true messagebird: access-key: "1" originator: "31619191918" recipients: "31619191919" default-alert: enabled: false send-on-resolved: true telegram: token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 id: 0123456789 default-alert: enabled: true twilio: sid: "1234" token: "5678" from: "+1-234-567-8901" to: "+1-234-567-8901" default-alert: enabled: true failure-threshold: 12 success-threshold: 15 teams: webhook-url: "http://example.com" default-alert: enabled: true jetbrainsspace: project: "foo" channel-id: "bar" token: "baz" default-alert: enabled: true failure-threshold: 5 success-threshold: 3 email: from: "from@example.com" username: "from@example.com" password: "hunter2" host: "mail.example.com" port: 587 to: "recipient1@example.com,recipient2@example.com" client: insecure: false default-alert: enabled: true gotify: server-url: "https://gotify.example" token: "**************" default-alert: enabled: true external-endpoints: - name: ext-ep-test group: core token: potato alerts: - type: discord endpoints: - name: website url: https://twin.sh/health alerts: - type: slack - type: pagerduty - type: mattermost - type: messagebird - type: discord success-threshold: 8 # test endpoint alert override - type: telegram - type: twilio - type: teams - type: pushover - type: jetbrainsspace - type: email - type: gotify conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Metrics { t.Error("Metrics should've been false by default") } // Alerting providers if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil { t.Fatal("Slack alerting config should've been valid") } if config.Alerting.Slack.GetDefaultAlert() == nil { t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Slack.DefaultConfig.WebhookURL != "http://example.com" { t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.DefaultConfig.WebhookURL) } if config.Alerting.PagerDuty == nil || config.Alerting.PagerDuty.Validate() != nil { t.Fatal("PagerDuty alerting config should've been valid") } if config.Alerting.PagerDuty.GetDefaultAlert() == nil { t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != "00000000000000000000000000000000" { t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.DefaultConfig.IntegrationKey) } if config.Alerting.Pushover == nil || config.Alerting.Pushover.Validate() != nil { t.Fatal("Pushover alerting config should've been valid") } if config.Alerting.Pushover.GetDefaultAlert() == nil { t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Pushover.DefaultConfig.ApplicationToken != "000000000000000000000000000000" { t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.ApplicationToken) } if config.Alerting.Pushover.DefaultConfig.UserKey != "000000000000000000000000000000" { t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.UserKey) } if config.Alerting.Mattermost == nil || config.Alerting.Mattermost.Validate() != nil { t.Fatal("Mattermost alerting config should've been valid") } if config.Alerting.Mattermost.GetDefaultAlert() == nil { t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Messagebird == nil || config.Alerting.Messagebird.Validate() != nil { t.Fatal("Messagebird alerting config should've been valid") } if config.Alerting.Messagebird.GetDefaultAlert() == nil { t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Messagebird.DefaultConfig.AccessKey != "1" { t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.DefaultConfig.AccessKey) } if config.Alerting.Messagebird.DefaultConfig.Originator != "31619191918" { t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.DefaultConfig.Originator) } if config.Alerting.Messagebird.DefaultConfig.Recipients != "31619191919" { t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.DefaultConfig.Recipients) } if config.Alerting.Discord == nil || config.Alerting.Discord.Validate() != nil { t.Fatal("Discord alerting config should've been valid") } if config.Alerting.Discord.GetDefaultAlert() == nil { t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Discord.GetDefaultAlert().FailureThreshold != 10 { t.Errorf("Discord default alert failure threshold should've been %d, but was %d", 10, config.Alerting.Discord.GetDefaultAlert().FailureThreshold) } if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 { t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold) } if config.Alerting.Discord.DefaultConfig.WebhookURL != "http://example.org" { t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.DefaultConfig.WebhookURL) } if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord { t.Error("expected discord configuration") } if config.Alerting.Telegram == nil || config.Alerting.Telegram.Validate() != nil { t.Fatal("Telegram alerting config should've been valid") } if config.Alerting.Telegram.GetDefaultAlert() == nil { t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Telegram.DefaultConfig.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" { t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.DefaultConfig.Token) } if config.Alerting.Telegram.DefaultConfig.ID != "0123456789" { t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.DefaultConfig.ID) } if config.Alerting.Twilio == nil || config.Alerting.Twilio.Validate() != nil { t.Fatal("Twilio alerting config should've been valid") } if config.Alerting.Twilio.GetDefaultAlert() == nil { t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Teams == nil || config.Alerting.Teams.Validate() != nil { t.Fatal("Teams alerting config should've been valid") } if config.Alerting.Teams.GetDefaultAlert() == nil { t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil { t.Fatal("JetBrainsSpace alerting config should've been valid") } if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil { t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" { t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project) } if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" { t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID) } if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" { t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token) } if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil { t.Fatal("Email alerting config should've been valid") } if config.Alerting.Email.GetDefaultAlert() == nil { t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Email.DefaultConfig.From != "from@example.com" { t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.From) } if config.Alerting.Email.DefaultConfig.Username != "from@example.com" { t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.Username) } if config.Alerting.Email.DefaultConfig.Password != "hunter2" { t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.DefaultConfig.Password) } if config.Alerting.Email.DefaultConfig.Host != "mail.example.com" { t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.DefaultConfig.Host) } if config.Alerting.Email.DefaultConfig.Port != 587 { t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.DefaultConfig.Port) } if config.Alerting.Email.DefaultConfig.To != "recipient1@example.com,recipient2@example.com" { t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.DefaultConfig.To) } if config.Alerting.Email.DefaultConfig.ClientConfig == nil { t.Fatal("Email client config should've been set") } if config.Alerting.Email.DefaultConfig.ClientConfig.Insecure { t.Error("Email client config should've been secure") } if config.Alerting.Gotify == nil || config.Alerting.Gotify.Validate() != nil { t.Fatal("Gotify alerting config should've been valid") } if config.Alerting.Gotify.GetDefaultAlert() == nil { t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil") } if config.Alerting.Gotify.DefaultConfig.ServerURL != "https://gotify.example" { t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.DefaultConfig.ServerURL) } if config.Alerting.Gotify.DefaultConfig.Token != "**************" { t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.DefaultConfig.Token) } // External endpoints if len(config.ExternalEndpoints) != 1 { t.Error("There should've been 1 external endpoint") } if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.ExternalEndpoints[0].Alerts[0].Type) } if !config.ExternalEndpoints[0].Alerts[0].IsEnabled() { t.Error("The alert should've been enabled") } if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 10 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.ExternalEndpoints[0].Alerts[0].FailureThreshold) } if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 15 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold) } // Endpoints if len(config.Endpoints) != 1 { t.Error("There should've been 1 endpoint") } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } if len(config.Endpoints[0].Alerts) != 12 { t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts)) } if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type) } if !config.Endpoints[0].Alerts[0].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[0].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[0].FailureThreshold) } if config.Endpoints[0].Alerts[0].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[0].SuccessThreshold) } if config.Endpoints[0].Alerts[1].Type != alert.TypePagerDuty { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePagerDuty, config.Endpoints[0].Alerts[1].Type) } if config.Endpoints[0].Alerts[1].GetDescription() != "default description" { t.Errorf("The description of the alert should've been %s, but it was %s", "default description", config.Endpoints[0].Alerts[1].GetDescription()) } if config.Endpoints[0].Alerts[1].FailureThreshold != 7 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 7, config.Endpoints[0].Alerts[1].FailureThreshold) } if config.Endpoints[0].Alerts[1].SuccessThreshold != 5 { t.Errorf("The success threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[1].SuccessThreshold) } if config.Endpoints[0].Alerts[2].Type != alert.TypeMattermost { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMattermost, config.Endpoints[0].Alerts[2].Type) } if !config.Endpoints[0].Alerts[2].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[2].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[2].FailureThreshold) } if config.Endpoints[0].Alerts[2].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[2].SuccessThreshold) } if config.Endpoints[0].Alerts[3].Type != alert.TypeMessagebird { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMessagebird, config.Endpoints[0].Alerts[3].Type) } if config.Endpoints[0].Alerts[3].IsEnabled() { t.Error("The alert should've been disabled") } if !config.Endpoints[0].Alerts[3].IsSendingOnResolved() { t.Error("The alert should be sending on resolve") } if config.Endpoints[0].Alerts[4].Type != alert.TypeDiscord { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.Endpoints[0].Alerts[4].Type) } if !config.Endpoints[0].Alerts[4].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[4].FailureThreshold != 10 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold) } if config.Endpoints[0].Alerts[4].SuccessThreshold != 8 { t.Errorf("The default success threshold of the alert should've been %d because it was explicitly overriden, but it was %d", 8, config.Endpoints[0].Alerts[4].SuccessThreshold) } if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTelegram, config.Endpoints[0].Alerts[5].Type) } if !config.Endpoints[0].Alerts[5].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[5].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[5].FailureThreshold) } if config.Endpoints[0].Alerts[5].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[5].SuccessThreshold) } if config.Endpoints[0].Alerts[6].Type != alert.TypeTwilio { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTwilio, config.Endpoints[0].Alerts[6].Type) } if !config.Endpoints[0].Alerts[6].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[6].FailureThreshold != 12 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 12, config.Endpoints[0].Alerts[6].FailureThreshold) } if config.Endpoints[0].Alerts[6].SuccessThreshold != 15 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Endpoints[0].Alerts[6].SuccessThreshold) } if config.Endpoints[0].Alerts[7].Type != alert.TypeTeams { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Endpoints[0].Alerts[7].Type) } if !config.Endpoints[0].Alerts[7].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[7].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[7].FailureThreshold) } if config.Endpoints[0].Alerts[7].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold) } if config.Endpoints[0].Alerts[8].Type != alert.TypePushover { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type) } if !config.Endpoints[0].Alerts[8].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[8].FailureThreshold != 5 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[8].FailureThreshold) } if config.Endpoints[0].Alerts[8].SuccessThreshold != 3 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold) } if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type) } if !config.Endpoints[0].Alerts[9].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[9].FailureThreshold != 5 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold) } if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold) } if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type) } if !config.Endpoints[0].Alerts[10].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[10].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[10].FailureThreshold) } if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold) } if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type) } if !config.Endpoints[0].Alerts[11].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[11].FailureThreshold != 3 { t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold) } if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold) } } func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: slack: webhook-url: "https://example.com" default-alert: enabled: true description: "description" endpoints: - name: website url: https://twin.sh/health alerts: - type: slack failure-threshold: 10 - type: slack failure-threshold: 20 description: "wow" - type: slack enabled: false failure-threshold: 30 provider-override: webhook-url: https://example.com conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } // Alerting providers if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil { t.Fatal("Slack alerting config should've been valid") } // Endpoints if len(config.Endpoints) != 1 { t.Error("There should've been 2 endpoints") } if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type) } if config.Endpoints[0].Alerts[1].Type != alert.TypeSlack { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[1].Type) } if config.Endpoints[0].Alerts[2].Type != alert.TypeSlack { t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[2].Type) } if !config.Endpoints[0].Alerts[0].IsEnabled() { t.Error("The alert should've been enabled") } if !config.Endpoints[0].Alerts[1].IsEnabled() { t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[2].IsEnabled() { t.Error("The alert should've been disabled") } if config.Endpoints[0].Alerts[0].GetDescription() != "description" { t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Endpoints[0].Alerts[0].GetDescription()) } if config.Endpoints[0].Alerts[1].GetDescription() != "wow" { t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Endpoints[0].Alerts[1].GetDescription()) } if config.Endpoints[0].Alerts[2].GetDescription() != "description" { t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Endpoints[0].Alerts[2].GetDescription()) } if config.Endpoints[0].Alerts[0].FailureThreshold != 10 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[0].FailureThreshold) } if config.Endpoints[0].Alerts[1].FailureThreshold != 20 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 20, config.Endpoints[0].Alerts[1].FailureThreshold) } if config.Endpoints[0].Alerts[2].FailureThreshold != 30 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 30, config.Endpoints[0].Alerts[2].FailureThreshold) } } func TestParseAndValidateConfigBytesWithInvalidPagerDutyAlertingConfig(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: pagerduty: integration-key: "INVALID_KEY" endpoints: - name: website url: https://twin.sh/health alerts: - type: pagerduty conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.PagerDuty != nil { t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called") } } func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: pushover: application-token: "INVALID_TOKEN" endpoints: - name: website url: https://twin.sh/health alerts: - type: pushover conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.Pushover != nil { t.Fatal("Pushover alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called") } } func TestParseAndValidateConfigBytesWithCustomAlertingConfig(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: custom: url: "https://example.com" body: | { "text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]" } endpoints: - name: website url: https://twin.sh/health alerts: - type: custom conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("Config shouldn't have been nil") } if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.Custom == nil { t.Fatal("Custom alerting config shouldn't have been nil") } if err = config.Alerting.Custom.Validate(); err != nil { t.Fatal("Custom alerting config should've been valid") } cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{ProviderOverride: map[string]any{"client": map[string]any{"insecure": true}}}) if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" { t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true)) } if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "TRIGGERED" { t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false)) } if !cfg.ClientConfig.Insecure { t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, cfg.ClientConfig.Insecure) } } func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndCustomPlaceholderValues(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: custom: placeholders: ALERT_TRIGGERED_OR_RESOLVED: TRIGGERED: "partial_outage" RESOLVED: "operational" url: "https://example.com" insecure: true body: "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]" endpoints: - name: website url: https://twin.sh/health alerts: - type: custom conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("DefaultConfig shouldn't have been nil") } if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.Custom == nil { t.Fatal("Custom alerting config shouldn't have been nil") } if err = config.Alerting.Custom.Validate(); err != nil { t.Fatal("Custom alerting config should've been valid") } cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{}) if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "operational" { t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'") } if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" { t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'") } } func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndOneCustomPlaceholderValue(t *testing.T) { config, err := parseAndValidateConfigBytes([]byte(` alerting: custom: placeholders: ALERT_TRIGGERED_OR_RESOLVED: TRIGGERED: "partial_outage" url: "https://example.com" body: "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]" endpoints: - name: website url: https://twin.sh/health alerts: - type: custom conditions: - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("DefaultConfig shouldn't have been nil") } if config.Alerting == nil { t.Fatal("config.Alerting shouldn't have been nil") } if config.Alerting.Custom == nil { t.Fatal("Custom alerting config shouldn't have been nil") } if err := config.Alerting.Custom.Validate(); err != nil { t.Fatal("Custom alerting config should've been valid") } cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{}) if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" { t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'") } if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" { t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'") } } func TestParseAndValidateConfigBytesWithInvalidEndpointName(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(` endpoints: - name: "" url: https://twin.sh/health conditions: - "[STATUS] == 200" `)) if err == nil { t.Error("should've returned an error") } } func TestParseAndValidateConfigBytesWithDuplicateEndpointName(t *testing.T) { scenarios := []struct { name string shouldError bool config string }{ { name: "same-name-no-group", shouldError: true, config: ` endpoints: - name: ep1 url: https://twin.sh/health conditions: - "[STATUS] == 200" - name: ep1 url: https://twin.sh/health conditions: - "[STATUS] == 200"`, }, { name: "same-name-different-group", shouldError: false, config: ` endpoints: - name: ep1 url: https://twin.sh/health conditions: - "[STATUS] == 200" - name: ep1 group: g1 url: https://twin.sh/health conditions: - "[STATUS] == 200"`, }, { name: "same-name-same-group", shouldError: true, config: ` endpoints: - name: ep1 group: g1 url: https://twin.sh/health conditions: - "[STATUS] == 200" - name: ep1 group: g1 url: https://twin.sh/health conditions: - "[STATUS] == 200"`, }, { name: "same-name-different-endpoint-type", shouldError: true, config: ` external-endpoints: - name: ep1 token: "12345678" endpoints: - name: ep1 url: https://twin.sh/health conditions: - "[STATUS] == 200"`, }, { name: "same-name-different-group-different-endpoint-type", shouldError: false, config: ` external-endpoints: - name: ep1 group: gr1 token: "12345678" endpoints: - name: ep1 url: https://twin.sh/health conditions: - "[STATUS] == 200"`, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(scenario.config)) if scenario.shouldError && err == nil { t.Error("should've returned an error") } else if !scenario.shouldError && err != nil { t.Error("shouldn't have returned an error") } }) } } func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(` storage: type: sqlite endpoints: - name: example url: https://example.org conditions: - "[STATUS] == 200" `)) if err == nil { t.Error("should've returned an error, because a file must be specified for a storage of type sqlite") } } func TestParseAndValidateConfigBytesWithInvalidYAML(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(` storage: invalid yaml endpoints: - name: example url: https://example.org conditions: - "[STATUS] == 200" `)) if err == nil { t.Error("should've returned an error") } } func TestParseAndValidateConfigBytesWithInvalidSecurityConfig(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(` security: basic: username: "admin" password-sha512: "invalid-sha512-hash" endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `)) if err == nil { t.Error("should've returned an error") } } func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) { const expectedUsername = "admin" const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu" config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` security: basic: username: "%s" password-bcrypt-base64: "%s" endpoints: - name: website url: https://twin.sh/health conditions: - "[STATUS] == 200" `, expectedUsername, expectedPasswordHash))) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("DefaultConfig shouldn't have been nil") } if config.Security == nil { t.Fatal("config.Security shouldn't have been nil") } if !config.Security.ValidateAndSetDefaults() { t.Error("Security config should've been valid") } if config.Security.Basic == nil { t.Fatal("config.Security.Basic shouldn't have been nil") } if config.Security.Basic.Username != expectedUsername { t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username) } if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash { t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordBcryptHashBase64Encoded) } } func TestParseAndValidateConfigBytesWithLiteralDollarSign(t *testing.T) { os.Setenv("GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign", "whatever") config, err := parseAndValidateConfigBytes([]byte(` endpoints: - name: website url: https://twin.sh/health conditions: - "[BODY] == $$GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign" - "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign" `)) if err != nil { t.Error("expected no error, got", err.Error()) } if config == nil { t.Fatal("DefaultConfig shouldn't have been nil") } if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } if config.Endpoints[0].Conditions[0] != "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign" { t.Errorf("Condition should have been %s", "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign") } if config.Endpoints[0].Conditions[1] != "[BODY] == whatever" { t.Errorf("Condition should have been %s", "[BODY] == whatever") } } func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(``)) if !errors.Is(err, ErrNoEndpointOrSuiteInConfig) { t.Error("The error returned should have been of type ErrNoEndpointOrSuiteInConfig") } } func TestGetAlertingProviderByAlertType(t *testing.T) { alertingConfig := &alerting.Config{ AWSSimpleEmailService: &awsses.AlertProvider{}, Custom: &custom.AlertProvider{}, Datadog: &datadog.AlertProvider{}, Discord: &discord.AlertProvider{}, Email: &email.AlertProvider{}, Gitea: &gitea.AlertProvider{}, GitHub: &github.AlertProvider{}, GitLab: &gitlab.AlertProvider{}, GoogleChat: &googlechat.AlertProvider{}, Gotify: &gotify.AlertProvider{}, HomeAssistant: &homeassistant.AlertProvider{}, IFTTT: &ifttt.AlertProvider{}, Ilert: &ilert.AlertProvider{}, IncidentIO: &incidentio.AlertProvider{}, JetBrainsSpace: &jetbrainsspace.AlertProvider{}, Line: &line.AlertProvider{}, Matrix: &matrix.AlertProvider{}, Mattermost: &mattermost.AlertProvider{}, Messagebird: &messagebird.AlertProvider{}, NewRelic: &newrelic.AlertProvider{}, Ntfy: &ntfy.AlertProvider{}, Opsgenie: &opsgenie.AlertProvider{}, PagerDuty: &pagerduty.AlertProvider{}, Plivo: &plivo.AlertProvider{}, Pushover: &pushover.AlertProvider{}, RocketChat: &rocketchat.AlertProvider{}, SendGrid: &sendgrid.AlertProvider{}, Signal: &signal.AlertProvider{}, SIGNL4: &signl4.AlertProvider{}, Slack: &slack.AlertProvider{}, Splunk: &splunk.AlertProvider{}, Squadcast: &squadcast.AlertProvider{}, Telegram: &telegram.AlertProvider{}, Teams: &teams.AlertProvider{}, TeamsWorkflows: &teamsworkflows.AlertProvider{}, Twilio: &twilio.AlertProvider{}, Vonage: &vonage.AlertProvider{}, Webex: &webex.AlertProvider{}, Zapier: &zapier.AlertProvider{}, Zulip: &zulip.AlertProvider{}, } scenarios := []struct { alertType alert.Type expected provider.AlertProvider }{ {alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService}, {alertType: alert.TypeCustom, expected: alertingConfig.Custom}, {alertType: alert.TypeDatadog, expected: alertingConfig.Datadog}, {alertType: alert.TypeDiscord, expected: alertingConfig.Discord}, {alertType: alert.TypeEmail, expected: alertingConfig.Email}, {alertType: alert.TypeGitea, expected: alertingConfig.Gitea}, {alertType: alert.TypeGitHub, expected: alertingConfig.GitHub}, {alertType: alert.TypeGitLab, expected: alertingConfig.GitLab}, {alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat}, {alertType: alert.TypeGotify, expected: alertingConfig.Gotify}, {alertType: alert.TypeHomeAssistant, expected: alertingConfig.HomeAssistant}, {alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT}, {alertType: alert.TypeIlert, expected: alertingConfig.Ilert}, {alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO}, {alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace}, {alertType: alert.TypeLine, expected: alertingConfig.Line}, {alertType: alert.TypeMatrix, expected: alertingConfig.Matrix}, {alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost}, {alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird}, {alertType: alert.TypeNewRelic, expected: alertingConfig.NewRelic}, {alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy}, {alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie}, {alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty}, {alertType: alert.TypePlivo, expected: alertingConfig.Plivo}, {alertType: alert.TypePushover, expected: alertingConfig.Pushover}, {alertType: alert.TypeRocketChat, expected: alertingConfig.RocketChat}, {alertType: alert.TypeSendGrid, expected: alertingConfig.SendGrid}, {alertType: alert.TypeSignal, expected: alertingConfig.Signal}, {alertType: alert.TypeSIGNL4, expected: alertingConfig.SIGNL4}, {alertType: alert.TypeSlack, expected: alertingConfig.Slack}, {alertType: alert.TypeSplunk, expected: alertingConfig.Splunk}, {alertType: alert.TypeSquadcast, expected: alertingConfig.Squadcast}, {alertType: alert.TypeTelegram, expected: alertingConfig.Telegram}, {alertType: alert.TypeTeams, expected: alertingConfig.Teams}, {alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows}, {alertType: alert.TypeTwilio, expected: alertingConfig.Twilio}, {alertType: alert.TypeVonage, expected: alertingConfig.Vonage}, {alertType: alert.TypeWebex, expected: alertingConfig.Webex}, {alertType: alert.TypeZapier, expected: alertingConfig.Zapier}, {alertType: alert.TypeZulip, expected: alertingConfig.Zulip}, } for _, scenario := range scenarios { t.Run(string(scenario.alertType), func(t *testing.T) { if alertingConfig.GetAlertingProviderByAlertType(scenario.alertType) != scenario.expected { t.Errorf("expected %s configuration", scenario.alertType) } }) } } func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) { tests := []struct { name string config *Config expected []string }{ { name: "no-endpoints", config: &Config{ Endpoints: []*endpoint.Endpoint{}, }, expected: []string{}, }, { name: "single-endpoint-no-labels", config: &Config{ Endpoints: []*endpoint.Endpoint{ { Name: "endpoint1", URL: "https://example.com", }, }, }, expected: []string{}, }, { name: "single-endpoint-with-labels", config: &Config{ Endpoints: []*endpoint.Endpoint{ { Name: "endpoint1", URL: "https://example.com", Enabled: toPtr(true), ExtraLabels: map[string]string{ "env": "production", "team": "backend", }, }, }, }, expected: []string{"env", "team"}, }, { name: "multiple-endpoints-with-labels", config: &Config{ Endpoints: []*endpoint.Endpoint{ { Name: "endpoint1", URL: "https://example.com", Enabled: toPtr(true), ExtraLabels: map[string]string{ "env": "production", "team": "backend", "module": "auth", }, }, { Name: "endpoint2", URL: "https://example.org", Enabled: toPtr(true), ExtraLabels: map[string]string{ "env": "staging", "team": "frontend", }, }, }, }, expected: []string{"env", "team", "module"}, }, { name: "multiple-endpoints-with-some-disabled", config: &Config{ Endpoints: []*endpoint.Endpoint{ { Name: "endpoint1", URL: "https://example.com", Enabled: toPtr(true), ExtraLabels: map[string]string{ "env": "production", "team": "backend", }, }, { Name: "endpoint2", URL: "https://example.org", Enabled: toPtr(false), ExtraLabels: map[string]string{ "module": "auth", }, }, }, }, expected: []string{"env", "team"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { labels := tt.config.GetUniqueExtraMetricLabels() if len(labels) != len(tt.expected) { t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels)) } for _, label := range tt.expected { if !contains(labels, label) { t.Errorf("expected label %s to be present", label) } } }) } } func TestParseAndValidateConfigBytesWithDuplicateKeysAcrossEntityTypes(t *testing.T) { scenarios := []struct { name string shouldError bool expectedErr string config string }{ { name: "endpoint-suite-same-key", shouldError: true, expectedErr: "duplicate key 'backend_test-api': suite 'backend_test-api' conflicts with endpoint 'backend_test-api'", config: ` endpoints: - name: test-api group: backend url: https://example.com/api conditions: - "[STATUS] == 200" suites: - name: test-api group: backend interval: 30s endpoints: - name: step1 url: https://example.com/test conditions: - "[STATUS] == 200"`, }, { name: "endpoint-suite-different-keys", shouldError: false, config: ` endpoints: - name: api-service group: backend url: https://example.com/api conditions: - "[STATUS] == 200" suites: - name: integration-tests group: testing interval: 30s endpoints: - name: step1 url: https://example.com/test conditions: - "[STATUS] == 200"`, }, { name: "endpoint-external-endpoint-suite-unique-keys", shouldError: false, config: ` endpoints: - name: api-service group: backend url: https://example.com/api conditions: - "[STATUS] == 200" external-endpoints: - name: monitoring-agent group: infrastructure token: "secret-token" heartbeat: interval: 5m suites: - name: integration-tests group: testing interval: 30s endpoints: - name: step1 url: https://example.com/test conditions: - "[STATUS] == 200"`, }, { name: "suite-with-same-key-as-external-endpoint", shouldError: true, expectedErr: "duplicate key 'monitoring_health-check': suite 'monitoring_health-check' conflicts with external endpoint 'monitoring_health-check'", config: ` endpoints: - name: dummy url: https://example.com/dummy conditions: - "[STATUS] == 200" external-endpoints: - name: health-check group: monitoring token: "secret-token" heartbeat: interval: 5m suites: - name: health-check group: monitoring interval: 30s endpoints: - name: step1 url: https://example.com/test conditions: - "[STATUS] == 200"`, }, { name: "endpoint-with-same-name-as-suite-endpoint-different-groups", shouldError: false, config: ` endpoints: - name: api-health group: backend url: https://example.com/health conditions: - "[STATUS] == 200" suites: - name: integration-suite group: testing interval: 30s endpoints: - name: api-health url: https://example.com/api/health conditions: - "[STATUS] == 200"`, }, { name: "endpoint-conflicting-with-suite-endpoint", shouldError: true, expectedErr: "duplicate key 'backend_api-health': endpoint 'backend_api-health' in suite 'backend_integration-suite' conflicts with endpoint 'backend_api-health'", config: ` endpoints: - name: api-health group: backend url: https://example.com/health conditions: - "[STATUS] == 200" suites: - name: integration-suite group: backend interval: 30s endpoints: - name: api-health url: https://example.com/api/health conditions: - "[STATUS] == 200"`, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(scenario.config)) if scenario.shouldError { if err == nil { t.Error("should've returned an error") } else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr { t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error()) } } else if err != nil { t.Errorf("shouldn't have returned an error, got: %v", err) } }) } } func TestParseAndValidateConfigBytesWithSuites(t *testing.T) { scenarios := []struct { name string shouldError bool expectedErr string config string }{ { name: "suite-with-no-name", shouldError: true, expectedErr: "invalid suite 'testing_': suite must have a name", config: ` endpoints: - name: dummy url: https://example.com/dummy conditions: - "[STATUS] == 200" suites: - group: testing interval: 30s endpoints: - name: step1 url: https://example.com/test conditions: - "[STATUS] == 200"`, }, { name: "suite-with-no-endpoints", shouldError: true, expectedErr: "invalid suite 'testing_empty-suite': suite must have at least one endpoint", config: ` endpoints: - name: dummy url: https://example.com/dummy conditions: - "[STATUS] == 200" suites: - name: empty-suite group: testing interval: 30s endpoints: []`, }, { name: "suite-with-duplicate-endpoint-names", shouldError: true, expectedErr: "invalid suite 'testing_duplicate-test': suite cannot have duplicate endpoint names: duplicate endpoint name 'step1'", config: ` endpoints: - name: dummy url: https://example.com/dummy conditions: - "[STATUS] == 200" suites: - name: duplicate-test group: testing interval: 30s endpoints: - name: step1 url: https://example.com/test1 conditions: - "[STATUS] == 200" - name: step1 url: https://example.com/test2 conditions: - "[STATUS] == 200"`, }, { name: "suite-with-invalid-negative-timeout", shouldError: true, expectedErr: "invalid suite 'testing_negative-timeout-suite': suite timeout must be positive", config: ` endpoints: - name: dummy url: https://example.com/dummy conditions: - "[STATUS] == 200" suites: - name: negative-timeout-suite group: testing interval: 30s timeout: -5m endpoints: - name: step1 url: https://example.com/test conditions: - "[STATUS] == 200"`, }, { name: "valid-suite-with-defaults", shouldError: false, config: ` endpoints: - name: api-service group: backend url: https://example.com/api conditions: - "[STATUS] == 200" suites: - name: integration-test group: testing endpoints: - name: step1 url: https://example.com/test conditions: - "[STATUS] == 200" - name: step2 url: https://example.com/validate conditions: - "[STATUS] == 200"`, }, { name: "valid-suite-with-all-fields", shouldError: false, config: ` endpoints: - name: api-service group: backend url: https://example.com/api conditions: - "[STATUS] == 200" suites: - name: full-integration-test group: testing enabled: true interval: 15m timeout: 10m context: base_url: "https://example.com" user_id: 12345 endpoints: - name: authentication url: https://example.com/auth conditions: - "[STATUS] == 200" - name: user-profile url: https://example.com/profile conditions: - "[STATUS] == 200" - "[BODY].user_id == 12345"`, }, { name: "valid-suite-with-endpoint-inheritance", shouldError: false, config: ` endpoints: - name: api-service group: backend url: https://example.com/api conditions: - "[STATUS] == 200" suites: - name: inheritance-test group: parent-group endpoints: - name: child-endpoint url: https://example.com/test conditions: - "[STATUS] == 200"`, }, { name: "valid-suite-with-store-functionality", shouldError: false, config: ` endpoints: - name: api-service group: backend url: https://example.com/api conditions: - "[STATUS] == 200" suites: - name: store-test group: testing endpoints: - name: get-token url: https://example.com/auth conditions: - "[STATUS] == 200" store: auth_token: "[BODY].token" - name: use-token url: https://example.com/data headers: Authorization: "Bearer {auth_token}" conditions: - "[STATUS] == 200"`, }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(scenario.config)) if scenario.shouldError { if err == nil { t.Error("should've returned an error") } else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr { t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error()) } } else if err != nil { t.Errorf("shouldn't have returned an error, got: %v", err) } }) } } func TestValidateTunnelingConfig(t *testing.T) { tests := []struct { name string config *Config wantErr bool errMsg string }{ { name: "valid tunneling config", config: &Config{ Tunneling: &tunneling.Config{ Tunnels: map[string]*sshtunnel.Config{ "test": { Type: "SSH", Host: "example.com", Username: "test", Password: "secret", }, }, }, Endpoints: []*endpoint.Endpoint{ { Name: "test-endpoint", URL: "http://example.com/health", ClientConfig: &client.Config{ Tunnel: "test", }, Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, wantErr: false, }, { name: "invalid tunnel reference in endpoint", config: &Config{ Tunneling: &tunneling.Config{ Tunnels: map[string]*sshtunnel.Config{ "test": { Type: "SSH", Host: "example.com", Username: "test", Password: "secret", }, }, }, Endpoints: []*endpoint.Endpoint{ { Name: "test-endpoint", URL: "http://example.com/health", ClientConfig: &client.Config{ Tunnel: "nonexistent", }, Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, wantErr: true, errMsg: "endpoint '_test-endpoint': tunnel 'nonexistent' not found in tunneling configuration", }, { name: "invalid tunnel reference in suite endpoint", config: &Config{ Tunneling: &tunneling.Config{ Tunnels: map[string]*sshtunnel.Config{ "test": { Type: "SSH", Host: "example.com", Username: "test", Password: "secret", }, }, }, Suites: []*suite.Suite{ { Name: "test-suite", Endpoints: []*endpoint.Endpoint{ { Name: "suite-endpoint", URL: "http://example.com/health", ClientConfig: &client.Config{ Tunnel: "invalid", }, Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, }, }, wantErr: true, errMsg: "suite '_test-suite' endpoint '_suite-endpoint': tunnel 'invalid' not found in tunneling configuration", }, { name: "no tunneling config", config: &Config{ Endpoints: []*endpoint.Endpoint{ { Name: "test-endpoint", URL: "http://example.com/health", Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateTunnelingConfig(tt.config) if tt.wantErr { if err == nil { t.Error("validateTunnelingConfig() expected error but got none") return } if err.Error() != tt.errMsg { t.Errorf("validateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg) } return } if err != nil { t.Errorf("validateTunnelingConfig() unexpected error = %v", err) } }) } } func TestResolveTunnelForClientConfig(t *testing.T) { config := &Config{ Tunneling: &tunneling.Config{ Tunnels: map[string]*sshtunnel.Config{ "test": { Type: "SSH", Host: "example.com", Username: "test", Password: "secret", }, }, }, } err := config.Tunneling.ValidateAndSetDefaults() if err != nil { t.Fatalf("Failed to validate tunnel config: %v", err) } tests := []struct { name string clientConfig *client.Config wantErr bool errMsg string }{ { name: "valid tunnel reference", clientConfig: &client.Config{ Tunnel: "test", }, wantErr: false, }, { name: "invalid tunnel reference", clientConfig: &client.Config{ Tunnel: "nonexistent", }, wantErr: true, errMsg: "tunnel 'nonexistent' not found in tunneling configuration", }, { name: "no tunnel reference", clientConfig: &client.Config{}, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := resolveTunnelForClientConfig(config, tt.clientConfig) if tt.wantErr { if err == nil { t.Error("resolveTunnelForClientConfig() expected error but got none") return } if err.Error() != tt.errMsg { t.Errorf("resolveTunnelForClientConfig() error = %v, want %v", err.Error(), tt.errMsg) } return } if err != nil { t.Errorf("resolveTunnelForClientConfig() unexpected error = %v", err) } }) } }