//go:build unix package diary_test import ( "os" "path/filepath" "slices" "strings" "syscall" "testing" "git.golem15.com/golem15/summercms/modules/diary" ) // dailyOnly is the environment of a configuration whose default channel is // the daily file at info. var dailyOnly = []string{"SUMMER_LOG__DEFAULT=daily", "SUMMER_LOG__CHANNELS__DAILY__LEVEL=info"} // TestDailyRotation: a record before and one after local midnight land in // two files named by their dates. func TestDailyRotation(t *testing.T) { root := t.TempDir() clk := newClock(day(21, 23, 59, 59)) var buf lockedBuffer setup(t, diary.Options{Stderr: &buf, Root: root, Now: clk.Now}, dailyOnly...) blog := diary.For("blog") blog.Info("before midnight") clk.Set(day(22, 0, 0, 1)) blog.Info("after midnight") dir := filepath.Join(root, "storage", "logs") first := readDaily(t, filepath.Join(dir, "summer-2026-10-21.log")) second := readDaily(t, filepath.Join(dir, "summer-2026-10-22.log")) if len(first) != 1 || !strings.Contains(first[0], "before midnight") { t.Errorf("2026-10-21 file = %q, want only the record before midnight", first) } if len(second) != 1 || !strings.Contains(second[0], "after midnight") { t.Errorf("2026-10-22 file = %q, want only the record after midnight", second) } if buf.String() != "" { t.Errorf("console written although the default channel is daily:\n%s", buf.String()) } } // TestDailyRetention: with days 2 the files of today and the day before are // kept, older files of the same base are deleted, and every other file in // the directory is left alone. days 0 keeps everything. func TestDailyRetention(t *testing.T) { keep := []string{ "summer-2026-10-20.log", "summer-2026-10-21.log", "summer.log", "other-2026-10-01.log", "summer-2026-10-01.json", "summer-latest.log", "summer-2026-1-1.log", "notes.txt", "xsummer-2026-10-01.log", } gone := []string{"summer-2026-10-19.log", "summer-2026-10-18.log", "summer-2025-01-01.log"} prepare := func(t *testing.T) string { t.Helper() root := t.TempDir() dir := filepath.Join(root, "storage", "logs") if err := os.MkdirAll(filepath.Join(dir, "summer-2026-10-01.log"), 0o750); err != nil { t.Fatal(err) } for _, name := range append(slices.Clone(keep), gone...) { if err := os.WriteFile(filepath.Join(dir, name), []byte("old\n"), 0o640); err != nil { t.Fatal(err) } } return root } list := func(t *testing.T, root string) []string { t.Helper() entries, err := os.ReadDir(filepath.Join(root, "storage", "logs")) if err != nil { t.Fatal(err) } var names []string for _, e := range entries { names = append(names, e.Name()) } return names } root := prepare(t) setup(t, diary.Options{Root: root, Stderr: &lockedBuffer{}}, append(slices.Clone(dailyOnly), "SUMMER_LOG__CHANNELS__DAILY__DAYS=2")...) diary.For("blog").Info("today") names := list(t, root) for _, name := range append(slices.Clone(keep), "summer-2026-10-01.log") { if !slices.Contains(names, name) { t.Errorf("retention deleted %s; left %v", name, names) } } for _, name := range gone { if slices.Contains(names, name) { t.Errorf("retention kept %s; left %v", name, names) } } today := readDaily(t, filepath.Join(root, "storage", "logs", "summer-2026-10-21.log")) if len(today) != 2 || today[0] != "old" || !strings.Contains(today[1], `"msg":"today"`) { t.Errorf("today's file = %q, want the old line and the new record appended", today) } diary.Reset() root = prepare(t) setup(t, diary.Options{Root: root, Stderr: &lockedBuffer{}}, append(slices.Clone(dailyOnly), "SUMMER_LOG__CHANNELS__DAILY__DAYS=0")...) diary.For("blog").Info("today") names = list(t, root) for _, name := range append(slices.Clone(keep), gone...) { if !slices.Contains(names, name) { t.Errorf("days 0 deleted %s", name) } } } // TestDailyPermissions: a new daily file is 0640 whatever the umask, its // directory 0750 under the usual umask, and an existing file keeps its mode. func TestDailyPermissions(t *testing.T) { old := syscall.Umask(0o022) t.Cleanup(func() { syscall.Umask(old) }) root := t.TempDir() setup(t, diary.Options{Root: root, Stderr: &lockedBuffer{}}, dailyOnly...) diary.For("blog").Info("first") dir := filepath.Join(root, "storage", "logs") file := filepath.Join(dir, "summer-2026-10-21.log") readDaily(t, file) assertMode(t, file, 0o640) assertMode(t, dir, 0o750) // A strict umask cannot strip the file's group read bit. diary.Reset() syscall.Umask(0o077) root = t.TempDir() setup(t, diary.Options{Root: root, Stderr: &lockedBuffer{}}, dailyOnly...) diary.For("blog").Info("strict umask") file = filepath.Join(root, "storage", "logs", "summer-2026-10-21.log") readDaily(t, file) assertMode(t, file, 0o640) // An existing file is appended to and keeps its mode. diary.Reset() syscall.Umask(0o022) root = t.TempDir() dir = filepath.Join(root, "storage", "logs") if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatal(err) } file = filepath.Join(dir, "summer-2026-10-21.log") if err := os.WriteFile(file, nil, 0o600); err != nil { t.Fatal(err) } setup(t, diary.Options{Root: root, Stderr: &lockedBuffer{}}, dailyOnly...) diary.For("blog").Info("existing") if lines := readDaily(t, file); len(lines) != 1 { t.Errorf("existing file has %d lines, want 1", len(lines)) } assertMode(t, file, 0o600) } func assertMode(t *testing.T, path string, want os.FileMode) { t.Helper() info, err := os.Stat(path) if err != nil { t.Fatal(err) } if got := info.Mode().Perm(); got != want { t.Errorf("%s mode = %o, want %o", path, got, want) } } // TestDailyUnwritable: a daily file that cannot be opened warns once (on a // plain stderr handler when no console channel exists), never fails Setup, // and later records are dropped silently. func TestDailyUnwritable(t *testing.T) { root := t.TempDir() // A regular file where the log directory should be: MkdirAll fails for // every user, root included. if err := os.WriteFile(filepath.Join(root, "blocker"), []byte("x"), 0o600); err != nil { t.Fatal(err) } var buf lockedBuffer setup(t, diary.Options{Root: root, Stderr: &buf}, append(slices.Clone(dailyOnly), "SUMMER_LOG__CHANNELS__DAILY__PATH=blocker/logs/summer.log")...) blog := diary.For("blog") for i := range 5 { blog.Info("record", "i", i) } out := buf.String() if n := strings.Count(out, "daily log file disabled"); n != 1 { t.Fatalf("warning appears %d times, want 1:\n%s", n, out) } for _, want := range []string{"level=WARN", "component=diary", filepath.Join(root, "blocker", "logs", "summer-2026-10-21.log")} { if !strings.Contains(out, want) { t.Errorf("warning lacks %q:\n%s", want, out) } } if strings.Contains(out, "msg=record") { t.Errorf("records reached stderr although the default channel is daily:\n%s", out) } if err := diary.Close(); err != nil { t.Errorf("Close after a disabled daily file: %v", err) } } // TestDailyLazyOpen: no file or directory exists until a record reaches the // daily channel; a closed file opens again on the next record. func TestDailyLazyOpen(t *testing.T) { root := t.TempDir() var buf lockedBuffer setup(t, diary.Options{Root: root, Stderr: &buf}) storage := filepath.Join(root, "storage") if _, err := os.Stat(storage); !os.IsNotExist(err) { t.Fatalf("Setup created %s (err %v)", storage, err) } diary.For("blog").Info("below the daily level") if _, err := os.Stat(storage); !os.IsNotExist(err) { t.Fatalf("an info record created %s although the daily level is warn (err %v)", storage, err) } if !strings.Contains(buf.String(), "below the daily level") { t.Errorf("console lacks the info record:\n%s", buf.String()) } file := filepath.Join(storage, "logs", "summer-2026-10-21.log") diary.For("blog").Warn("first warning") if lines := readDaily(t, file); len(lines) != 1 { t.Fatalf("daily file has %d lines after one warning, want 1", len(lines)) } diary.For("blog").Warn("after close") if lines := readDaily(t, file); len(lines) != 2 { t.Fatalf("daily file has %d lines after reopening, want 2", len(lines)) } } // TestPathConfinement: a relative daily path may not leave the application // root; a path that stays inside after cleaning and an absolute path are // accepted, and relative paths resolve against Options.Root. func TestPathConfinement(t *testing.T) { for _, path := range []string{"../summer.log", "storage/../../summer.log", "./../logs/summer.log", ".."} { _, err := diary.ParseConfig(openConfig(t, "SUMMER_LOG__CHANNELS__DAILY__PATH="+path)) if err == nil || !strings.Contains(err.Error(), "log.channels.daily.path") { t.Errorf("path %q: err %v, want an error naming log.channels.daily.path", path, err) } } abs := filepath.Join(t.TempDir(), "elsewhere", "app.log") for _, path := range []string{"storage/../logs/summer.log", "logs/app.log", abs} { if _, err := diary.ParseConfig(openConfig(t, "SUMMER_LOG__CHANNELS__DAILY__PATH="+path)); err != nil { t.Errorf("path %q rejected: %v", path, err) } } root := t.TempDir() setup(t, diary.Options{Root: root, Stderr: &lockedBuffer{}}, append(slices.Clone(dailyOnly), "SUMMER_LOG__CHANNELS__DAILY__PATH="+abs)...) diary.For("blog").Info("absolute") if lines := readDaily(t, filepath.Join(filepath.Dir(abs), "app-2026-10-21.log")); len(lines) != 1 { t.Errorf("absolute path file has %d lines, want 1", len(lines)) } diary.Reset() setup(t, diary.Options{Root: root, Stderr: &lockedBuffer{}}, append(slices.Clone(dailyOnly), "SUMMER_LOG__CHANNELS__DAILY__PATH=storage/../logs/app.log")...) diary.For("blog").Info("relative") if lines := readDaily(t, filepath.Join(root, "logs", "app-2026-10-21.log")); len(lines) != 1 { t.Errorf("relative path did not resolve against the root: %d lines", len(lines)) } }