Files
dnswatcher/internal/config/config_test.go
T
clawbot 7ee78b384a
check / check (push) Waiting to run
config: a bad number or true/false setting stops startup (closes #269)
PORT and DNSWATCHER_TLS_EXPIRY_WARNING were read with viper.GetInt,
and DNSWATCHER_DEBUG, DNSWATCHER_MAINTENANCE_MODE and
DNSWATCHER_SEND_TEST_NOTIFICATION with viper.GetBool, which turn a
value that does not parse into 0 or false with no error. Each is now
parsed the way parseInterval parses an interval: PORT must be 1 to
65535, the expiry warning 1 or more days, the true/false settings a
value strconv.ParseBool accepts. Anything else stops startup with an
error naming the variable and the value; empty still means the
default.

Reading the config file moved, unchanged, from buildConfig into
readConfigFile, so buildConfig stays within the linter's length limit.

Model: opus-5-5
2026-10-13 11:51:06 +00:00

390 lines
11 KiB
Go

package config_test
import (
"strconv"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/logger"
)
// newTestParams creates config.Params suitable for testing
// without requiring the fx dependency injection framework.
func newTestParams(t *testing.T) config.Params {
t.Helper()
g := &globals.Globals{
Appname: "dnswatcher",
Version: "test",
}
l, err := logger.New(nil, logger.Params{Globals: g})
require.NoError(t, err, "failed to create logger")
return config.Params{
Globals: g,
Logger: l,
}
}
// These tests exercise viper global state and MUST NOT use
// t.Parallel(). Each test resets viper for isolation.
func TestNew_DefaultValues(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug)
assert.Equal(t, "/var/lib/dnswatcher", cfg.DataDir)
assert.Equal(t, time.Hour, cfg.DNSInterval)
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
assert.Equal(t, 7, cfg.TLSExpiryWarning)
assert.False(t, cfg.MaintenanceMode)
assert.Empty(t, cfg.SlackWebhook)
assert.Empty(t, cfg.MattermostWebhook)
assert.Empty(t, cfg.NtfyTopic)
assert.Empty(t, cfg.SentryDSN)
assert.Empty(t, cfg.MetricsUsername)
assert.Empty(t, cfg.MetricsPassword)
assert.False(t, cfg.SendTestNotification)
assert.Empty(t, cfg.SkipRecordNotifications)
}
func TestNew_EnvironmentOverrides(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "9090")
t.Setenv("DNSWATCHER_DEBUG", "true")
t.Setenv("DNSWATCHER_DATA_DIR", "/tmp/test-data")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "30m")
t.Setenv("DNSWATCHER_TLS_INTERVAL", "6h")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "14")
t.Setenv("DNSWATCHER_SLACK_WEBHOOK", "https://hooks.slack.com/t")
t.Setenv("DNSWATCHER_MATTERMOST_WEBHOOK", "https://mm.test/hooks/t")
t.Setenv("DNSWATCHER_NTFY_TOPIC", "https://ntfy.sh/test")
t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "true")
t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin")
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret")
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "true")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 9090, cfg.Port)
assert.True(t, cfg.Debug)
assert.Equal(t, "/tmp/test-data", cfg.DataDir)
assert.Equal(t, 30*time.Minute, cfg.DNSInterval)
assert.Equal(t, 6*time.Hour, cfg.TLSInterval)
assert.Equal(t, 14, cfg.TLSExpiryWarning)
assert.Equal(t, "https://hooks.slack.com/t", cfg.SlackWebhook)
assert.Equal(t, "https://mm.test/hooks/t", cfg.MattermostWebhook)
assert.Equal(t, "https://ntfy.sh/test", cfg.NtfyTopic)
assert.Equal(t, "https://sentry.test/1", cfg.SentryDSN)
assert.True(t, cfg.MaintenanceMode)
assert.Equal(t, "admin", cfg.MetricsUsername)
assert.Equal(t, "secret", cfg.MetricsPassword)
assert.True(t, cfg.SendTestNotification)
}
func TestNew_NoTargetsError(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "")
_, err := config.New(nil, newTestParams(t))
require.Error(t, err)
assert.ErrorIs(t, err, config.ErrNoTargets)
}
func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", " , , ")
_, err := config.New(nil, newTestParams(t))
require.Error(t, err)
assert.ErrorIs(t, err, config.ErrNoTargets)
}
// TestNew_InvalidIntervalStopsStartup checks values that must stop startup;
// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault check that an
// unset or empty interval means the default.
func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"}
values := []string{
"banana", // not a duration
"5", // no unit
"1d", // days are not a unit time.ParseDuration knows
"0", // zero
"-1h", // negative
}
for _, variable := range variables {
for _, value := range values {
t.Run(variable+"="+value, func(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv(variable, value)
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrInvalidInterval)
require.ErrorContains(t, err, variable)
require.ErrorContains(t, err, strconv.Quote(value))
})
}
}
}
func TestNew_EmptyValueMeansDefault(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "")
t.Setenv("DNSWATCHER_TLS_INTERVAL", "")
t.Setenv("PORT", "")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "")
t.Setenv("DNSWATCHER_DEBUG", "")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "")
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, time.Hour, cfg.DNSInterval)
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
assert.Equal(t, 8080, cfg.Port)
assert.Equal(t, 7, cfg.TLSExpiryWarning)
assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
assert.False(t, cfg.SendTestNotification)
}
// TestNew_InvalidNumberOrBoolStopsStartup checks values that must stop
// startup; TestNew_EnvironmentOverrides, the two tests after this one and
// TestNew_EmptyValueMeansDefault check values that must not.
func TestNew_InvalidNumberOrBoolStopsStartup(t *testing.T) {
notBools := []string{"yes", "on", "2", "tRUE"}
tests := []struct {
variable string
values []string
want error
}{
{
variable: "PORT",
values: []string{"notaport", "0", "-1", "65536", "8080.5"},
want: config.ErrInvalidPort,
},
{
variable: "DNSWATCHER_TLS_EXPIRY_WARNING",
values: []string{"abc", "7d", "0", "-5"},
want: config.ErrInvalidTLSExpiryWarning,
},
{
variable: "DNSWATCHER_DEBUG",
values: notBools,
want: config.ErrInvalidBool,
},
{
variable: "DNSWATCHER_MAINTENANCE_MODE",
values: notBools,
want: config.ErrInvalidBool,
},
{
variable: "DNSWATCHER_SEND_TEST_NOTIFICATION",
values: notBools,
want: config.ErrInvalidBool,
},
}
for _, tt := range tests {
for _, value := range tt.values {
t.Run(tt.variable+"="+value, func(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv(tt.variable, value)
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, tt.want)
require.ErrorContains(t, err, tt.variable)
require.ErrorContains(t, err, strconv.Quote(value))
})
}
}
}
func TestNew_LowestNumbersAndOtherSpellingsOfTrue(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "1")
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "1")
t.Setenv("DNSWATCHER_DEBUG", "1")
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "T")
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "True")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 1, cfg.Port)
assert.Equal(t, 1, cfg.TLSExpiryWarning)
assert.True(t, cfg.Debug)
assert.True(t, cfg.MaintenanceMode)
assert.True(t, cfg.SendTestNotification)
}
func TestNew_HighestPort(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "65535")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 65535, cfg.Port)
}
func TestNew_DebugEnablesDebugLogging(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DEBUG", "true")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.True(t, cfg.Debug)
}
func TestNew_PortEnvNotPrefixed(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("PORT", "3000")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 3000, cfg.Port,
"PORT env should work without DNSWATCHER_ prefix")
}
func TestNew_TargetClassification(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS",
"example.com,www.example.com,api.example.com,example.org")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
// example.com and example.org are apex domains
assert.Len(t, cfg.Domains, 2)
// www.example.com and api.example.com are hostnames
assert.Len(t, cfg.Hostnames, 2)
}
func TestNew_InvalidTargetPublicSuffix(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "co.uk")
_, err := config.New(nil, newTestParams(t))
require.Error(t, err, "public suffix should be rejected")
}
func TestNew_EmptyAppnameDefaultsToDnswatcher(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
g := &globals.Globals{Appname: "", Version: "test"}
l, err := logger.New(nil, logger.Params{Globals: g})
require.NoError(t, err)
cfg, err := config.New(
nil, config.Params{Globals: g, Logger: l},
)
require.NoError(t, err)
assert.Equal(t, 8080, cfg.Port,
"defaults should load when appname is empty")
}
func TestNew_TargetsWithWhitespace(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", " example.com , www.example.com ")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
"whitespace around targets should be trimmed")
}
func TestNew_TargetsWithTrailingComma(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com,")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
"trailing comma should be ignored")
}
func TestNew_SkipRecordNotifications(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.net,www.example.net,example.org")
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
" WWW.Example.net. , example.net,www.example.net")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t,
[]string{"www.example.net", "example.net"},
cfg.SkipRecordNotifications,
"names are written as targets are, each once",
)
}
func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.net")
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
"example.net,www.example.net")
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrNotInTargets)
require.ErrorContains(t, err, "DNSWATCHER_SKIP_RECORD_NOTIFICATIONS")
require.ErrorContains(t, err, `"www.example.net"`)
}
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_DNS_INTERVAL", "5s")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t, 5*time.Second, cfg.DNSInterval)
}
func TestStatePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dataDir string
want string
}{
{"default", "/var/lib/dnswatcher", "/var/lib/dnswatcher/state.json"},
{"absolute", "/var/lib/dw", "/var/lib/dw/state.json"},
{"nested", "/opt/app/data", "/opt/app/data/state.json"},
{"empty", "", "/state.json"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &config.Config{DataDir: tt.dataDir}
assert.Equal(t, tt.want, cfg.StatePath())
})
}
}