1 Commits
Author SHA1 Message Date
sneak 39cbb34ccc Stop startup when a setting does not parse (closes #219)
check / check (push) Waiting to run
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
2026-10-27 21:43:30 +00:00
5 changed files with 52 additions and 152 deletions
-4
View File
@@ -627,10 +627,6 @@ func (a *App) cmdWindow(args string) {
nick, buf.Name, "connected", nick, buf.Name, "connected",
) )
} else { } else {
a.mu.Lock()
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", "connected") a.ui.SetStatus(nick, "", "connected")
} }
} }
-57
View File
@@ -219,63 +219,6 @@ func TestJoinResponse(t *testing.T) {
} }
} }
// TestAltDigit checks that Alt and a digit shows that window and
// makes its channel or user the target, as /window does. Window
// 0, the status window, leaves no target.
func TestAltDigit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
digit rune
// The buffer shown and the target after the key.
wantBuffer string
wantTarget string
}{
{
name: "channel window",
digit: '1',
wantBuffer: testChannel,
wantTarget: testChannel,
},
{
name: "status window",
digit: '0',
wantBuffer: "(status)",
wantTarget: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick, target: testOtherNick}
app.ui.OnInput(app.handleInput)
runUI(t, app.ui)
// Window 1 is the channel. Window 2, the one shown, is
// the other user's.
app.ui.SwitchToBuffer(testChannel)
app.ui.SwitchToBuffer(testOtherNick)
key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt)
app.ui.app.GetInputCapture()(key)
var shown string
app.ui.app.QueueUpdate(func() {
shown = app.ui.buffers[app.ui.currentBuffer].Name
})
if shown != test.wantBuffer || app.target != test.wantTarget {
t.Fatalf("got buffer %s and target %q, want %s and %q",
shown, app.target, test.wantBuffer, test.wantTarget)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice, // TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another // join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to. // client shows as one line in the buffer it belongs to.
+2 -5
View File
@@ -240,13 +240,10 @@ func (ui *UI) initKeyCapture() {
return event return event
} }
// Alt and a digit runs /window with that digit.
// SwitchBuffer alone would leave the target as it was.
r := event.Rune() r := event.Rune()
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
if ui.onInput != nil { idx := int(r - '0')
ui.onInput("/window " + string(r)) ui.SwitchBuffer(idx)
}
return nil return nil
} }
+14 -42
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"os" "os"
"strconv" "strconv"
"time" "time"
@@ -32,8 +31,6 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" + const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
var errOutOfRange = errors.New("setting out of range")
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -70,7 +67,7 @@ type Config struct {
// New creates a new Config by reading from files and environment variables. // New creates a new Config by reading from files and environment variables.
// It returns an error when a number, true/false or duration setting does // It returns an error when a number, true/false or duration setting does
// not parse or is out of range. // not parse.
func New( func New(
_ fx.Lifecycle, params Params, _ fx.Lifecycle, params Params,
) (*Config, error) { ) (*Config, error) {
@@ -152,16 +149,14 @@ func New(
} }
// parseSettings reads the number and true/false settings into cfg and // parseSettings reads the number and true/false settings into cfg and
// checks the duration settings. Each number and duration must be above // checks that each duration setting is a valid Go duration. Its error
// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the // names each setting whose value does not parse.
// system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error { func parseSettings(cfg *Config) error {
return errors.Join( return errors.Join(
parseInt("PORT", 0, &cfg.Port), parseInt("PORT", &cfg.Port),
parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize), parseInt("MAX_MESSAGE_SIZE", &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits), parseInt("NEOIRC_HASHCASH_BITS", &cfg.HashcashBits),
parseInt("LOGIN_RATE_BURST", 1, &cfg.LoginRateBurst), parseInt("LOGIN_RATE_BURST", &cfg.LoginRateBurst),
parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit), parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit),
parseBool("DEBUG", &cfg.Debug), parseBool("DEBUG", &cfg.Debug),
parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode), parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode),
@@ -171,43 +166,25 @@ func parseSettings(cfg *Config) error {
) )
} }
// parseInt sets *value to the setting key read as a whole number. It // parseInt sets *value to the setting key read as a whole number.
// returns an error when the value does not parse or is below minimum. func parseInt(key string, value *int) error {
func parseInt(key string, minimum int, value *int) error { parsed, err := strconv.Atoi(viper.GetString(key))
raw := viper.GetString(key)
parsed, err := strconv.Atoi(raw)
if err != nil { if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err) return fmt.Errorf("cannot parse %s: %w", key, err)
} }
if parsed < minimum {
return fmt.Errorf("%w: %s is %s, but must be at least %d",
errOutOfRange, key, raw, minimum)
}
*value = parsed *value = parsed
return nil return nil
} }
// parseFloat sets *value to the setting key read as a floating-point // parseFloat sets *value to the setting key read as a decimal number.
// number. It returns an error when the value does not parse or is not a
// finite number above zero.
func parseFloat(key string, value *float64) error { func parseFloat(key string, value *float64) error {
raw := viper.GetString(key) parsed, err := strconv.ParseFloat(viper.GetString(key), 64)
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil { if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err) return fmt.Errorf("cannot parse %s: %w", key, err)
} }
if math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 {
return fmt.Errorf(
"%w: %s is %s, but must be a finite number above zero",
errOutOfRange, key, raw)
}
*value = parsed *value = parsed
return nil return nil
@@ -226,17 +203,12 @@ func parseBool(key string, value *bool) error {
} }
// checkDuration returns an error naming the setting key when value is // checkDuration returns an error naming the setting key when value is
// not a Go duration above zero. // not a valid Go duration.
func checkDuration(key, value string) error { func checkDuration(key, value string) error {
duration, err := time.ParseDuration(value) _, err := time.ParseDuration(value)
if err != nil { if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err) return fmt.Errorf("cannot parse %s: %w", key, err)
} }
if duration <= 0 {
return fmt.Errorf("%w: %s is %s, but must be above zero",
errOutOfRange, key, value)
}
return nil return nil
} }
+27 -35
View File
@@ -78,30 +78,28 @@ func TestUnsetSettingsKeepDefaults(t *testing.T) {
} }
func TestValidSettingsAreUsed(t *testing.T) { func TestValidSettingsAreUsed(t *testing.T) {
// Each whole number is the lowest value its setting allows, and 1ns t.Setenv("PORT", "9000")
// is the shortest duration allowed. t.Setenv("MAX_MESSAGE_SIZE", "1024")
t.Setenv("PORT", "0")
t.Setenv("MAX_MESSAGE_SIZE", "1")
t.Setenv("NEOIRC_HASHCASH_BITS", "0") t.Setenv("NEOIRC_HASHCASH_BITS", "0")
t.Setenv("LOGIN_RATE_BURST", "1") t.Setenv("LOGIN_RATE_BURST", "10")
t.Setenv("LOGIN_RATE_LIMIT", "0.5") t.Setenv("LOGIN_RATE_LIMIT", "0.5")
t.Setenv("DEBUG", "true") t.Setenv("DEBUG", "true")
t.Setenv("MAINTENANCE_MODE", "true") t.Setenv("MAINTENANCE_MODE", "true")
t.Setenv("MESSAGE_MAX_AGE", "1ns") t.Setenv("MESSAGE_MAX_AGE", "24h")
t.Setenv("QUEUE_MAX_AGE", "1h30m") t.Setenv("QUEUE_MAX_AGE", "1h30m")
t.Setenv("SESSION_IDLE_TIMEOUT", "45m") t.Setenv("SESSION_IDLE_TIMEOUT", "0")
want := settings{ want := settings{
Port: 0, Port: 9000,
MaxMessageSize: 1, MaxMessageSize: 1024,
HashcashBits: 0, HashcashBits: 0,
LoginRateBurst: 1, LoginRateBurst: 10,
LoginRateLimit: 0.5, LoginRateLimit: 0.5,
Debug: true, Debug: true,
MaintenanceMode: true, MaintenanceMode: true,
MessageMaxAge: "1ns", MessageMaxAge: "24h",
QueueMaxAge: "1h30m", QueueMaxAge: "1h30m",
SessionIdleTimeout: "45m", SessionIdleTimeout: "0",
} }
got := settingsOf(newConfig(t)) got := settingsOf(newConfig(t))
@@ -139,47 +137,41 @@ func settingsOf(cfg *config.Config) settings {
} }
} }
func TestBadSettingStopsStartup(t *testing.T) { func TestUnparseableSettingStopsStartup(t *testing.T) {
// For each setting, the first value does not parse and the others
// parse but are out of range.
tests := []struct { tests := []struct {
key string key string
values []string value string
}{ }{
{key: "PORT", values: []string{"80a", "-1"}}, {key: "PORT", value: "80a"},
{key: "MAX_MESSAGE_SIZE", values: []string{"4k", "0", "-4096"}}, {key: "MAX_MESSAGE_SIZE", value: "4k"},
{key: "NEOIRC_HASHCASH_BITS", values: []string{"twenty", "-1"}}, {key: "NEOIRC_HASHCASH_BITS", value: "twenty"},
{key: "LOGIN_RATE_BURST", values: []string{"five", "0", "-5"}}, {key: "LOGIN_RATE_BURST", value: "five"},
{key: "LOGIN_RATE_LIMIT", values: []string{ {key: "LOGIN_RATE_LIMIT", value: "fast"},
"fast", "0", "-1", "NaN", "Inf", "-Inf", {key: "DEBUG", value: "yes"},
}}, {key: "MAINTENANCE_MODE", value: "on"},
{key: "DEBUG", values: []string{"yes"}}, {key: "MESSAGE_MAX_AGE", value: "30d"},
{key: "MAINTENANCE_MODE", values: []string{"on"}}, {key: "QUEUE_MAX_AGE", value: "720"},
{key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}}, {key: "SESSION_IDLE_TIMEOUT", value: "a month"},
{key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}},
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
} }
for _, test := range tests { for _, test := range tests {
for _, value := range test.values { t.Run(test.key, func(t *testing.T) {
t.Run(test.key+"="+value, func(t *testing.T) { t.Setenv(test.key, test.value)
t.Setenv(test.key, value)
_, err := loadConfig(t) _, err := loadConfig(t)
if err == nil { if err == nil {
t.Fatalf("%s=%q: got no error", test.key, value) t.Fatalf("%s=%q: got no error", test.key, test.value)
} }
msg := err.Error() msg := err.Error()
if !strings.Contains(msg, test.key) || if !strings.Contains(msg, test.key) ||
!strings.Contains(msg, value) { !strings.Contains(msg, test.value) {
t.Errorf("error %q does not name %s and %q", t.Errorf("error %q does not name %s and %q",
msg, test.key, value) msg, test.key, test.value)
} }
}) })
} }
} }
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) { func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test. // t.Setenv puts back whatever value was there after the test.