Stop startup when a setting does not parse (closes #219)
check / check (push) Canceled after 0s

config.New read the number and true/false settings with viper's
GetInt, GetFloat64 and GetBool. These return zero or false for a value
that does not parse, so a typo silently changed how the server ran.

config.New now parses these settings with strconv. It also checks that
MESSAGE_MAX_AGE, QUEUE_MAX_AGE and SESSION_IDLE_TIMEOUT are valid Go
durations. A value that does not parse makes config.New return an
error naming the setting and its value, and startup stops. Unset
settings keep their defaults.

Model: opus-5-5
This commit is contained in:
2026-10-27 21:43:30 +00:00
parent 83a1585ddc
commit 39cbb34ccc
2 changed files with 216 additions and 9 deletions
+141 -2
View File
@@ -2,6 +2,7 @@ package config_test
import (
"os"
"strings"
"testing"
"time"
@@ -10,7 +11,8 @@ import (
"sneak.berlin/go/neoirc/internal/logger"
)
func newConfig(t *testing.T) *config.Config {
// loadConfig runs config.New and returns its result.
func loadConfig(t *testing.T) (*config.Config, error) {
t.Helper()
globs := &globals.Globals{
@@ -24,9 +26,15 @@ func newConfig(t *testing.T) *config.Config {
t.Fatal(err)
}
cfg, err := config.New(
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)
}
@@ -34,6 +42,137 @@ func newConfig(t *testing.T) *config.Config {
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) {
t.Setenv("PORT", "9000")
t.Setenv("MAX_MESSAGE_SIZE", "1024")
t.Setenv("NEOIRC_HASHCASH_BITS", "0")
t.Setenv("LOGIN_RATE_BURST", "10")
t.Setenv("LOGIN_RATE_LIMIT", "0.5")
t.Setenv("DEBUG", "true")
t.Setenv("MAINTENANCE_MODE", "true")
t.Setenv("MESSAGE_MAX_AGE", "24h")
t.Setenv("QUEUE_MAX_AGE", "1h30m")
t.Setenv("SESSION_IDLE_TIMEOUT", "0")
want := settings{
Port: 9000,
MaxMessageSize: 1024,
HashcashBits: 0,
LoginRateBurst: 10,
LoginRateLimit: 0.5,
Debug: true,
MaintenanceMode: true,
MessageMaxAge: "24h",
QueueMaxAge: "1h30m",
SessionIdleTimeout: "0",
}
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 TestUnparseableSettingStopsStartup(t *testing.T) {
tests := []struct {
key string
value string
}{
{key: "PORT", value: "80a"},
{key: "MAX_MESSAGE_SIZE", value: "4k"},
{key: "NEOIRC_HASHCASH_BITS", value: "twenty"},
{key: "LOGIN_RATE_BURST", value: "five"},
{key: "LOGIN_RATE_LIMIT", value: "fast"},
{key: "DEBUG", value: "yes"},
{key: "MAINTENANCE_MODE", value: "on"},
{key: "MESSAGE_MAX_AGE", value: "30d"},
{key: "QUEUE_MAX_AGE", value: "720"},
{key: "SESSION_IDLE_TIMEOUT", value: "a month"},
}
for _, test := range tests {
t.Run(test.key, func(t *testing.T) {
t.Setenv(test.key, test.value)
_, err := loadConfig(t)
if err == nil {
t.Fatalf("%s=%q: got no error", test.key, test.value)
}
msg := err.Error()
if !strings.Contains(msg, test.key) ||
!strings.Contains(msg, test.value) {
t.Errorf("error %q does not name %s and %q",
msg, test.key, test.value)
}
})
}
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "")