Files
summercms/modules/diary/config_test.go
Jakub Zych 24f4643283 test(diary): config, channels, rotation, redaction and slogtest
- config rules name their key; env strings overlay defaults; D-08 defaults
- component floors, verbosity 1-3, quiet on the console only
- daily rotation, retention, 0640 files, unwritable dir, lazy open, path confinement
- redaction on every driver, console control-character stripping and prefix
- loggers before Setup, Reset, HideOnTTY, 100 concurrent writers
- testing/slogtest for the console handler and the JSON pipeline
- sunscreen InstallDefault still installs a redacting logfmt handler
2026-10-21 19:09:28 +02:00

203 lines
7.9 KiB
Go

package diary_test
import (
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/diary"
)
// TestConfigRules: every invalid value in config/log.yaml fails with an
// error naming its exact key; YAML lists and comma strings parse alike.
func TestConfigRules(t *testing.T) {
cases := []struct {
name string
yaml string
key string
}{
{"level", "level: loud\n", "log.level"},
{"default undefined", "default: nowhere\n", "log.default"},
{"default empty", "default: \"\"\n", "log.default"},
{"driver", "channels:\n daily:\n driver: syslog\n", "log.channels.daily.driver"},
{"driver missing", "channels:\n extra:\n level: info\n", "log.channels.extra.driver"},
{"format", "channels:\n console:\n format: xml\n", "log.channels.console.format"},
{"auto outside console", "channels:\n stderr:\n format: auto\n", "log.channels.stderr.format"},
{"channel level", "channels:\n daily:\n level: chatty\n", "log.channels.daily.level"},
{"days negative", "channels:\n daily:\n days: -3\n", "log.channels.daily.days"},
{"days fraction", "channels:\n daily:\n days: 1.5\n", "log.channels.daily.days"},
{"days text", "channels:\n daily:\n days: often\n", "log.channels.daily.days"},
{"unknown setting", "channels:\n daily:\n rotate: hourly\n", "log.channels.daily.rotate"},
{"channel not a map", "channels:\n daily: yes\n", "log.channels.daily"},
{"daily path missing", "channels:\n daily:\n path: \"\"\n", "log.channels.daily.path"},
{"path escapes", "channels:\n daily:\n path: ../outside/summer.log\n", "log.channels.daily.path"},
{"path escapes after clean", "channels:\n daily:\n path: storage/../../outside.log\n", "log.channels.daily.path"},
{"stack empty", "channels:\n stack:\n channels: []\n", "log.channels.stack.channels"},
{"stack child", "channels:\n stack:\n channels: [console, nowhere]\n", "log.channels.stack.channels"},
{"stack self", "channels:\n stack:\n channels: [console, stack]\n", "log.channels.stack.channels"},
{"stack cycle", "default: a\nchannels:\n a:\n driver: stack\n channels: [console, b]\n b:\n driver: stack\n channels: [a]\n", "log.channels.a.channels"},
{"component level", "components:\n river: chatty\n", "log.components.river"},
{"component nested", "components:\n golem15:\n blog: info\n", "log.components.golem15"},
{"components not a map", "components: warn\n", "log.components"},
{"channels not a map", "channels: console\n", "log.channels"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := diary.ParseConfig(openYAMLConfig(t, tc.yaml))
if err == nil {
t.Fatalf("ParseConfig accepted:\n%s", tc.yaml)
}
if !strings.Contains(err.Error(), tc.key) {
t.Errorf("error %q does not name %s", err, tc.key)
}
})
}
// A cycle error names the path it found.
_, err := diary.ParseConfig(openYAMLConfig(t, cases[19].yaml))
if err == nil || !strings.Contains(err.Error(), "a -> b -> a") {
t.Errorf("cycle error = %v, want the path a -> b -> a", err)
}
// A YAML list, a comma string in YAML and a comma string from the
// environment give the same stack.
list, err := diary.ParseConfig(openYAMLConfig(t, "channels:\n stack:\n channels: [console, daily]\n"))
if err != nil {
t.Fatal(err)
}
comma, err := diary.ParseConfig(openYAMLConfig(t, "channels:\n stack:\n channels: \" console , daily \"\n"))
if err != nil {
t.Fatal(err)
}
env, err := diary.ParseConfig(openConfig(t, "SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily"))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(list, comma) || !reflect.DeepEqual(list, env) {
t.Errorf("list, comma and env stacks differ:\n list %+v\ncomma %+v\n env %+v", list.Channels["stack"], comma.Channels["stack"], env.Channels["stack"])
}
// Levels accept warning as warn and any case; days 0 keeps every file.
got, err := diary.ParseConfig(openYAMLConfig(t, "level: WARNING\nchannels:\n daily:\n days: 0\n"))
if err != nil {
t.Fatal(err)
}
if got.Level != slog.LevelWarn || got.Channels["daily"].Days != 0 {
t.Errorf("level %v days %d, want WARN and 0", got.Level, got.Channels["daily"].Days)
}
// compass keeps a dotted YAML key literal, so it does not nest; only
// the nested form (above, or from SUMMER_LOG__ variables) is refused.
got, err = diary.ParseConfig(openYAMLConfig(t, "components:\n golem15.blog: debug\n"))
if err != nil {
t.Fatal(err)
}
if got.Components["golem15.blog"] != slog.LevelDebug {
t.Errorf("literal dotted component = %v", got.Components)
}
}
// TestConfigEnvStrings: SUMMER_LOG__ overrides arrive as strings and
// overlay the defaults key by key, also on top of config/log.yaml.
func TestConfigEnvStrings(t *testing.T) {
got, err := diary.ParseConfig(openConfig(t,
"SUMMER_LOG__LEVEL=debug",
"SUMMER_LOG__CHANNELS__DAILY__LEVEL=error",
"SUMMER_LOG__CHANNELS__DAILY__DAYS=7",
"SUMMER_LOG__COMPONENTS__JOURNAL=debug",
))
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
want := diary.DefaultConfig()
want.Level = slog.LevelDebug
daily := want.Channels["daily"]
daily.Level, daily.HasLevel, daily.Days = slog.LevelError, true, 7
want.Channels["daily"] = daily
want.Components["journal"] = slog.LevelDebug
if !reflect.DeepEqual(got, want) {
t.Errorf("env overlay:\n got %+v\nwant %+v", got, want)
}
// The environment wins over the file, and keys the environment does
// not set keep the file's values.
got, err = diary.ParseConfig(openYAMLConfig(t,
"channels:\n daily:\n path: var/log/app.log\n days: 3\n",
"SUMMER_LOG__CHANNELS__DAILY__DAYS=9",
))
if err != nil {
t.Fatal(err)
}
if d := got.Channels["daily"]; d.Path != "var/log/app.log" || d.Days != 9 || d.Format != diary.FormatJSON || d.Level != slog.LevelWarn {
t.Errorf("daily channel = %+v, want path var/log/app.log, days 9 and the default format and level", d)
}
// A nil config gives the defaults.
none, err := diary.ParseConfig(nil)
if err != nil || !reflect.DeepEqual(none, diary.DefaultConfig()) {
t.Errorf("ParseConfig(nil) = %+v, %v; want the defaults", none, err)
}
}
// TestDefaultConfig: the defaults equal the D-08 example (components river
// warn), and the config/log.yaml that summer new writes parses to the
// defaults plus its http component.
func TestDefaultConfig(t *testing.T) {
example := `default: stack
level: info
channels:
stack:
driver: stack
channels: [console, daily]
console:
driver: console
format: auto
daily:
driver: daily
path: storage/logs/summer.log
days: 14
format: json
level: warn
components:
river: warn
`
got, err := diary.ParseConfig(openYAMLConfig(t, example))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, diary.DefaultConfig()) {
t.Errorf("D-08 example:\n got %+v\nwant %+v", got, diary.DefaultConfig())
}
def := diary.DefaultConfig()
if def.Default != "stack" || def.Level != slog.LevelInfo || def.Components["river"] != slog.LevelWarn || len(def.Components) != 1 {
t.Errorf("DefaultConfig head = %q %v %v", def.Default, def.Level, def.Components)
}
for _, name := range []string{"stack", "console", "daily", "stderr", "null"} {
if _, ok := def.Channels[name]; !ok {
t.Errorf("DefaultConfig lacks channel %q", name)
}
}
// The defaults are a fresh copy each time.
def.Components["river"] = slog.LevelDebug
if diary.DefaultConfig().Components["river"] != slog.LevelWarn {
t.Error("DefaultConfig returns shared maps")
}
generated, err := os.ReadFile(filepath.Join("..", "..", "internal", "build", "testdata", "new", "config__log.yaml.golden"))
if err != nil {
t.Fatal(err)
}
got, err = diary.ParseConfig(openYAMLConfig(t, string(generated)))
if err != nil {
t.Fatalf("generated config/log.yaml: %v", err)
}
want := diary.DefaultConfig()
want.Components["http"] = slog.LevelInfo
if !reflect.DeepEqual(got, want) {
t.Errorf("generated config/log.yaml:\n got %+v\nwant %+v", got, want)
}
}