package config_test import ( "net/netip" "os" "path/filepath" "strings" "testing" "time" "github.com/spf13/viper" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/logger" ) // loadConfig runs config.New and returns its result. func loadConfig(t *testing.T) (*config.Config, error) { t.Helper() globs := &globals.Globals{ Appname: "neoirc-test", Version: "test", StartTime: time.Now(), } log, err := logger.New(nil, logger.Params{Globals: globs}) if err != nil { t.Fatal(err) } return config.New( nil, config.Params{Globals: globs, Logger: log}, ) } func newConfig(t *testing.T) *config.Config { t.Helper() cfg, err := loadConfig(t) if err != nil { t.Fatal(err) } return cfg } func TestUnsetSettingsKeepDefaults(t *testing.T) { // Viper counts an empty environment variable as unset. t.Setenv("PORT", "") t.Setenv("MAX_MESSAGE_SIZE", "") t.Setenv("NEOIRC_HASHCASH_BITS", "") t.Setenv("LOGIN_RATE_BURST", "") t.Setenv("LOGIN_RATE_LIMIT", "") t.Setenv("DEBUG", "") t.Setenv("MAINTENANCE_MODE", "") t.Setenv("MESSAGE_MAX_AGE", "") t.Setenv("QUEUE_MAX_AGE", "") t.Setenv("SESSION_IDLE_TIMEOUT", "") // Each duration setting defaults to 30 days. const thirtyDays = "720h" want := settings{ Port: 8080, MaxMessageSize: 4096, HashcashBits: 20, LoginRateBurst: 5, LoginRateLimit: 1, Debug: false, MaintenanceMode: false, MessageMaxAge: thirtyDays, QueueMaxAge: thirtyDays, SessionIdleTimeout: thirtyDays, } got := settingsOf(newConfig(t)) if got != want { t.Errorf("settings = %+v, want %+v", got, want) } } func TestValidSettingsAreUsed(t *testing.T) { // Each whole number is the lowest value its setting allows, and 1ns // is the shortest duration allowed. t.Setenv("PORT", "0") t.Setenv("MAX_MESSAGE_SIZE", "1") t.Setenv("NEOIRC_HASHCASH_BITS", "0") t.Setenv("LOGIN_RATE_BURST", "1") t.Setenv("LOGIN_RATE_LIMIT", "0.5") t.Setenv("DEBUG", "true") t.Setenv("MAINTENANCE_MODE", "true") t.Setenv("MESSAGE_MAX_AGE", "1ns") t.Setenv("QUEUE_MAX_AGE", "1h30m") t.Setenv("SESSION_IDLE_TIMEOUT", "45m") want := settings{ Port: 0, MaxMessageSize: 1, HashcashBits: 0, LoginRateBurst: 1, LoginRateLimit: 0.5, Debug: true, MaintenanceMode: true, MessageMaxAge: "1ns", QueueMaxAge: "1h30m", SessionIdleTimeout: "45m", } got := settingsOf(newConfig(t)) if got != want { t.Errorf("settings = %+v, want %+v", got, want) } } // settings holds the Config fields whose settings config.New parses. type settings struct { Port int MaxMessageSize int HashcashBits int LoginRateBurst int LoginRateLimit float64 Debug bool MaintenanceMode bool MessageMaxAge string QueueMaxAge string SessionIdleTimeout string } func settingsOf(cfg *config.Config) settings { return settings{ Port: cfg.Port, MaxMessageSize: cfg.MaxMessageSize, HashcashBits: cfg.HashcashBits, LoginRateBurst: cfg.LoginRateBurst, LoginRateLimit: cfg.LoginRateLimit, Debug: cfg.Debug, MaintenanceMode: cfg.MaintenanceMode, MessageMaxAge: cfg.MessageMaxAge, QueueMaxAge: cfg.QueueMaxAge, SessionIdleTimeout: cfg.SessionIdleTimeout, } } func TestBadSettingStopsStartup(t *testing.T) { // Each value is invalid for its setting. tests := []struct { key string values []string }{ {key: "PORT", values: []string{"80a", "-1"}}, {key: "MAX_MESSAGE_SIZE", values: []string{"4k", "0", "-4096"}}, {key: "NEOIRC_HASHCASH_BITS", values: []string{"twenty", "-1"}}, {key: "LOGIN_RATE_BURST", values: []string{"five", "0", "-5"}}, {key: "LOGIN_RATE_LIMIT", values: []string{ "fast", "0", "-1", "NaN", "Inf", "-Inf", }}, {key: "DEBUG", values: []string{"yes"}}, {key: "MAINTENANCE_MODE", values: []string{"on"}}, {key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}}, {key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}}, {key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}}, {key: "TRUSTED_PROXIES", values: []string{ "proxy.example.com", "10.0.0.0/33", "192.0.2.1:8080", "::ffff:10.0.0.0/104", "fe80::1%eth0", }}, } for _, test := range tests { for _, value := range test.values { t.Run(test.key+"="+value, func(t *testing.T) { t.Setenv(test.key, value) _, err := loadConfig(t) if err == nil { t.Fatalf("%s=%q: got no error", test.key, value) } msg := err.Error() if !strings.Contains(msg, test.key) || !strings.Contains(msg, value) { t.Errorf("error %q does not name %s and %q", msg, test.key, value) } }) } } } func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) { // t.Setenv puts back whatever value was there after the test. t.Setenv("IRC_LISTEN_ADDR", "") err := os.Unsetenv("IRC_LISTEN_ADDR") if err != nil { t.Fatal(err) } const want = ":6667" cfg := newConfig(t) if cfg.IRCListenAddr != want { t.Errorf("IRCListenAddr = %q, want %q", cfg.IRCListenAddr, want) } } func TestIRCListenAddrEmptyTurnsListenerOff(t *testing.T) { t.Setenv("IRC_LISTEN_ADDR", "") cfg := newConfig(t) if cfg.IRCListenAddr != "" { t.Errorf("IRCListenAddr = %q, want empty", cfg.IRCListenAddr) } } func TestTrustedProxies(t *testing.T) { // Each case lists addresses that must be in the trusted set and // addresses that must not. tests := []struct { name string set bool value string trusted []string untrusted []string }{ { name: "unset trusts the RFC 1918 ranges", set: false, value: "", trusted: []string{ "10.1.2.3", "172.16.0.1", "172.31.255.254", "192.168.1.1", }, untrusted: []string{ "127.0.0.1", "172.32.0.1", "192.0.2.1", "2001:db8::1", }, }, { name: "a value replaces the default", set: true, value: "198.51.100.0/24, 192.0.2.7,2001:db8::/32", trusted: []string{"198.51.100.200", "192.0.2.7", "2001:db8::5"}, untrusted: []string{"10.4.5.6", "192.168.4.4", "192.0.2.8"}, }, { name: "a mapped IPv6 address is trusted as IPv4", set: true, value: "::ffff:192.0.2.7", trusted: []string{"192.0.2.7"}, untrusted: []string{"10.7.8.9"}, }, { name: "the empty string trusts nothing", set: true, value: "", trusted: nil, untrusted: []string{"10.0.0.1", "192.168.0.1", "::1"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { // t.Setenv puts back whatever value was there after the // test, including when Unsetenv removes it. t.Setenv("TRUSTED_PROXIES", test.value) if !test.set { err := os.Unsetenv("TRUSTED_PROXIES") if err != nil { t.Fatal(err) } } proxies := newConfig(t).TrustedProxies for _, addr := range test.trusted { if !proxies.Contains(netip.MustParseAddr(addr)) { t.Errorf("%s is not trusted", addr) } } for _, addr := range test.untrusted { if proxies.Contains(netip.MustParseAddr(addr)) { t.Errorf("%s is trusted", addr) } } }) } } // unsetEnv removes the environment variable key for the rest of the // test, so that config.New reads key from the config file. func unsetEnv(t *testing.T, key string) { t.Helper() // t.Setenv puts back whatever value was there after the test, // including when Unsetenv removes it. t.Setenv(key, "") err := os.Unsetenv(key) if err != nil { t.Fatal(err) } } // writeConfigFile writes content to the config file config.New reads // when HOME is home, and returns the file's path. func writeConfigFile(t *testing.T, home, content string) string { t.Helper() // loadConfig names the app neoirc-test, so config.New reads // $HOME/.config/neoirc-test/neoirc-test.yaml. dir := filepath.Join(home, ".config", "neoirc-test") err := os.MkdirAll(dir, 0o700) if err != nil { t.Fatal(err) } path := filepath.Join(dir, "neoirc-test.yaml") err = os.WriteFile(path, []byte(content), 0o600) if err != nil { t.Fatal(err) } // Viper keeps the file and the values it read from it after the // file is gone. Reset clears them for later tests. t.Cleanup(viper.Reset) return path } func TestListOrMapInConfigFileStopsStartup(t *testing.T) { // YAML reads the first value as a list and the second as a map. values := []string{`[":6667"]`, `{addr: ":6667"}`} keys := []string{"IRC_LISTEN_ADDR", "MOTD", "TRUSTED_PROXIES"} for _, key := range keys { for _, value := range values { t.Run(key+"="+value, func(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) unsetEnv(t, key) writeConfigFile(t, home, key+": "+value+"\n") _, err := loadConfig(t) if err == nil || !strings.Contains(err.Error(), key) { t.Errorf("error = %v, want one naming %s", err, key) } }) } } } func TestListOrMapErrorLeavesOutValue(t *testing.T) { const key = "NEOIRC_OPER_PASSWORD" home := t.TempDir() t.Setenv("HOME", home) unsetEnv(t, key) // YAML reads an unquoted {hunter2} as a map. writeConfigFile(t, home, key+": {hunter2}\n") _, err := loadConfig(t) if err == nil || !strings.Contains(err.Error(), key) { t.Fatalf("error = %v, want one naming %s", err, key) } if strings.Contains(err.Error(), "hunter2") { t.Errorf("error %q shows the password", err) } } func TestEmptyEnvironmentValueWinsOverConfigFile(t *testing.T) { const ( ircListenAddr = "IRC_LISTEN_ADDR" trustedProxies = "TRUSTED_PROXIES" ) // Each setting is the empty string in the environment and a list in // the config file. The environment wins, so the list is never read. tests := []struct { key string empty func(cfg *config.Config) bool }{ { key: ircListenAddr, empty: func(cfg *config.Config) bool { return cfg.IRCListenAddr == "" }, }, { key: trustedProxies, empty: func(cfg *config.Config) bool { return len(cfg.TrustedProxies.Prefixes()) == 0 }, }, } for _, test := range tests { t.Run(test.key, func(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv(test.key, "") writeConfigFile(t, home, test.key+": [\":6667\"]\n") cfg := newConfig(t) if !test.empty(cfg) { t.Errorf("%s is not the empty string", test.key) } }) } } func TestSingleValuesInConfigFileAreUsed(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) unsetEnv(t, "PORT") unsetEnv(t, "DEBUG") unsetEnv(t, "MOTD") writeConfigFile(t, home, "PORT: 9000\nDEBUG: true\nMOTD: hello\n") cfg := newConfig(t) if cfg.Port != 9000 || !cfg.Debug || cfg.MOTD != "hello" { t.Errorf("Port, Debug, MOTD = %d, %t, %q, want 9000, true, %q", cfg.Port, cfg.Debug, cfg.MOTD, "hello") } } func TestConfigFileThatDoesNotParseStopsStartup(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) path := writeConfigFile(t, home, "MOTD: [unclosed\n") _, err := loadConfig(t) if err == nil || !strings.Contains(err.Error(), path) { t.Errorf("error = %v, want one naming %s", err, path) } }