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) } }