check / check (push) Failing after 3s
Every setting is now read through one helper that uses cast.ToStringE, the conversion viper.GetString wraps, keeping its error instead of returning the empty string. TRUSTED_PROXIES uses the same helper. A config file that does not parse makes New return an error naming the file instead of panicking. Model: opus-5-5
440 lines
11 KiB
Go
440 lines
11 KiB
Go
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)
|
|
}
|
|
}
|