Files
gatus/config/config_test.go
T
Nikolaus SchuetzandTwiN 374de23cdd test(config): make HasLoadedConfigurationBeenModified deterministic (#1786)
* test(config): make HasLoadedConfigurationBeenModified deterministic

The two subtests each slept a full second so a rewritten file's mod time
(second precision) would advance past the load time. Advance the mod time
explicitly with os.Chtimes instead, and give the directory subtest its own
temp dir so the file subtest's future-dated config.yaml no longer leaks
into it. Removes ~2s of wall-clock sleep and the coarse-clock dependence;
the test is now deterministic.

* test(config): trim comments to the forward-facing rationale

---------

Co-authored-by: TwiN <twin@linux.com>
2026-09-21 21:08:52 -04:00

2647 lines
87 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"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/clickup"
"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/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")
}
if err = os.WriteFile(configFilePath, []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
// File mod times have second precision, so advance it explicitly.
future := time.Now().Add(2 * time.Second)
if err = os.Chtimes(configFilePath, future, future); err != nil {
t.Fatalf("failed to advance config file mod time: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because the config file has been modified")
}
})
t.Run("config-directory-as-config-path", func(t *testing.T) {
dir := t.TempDir()
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 write config file: %v", err)
}
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")
}
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
t.Fatalf("failed to add config file: %v", err)
}
// File mod times have second precision, so advance it explicitly.
future := time.Now().Add(2 * time.Second)
if err = os.Chtimes(filepath.Join(dir, "metrics.yaml"), future, future); err != nil {
t.Fatalf("failed to advance config file mod time: %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"
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
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) != 9 {
t.Fatal("There should've been 9 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")
}
}
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
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: 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.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) != 11 {
t.Fatalf("There should've been 11 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.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, 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 != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].FailureThreshold)
}
if config.Endpoints[0].Alerts[9].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)
}
if config.Endpoints[0].Alerts[10].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, 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)
}
}
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{},
ClickUp: &clickup.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{},
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.TypeClickUp, expected: alertingConfig.ClickUp},
{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.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 !slices.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)
}
})
}
}