Files
summercms/modules/diary/config_smoke_test.go
Jakub Zych 5b09b32512 feat(diary): channels, components and config/log.yaml
- stderr and null drivers; per-component levels from log.components
- SUMMER_LOG__ overrides parse as strings and overlay the defaults key by key
- Validation names the key: driver, format, level, days, stack children and cycles, daily path outside the root
- summer new writes config/log.yaml (stack of console and daily, river warn, http info)
- docs/services/logging.md: channels, config/log.yaml, component loggers, daily files, redaction
- Log keys in the configuration docs; ExampleFor backs the docs snippet
2026-10-21 11:48:25 +02:00

136 lines
4.9 KiB
Go

package diary_test
import (
"bytes"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/diary"
)
// openConfig opens a compass config from an empty config directory with
// only the given environment.
func openConfig(t *testing.T, environ ...string) *compass.Config {
t.Helper()
dir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{}, environ...)})
if err != nil {
t.Fatal(err)
}
return cfg
}
// TestConfigEnvSmoke: SUMMER_LOG__ overrides arrive as strings, parse, and
// overlay the framework defaults key by key; every invalid value fails with
// an error naming its key.
func TestConfigEnvSmoke(t *testing.T) {
cfg := openConfig(t,
"SUMMER_LOG__CHANNELS__DAILY__DAYS=30",
"SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily",
"SUMMER_LOG__COMPONENTS__RIVER=error",
)
got, err := diary.ParseConfig(cfg)
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
want := diary.DefaultConfig()
daily := want.Channels["daily"]
daily.Days = 30
want.Channels["daily"] = daily
want.Components["river"] = slog.LevelError
if !reflect.DeepEqual(got, want) {
t.Errorf("parsed config:\n got %+v\nwant %+v", got, want)
}
if ch := got.Channels["stack"].Channels; !reflect.DeepEqual(ch, []string{"console", "daily"}) {
t.Errorf("stack children = %q, want console, daily", ch)
}
errorCases := []struct {
name string
environ []string
key string
}{
{"level", []string{"SUMMER_LOG__LEVEL=loud"}, "log.level"},
{"default", []string{"SUMMER_LOG__DEFAULT=missing"}, "log.default"},
{"driver", []string{"SUMMER_LOG__CHANNELS__DAILY__DRIVER=syslog"}, "log.channels.daily.driver"},
{"driver required", []string{"SUMMER_LOG__CHANNELS__EXTRA__LEVEL=info"}, "log.channels.extra.driver"},
{"format", []string{"SUMMER_LOG__CHANNELS__CONSOLE__FORMAT=xml"}, "log.channels.console.format"},
{"auto outside console", []string{"SUMMER_LOG__CHANNELS__DAILY__FORMAT=auto"}, "log.channels.daily.format"},
{"channel level", []string{"SUMMER_LOG__CHANNELS__CONSOLE__LEVEL=verbose"}, "log.channels.console.level"},
{"days", []string{"SUMMER_LOG__CHANNELS__DAILY__DAYS=-1"}, "log.channels.daily.days"},
{"days text", []string{"SUMMER_LOG__CHANNELS__DAILY__DAYS=two"}, "log.channels.daily.days"},
{"path escapes root", []string{"SUMMER_LOG__CHANNELS__DAILY__PATH=../outside/summer.log"}, "log.channels.daily.path"},
{"stack child", []string{"SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,nowhere"}, "log.channels.stack.channels"},
{"stack cycle", []string{
"SUMMER_LOG__DEFAULT=a",
"SUMMER_LOG__CHANNELS__A__DRIVER=stack",
"SUMMER_LOG__CHANNELS__A__CHANNELS=console,b",
"SUMMER_LOG__CHANNELS__B__DRIVER=stack",
"SUMMER_LOG__CHANNELS__B__CHANNELS=a",
}, "log.channels.a.channels"},
{"component level", []string{"SUMMER_LOG__COMPONENTS__RIVER=chatty"}, "log.components.river"},
}
for _, tc := range errorCases {
_, err := diary.ParseConfig(openConfig(t, tc.environ...))
if err == nil {
t.Errorf("%s: ParseConfig accepted %q", tc.name, tc.environ)
continue
}
if !strings.Contains(err.Error(), tc.key) {
t.Errorf("%s: error %q does not name %s", tc.name, err, tc.key)
}
}
}
// TestDailyDegradesSmoke: a daily channel whose directory cannot be created
// warns once on the console, is disabled, and never fails Setup or stops
// later records from reaching the console.
func TestDailyDegradesSmoke(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
root := t.TempDir()
readOnly := filepath.Join(root, "readonly")
if err := os.Mkdir(readOnly, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(readOnly, 0o755) })
cfg := openConfig(t, "SUMMER_LOG__CHANNELS__DAILY__PATH=readonly/logs/summer.log")
var buf bytes.Buffer
t.Cleanup(diary.Reset)
if err := diary.Setup(cfg, diary.Options{Root: root, Stderr: &buf, Color: bonfire.ColorNever}); err != nil {
t.Fatalf("Setup failed because of the log file: %v", err)
}
logger := diary.For("journal")
logger.Warn("first warning")
logger.Warn("second warning")
logger.Info("still logging")
out := buf.String()
if n := strings.Count(out, "daily log file disabled"); n != 1 {
t.Errorf("daily warning appears %d times, want 1:\n%s", n, out)
}
if !strings.Contains(out, filepath.Join(readOnly, "logs")) {
t.Errorf("warning does not name the path:\n%s", out)
}
if !strings.Contains(out, "component=diary") {
t.Errorf("warning does not come from component diary:\n%s", out)
}
for _, want := range []string{"first warning", "second warning", "still logging"} {
if !strings.Contains(out, want) {
t.Errorf("console lacks %q after the daily channel failed:\n%s", want, out)
}
}
}