mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
1183 lines
29 KiB
Go
1183 lines
29 KiB
Go
package logging
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
func resetLoggingState() {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Always use the real close function during test cleanup so active writers
|
|
// can be closed even when a test has stubbed closeFileFn.
|
|
closeFileFn = defaultCloseFileFn
|
|
if fileCloser != nil {
|
|
_ = fileCloser.Close()
|
|
fileCloser = nil
|
|
}
|
|
|
|
baseWriter = os.Stderr
|
|
baseComponent = ""
|
|
baseLogger = zerolog.New(baseWriter).With().Timestamp().Logger()
|
|
log.Logger = baseLogger
|
|
zerolog.TimeFieldFormat = defaultTimeFmt
|
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
|
nowFn = time.Now
|
|
isTerminalFn = term.IsTerminal
|
|
mkdirAllFn = os.MkdirAll
|
|
chmodFn = os.Chmod
|
|
openFileFn = os.OpenFile
|
|
openFn = os.Open
|
|
statFn = os.Stat
|
|
lstatFn = os.Lstat
|
|
readDirFn = os.ReadDir
|
|
renameFn = os.Rename
|
|
removeFn = os.Remove
|
|
copyFn = io.Copy
|
|
gzipNewWriterFn = gzip.NewWriter
|
|
statFileFn = defaultStatFileFn
|
|
chmodFileFn = func(file *os.File, mode os.FileMode) error { return file.Chmod(mode) }
|
|
closeFileFn = defaultCloseFileFn
|
|
compressFn = compressAndRemove
|
|
|
|
GetBroadcaster().Shutdown()
|
|
}
|
|
|
|
func baseWriterDebugString() string {
|
|
return fmt.Sprintf("%#v", baseWriter)
|
|
}
|
|
|
|
func captureStderr(t *testing.T, fn func()) string {
|
|
t.Helper()
|
|
|
|
originalStderr := os.Stderr
|
|
readPipe, writePipe, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("failed to create stderr pipe: %v", err)
|
|
}
|
|
|
|
os.Stderr = writePipe
|
|
defer func() {
|
|
os.Stderr = originalStderr
|
|
_ = readPipe.Close()
|
|
_ = writePipe.Close()
|
|
}()
|
|
|
|
fn()
|
|
|
|
if err := writePipe.Close(); err != nil {
|
|
t.Fatalf("failed to close write pipe: %v", err)
|
|
}
|
|
output, err := io.ReadAll(readPipe)
|
|
if err != nil {
|
|
t.Fatalf("failed to read stderr output: %v", err)
|
|
}
|
|
|
|
return string(output)
|
|
}
|
|
|
|
func TestInitJSONFormatSetsLevelAndComponent(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
Init(Config{
|
|
Format: "json",
|
|
Level: "debug",
|
|
Component: "apiserver",
|
|
})
|
|
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
|
|
repr := baseWriterDebugString()
|
|
if !strings.Contains(repr, fmt.Sprintf("(%p)", os.Stderr)) {
|
|
t.Fatalf("expected base writer to include os.Stderr, got %#v", baseWriter)
|
|
}
|
|
if !strings.Contains(repr, "LogBroadcaster") {
|
|
t.Fatalf("expected base writer to include broadcaster, got %#v", baseWriter)
|
|
}
|
|
|
|
if zerolog.GlobalLevel() != zerolog.DebugLevel {
|
|
t.Fatalf("expected global level debug, got %s", zerolog.GlobalLevel())
|
|
}
|
|
|
|
if baseComponent != "apiserver" {
|
|
t.Fatalf("expected base component apiserver, got %s", baseComponent)
|
|
}
|
|
|
|
if !reflect.DeepEqual(log.Logger, baseLogger) {
|
|
t.Fatal("expected global log.Logger to match baseLogger")
|
|
}
|
|
}
|
|
|
|
func TestInitConsoleFormatUsesConsoleWriter(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
Init(Config{
|
|
Format: "console",
|
|
Level: "info",
|
|
})
|
|
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
|
|
repr := baseWriterDebugString()
|
|
if !strings.Contains(repr, "zerolog.ConsoleWriter") {
|
|
t.Fatalf("expected console writer, got %#v", baseWriter)
|
|
}
|
|
if !strings.Contains(repr, "LogBroadcaster") {
|
|
t.Fatalf("expected base writer to include broadcaster, got %#v", baseWriter)
|
|
}
|
|
}
|
|
|
|
func TestInitAutoFormatWithPipe(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
origStderr := os.Stderr
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("failed to create pipe: %v", err)
|
|
}
|
|
os.Stderr = w
|
|
defer func() {
|
|
os.Stderr = origStderr
|
|
_ = r.Close()
|
|
_ = w.Close()
|
|
}()
|
|
|
|
Init(Config{
|
|
Format: "auto",
|
|
Level: "info",
|
|
})
|
|
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
|
|
repr := baseWriterDebugString()
|
|
if !strings.Contains(repr, fmt.Sprintf("(%p)", w)) {
|
|
t.Fatalf("expected base writer to use provided pipe, got %#v", baseWriter)
|
|
}
|
|
if !strings.Contains(repr, "LogBroadcaster") {
|
|
t.Fatalf("expected base writer to include broadcaster, got %#v", baseWriter)
|
|
}
|
|
}
|
|
|
|
func TestWithRequestID(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
Init(Config{
|
|
Format: "json",
|
|
Level: "info",
|
|
})
|
|
|
|
ctx, generated := WithRequestID(nil, "")
|
|
if generated == "" {
|
|
t.Fatal("expected generated request id")
|
|
}
|
|
if ctx == nil {
|
|
t.Fatal("expected non-nil context")
|
|
}
|
|
|
|
ctx2, id := WithRequestID(nil, "custom-123")
|
|
if id != "custom-123" {
|
|
t.Fatalf("expected custom-123, got %s", id)
|
|
}
|
|
if ctx2 == nil {
|
|
t.Fatal("expected non-nil context")
|
|
}
|
|
}
|
|
|
|
func TestWithRequestIDTrimsWhitespace(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
Init(Config{})
|
|
|
|
_, id := WithRequestID(nil, " ")
|
|
if id == "" {
|
|
t.Fatal("expected generated id for whitespace input")
|
|
}
|
|
}
|
|
|
|
func TestInitThreadSafety(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
var wg sync.WaitGroup
|
|
configs := []Config{
|
|
{Format: "json", Level: "debug", Component: "worker"},
|
|
{Format: "json", Level: "warn", Component: "api"},
|
|
}
|
|
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
Init(configs[idx%len(configs)])
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
|
|
// Ensure baseLogger is valid and global logger matches it.
|
|
if reflect.DeepEqual(baseLogger, zerolog.Logger{}) {
|
|
t.Fatal("expected initialized base logger")
|
|
}
|
|
if !reflect.DeepEqual(log.Logger, baseLogger) {
|
|
t.Fatal("expected global log.Logger to match baseLogger after concurrent init")
|
|
}
|
|
}
|
|
|
|
func TestIsLevelEnabled(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
// Set global level to Info
|
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
|
|
|
if !IsLevelEnabled(zerolog.InfoLevel) {
|
|
t.Fatal("expected info level to be enabled")
|
|
}
|
|
if !IsLevelEnabled(zerolog.WarnLevel) {
|
|
t.Fatal("expected warn level to be enabled")
|
|
}
|
|
if !IsLevelEnabled(zerolog.ErrorLevel) {
|
|
t.Fatal("expected error level to be enabled")
|
|
}
|
|
if IsLevelEnabled(zerolog.DebugLevel) {
|
|
t.Fatal("expected debug level to be disabled")
|
|
}
|
|
|
|
// Change to debug level
|
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
if !IsLevelEnabled(zerolog.DebugLevel) {
|
|
t.Fatal("expected debug level to be enabled after setting global level")
|
|
}
|
|
}
|
|
|
|
func TestRollingFileWriter(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
logFile := dir + "/test.log"
|
|
|
|
cfg := Config{
|
|
Format: "json",
|
|
Level: "info",
|
|
FilePath: logFile,
|
|
MaxSizeMB: 1,
|
|
MaxAgeDays: 7,
|
|
Compress: false,
|
|
}
|
|
|
|
Init(cfg)
|
|
|
|
// Write some log output
|
|
log.Info().Msg("test message")
|
|
|
|
// Check file exists (this confirms rolling file writer was created)
|
|
if _, err := os.Stat(logFile); os.IsNotExist(err) {
|
|
t.Fatal("expected log file to be created")
|
|
}
|
|
|
|
// Check file has content
|
|
data, err := os.ReadFile(logFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to read log file: %v", err)
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("expected log file to have content")
|
|
}
|
|
}
|
|
|
|
func TestInitClosesPreviousRollingFileWriter(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
logFile := filepath.Join(dir, "app.log")
|
|
closeCalls := 0
|
|
closeFileFn = func(file *os.File) error {
|
|
closeCalls++
|
|
return file.Close()
|
|
}
|
|
|
|
Init(Config{Format: "json", FilePath: logFile})
|
|
Init(Config{Format: "json", FilePath: logFile})
|
|
|
|
if closeCalls != 1 {
|
|
t.Fatalf("expected previous file writer to be closed once, got %d", closeCalls)
|
|
}
|
|
}
|
|
|
|
func TestShutdownClosesActiveRollingFileWriterAndSubscribers(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
logFile := filepath.Join(dir, "app.log")
|
|
closeCalls := 0
|
|
closeFileFn = func(file *os.File) error {
|
|
closeCalls++
|
|
return file.Close()
|
|
}
|
|
|
|
Init(Config{Format: "json", FilePath: logFile})
|
|
_, ch, _ := GetBroadcaster().Subscribe()
|
|
|
|
Shutdown()
|
|
Shutdown()
|
|
|
|
if closeCalls != 1 {
|
|
t.Fatalf("expected active file writer to be closed once, got %d", closeCalls)
|
|
}
|
|
if fileCloser != nil {
|
|
t.Fatal("expected fileCloser to be cleared after shutdown")
|
|
}
|
|
|
|
select {
|
|
case _, ok := <-ch:
|
|
if ok {
|
|
t.Fatal("expected subscriber channel to be closed on shutdown")
|
|
}
|
|
default:
|
|
t.Fatal("expected subscriber channel to be closed immediately")
|
|
}
|
|
}
|
|
|
|
func TestParseLevelDefaults(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want zerolog.Level
|
|
}{
|
|
{"", zerolog.InfoLevel},
|
|
{"debug", zerolog.DebugLevel},
|
|
{"DEBUG", zerolog.DebugLevel},
|
|
{"trace", zerolog.TraceLevel},
|
|
{"info", zerolog.InfoLevel},
|
|
{"INFO", zerolog.InfoLevel},
|
|
{"warn", zerolog.WarnLevel},
|
|
{"warning", zerolog.WarnLevel},
|
|
{"WARN", zerolog.WarnLevel},
|
|
{"error", zerolog.ErrorLevel},
|
|
{"ERROR", zerolog.ErrorLevel},
|
|
{"fatal", zerolog.FatalLevel},
|
|
{"panic", zerolog.PanicLevel},
|
|
{"disabled", zerolog.Disabled},
|
|
{"unknown", zerolog.InfoLevel},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := parseLevel(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("parseLevel(%q) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseLevelWarnsOnInvalidValue(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
stderr := captureStderr(t, func() {
|
|
if got := parseLevel(" VERBOSE "); got != zerolog.InfoLevel {
|
|
t.Fatalf("expected info fallback for invalid level, got %s", got)
|
|
}
|
|
})
|
|
|
|
if !strings.Contains(stderr, `logging: invalid level "verbose"; using "info"`) {
|
|
t.Fatalf("expected invalid level warning, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestSelectWriter(t *testing.T) {
|
|
tests := []struct {
|
|
format string
|
|
isTTY bool
|
|
wantErr bool
|
|
}{
|
|
{"json", false, false},
|
|
{"console", false, false},
|
|
{"auto", false, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
w := selectWriter(tc.format)
|
|
if w == nil {
|
|
t.Errorf("selectWriter(%q) returned nil", tc.format)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSelectWriterAutoTerminal(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
isTerminalFn = func(int) bool { return true }
|
|
|
|
w := selectWriter("auto")
|
|
if _, ok := w.(zerolog.ConsoleWriter); !ok {
|
|
t.Fatalf("expected console writer, got %#v", w)
|
|
}
|
|
}
|
|
|
|
func TestSelectWriterDefault(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
w := selectWriter("unknown")
|
|
if w != os.Stderr {
|
|
t.Fatalf("expected default writer to be os.Stderr, got %#v", w)
|
|
}
|
|
}
|
|
|
|
func TestSelectWriterWarnsOnInvalidFormat(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
stderr := captureStderr(t, func() {
|
|
w := selectWriter("pretty")
|
|
if w != os.Stderr {
|
|
t.Fatalf("expected invalid format fallback to os.Stderr, got %#v", w)
|
|
}
|
|
})
|
|
|
|
if !strings.Contains(stderr, `logging: invalid format "pretty"; using "json"`) {
|
|
t.Fatalf("expected invalid format warning, got %q", stderr)
|
|
}
|
|
}
|
|
|
|
func TestIsTerminalNil(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
if isTerminal(nil) {
|
|
t.Fatal("expected nil file to report false")
|
|
}
|
|
}
|
|
|
|
func TestNewRollingFileWriter_EmptyPath(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
writer, err := newRollingFileWriter(Config{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if writer != nil {
|
|
t.Fatalf("expected nil writer, got %#v", writer)
|
|
}
|
|
}
|
|
|
|
func TestNewRollingFileWriter_MkdirError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
mkdirAllFn = func(string, os.FileMode) error {
|
|
return errors.New("mkdir failed")
|
|
}
|
|
|
|
_, err := newRollingFileWriter(Config{FilePath: "/tmp/logs/test.log"})
|
|
if err == nil {
|
|
t.Fatal("expected error from mkdir")
|
|
}
|
|
}
|
|
|
|
func TestNewRollingFileWriter_HardensDirectoryPermissions(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
root := t.TempDir()
|
|
logDir := filepath.Join(root, "logs")
|
|
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
|
t.Fatalf("failed to create log dir: %v", err)
|
|
}
|
|
|
|
writer, err := newRollingFileWriter(Config{FilePath: filepath.Join(logDir, "app.log")})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
w, ok := writer.(*rollingFileWriter)
|
|
if !ok {
|
|
t.Fatalf("expected rollingFileWriter, got %#v", writer)
|
|
}
|
|
defer func() { _ = w.closeLocked() }()
|
|
|
|
info, err := os.Stat(logDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat log dir: %v", err)
|
|
}
|
|
if got := info.Mode().Perm(); got != logDirPerm {
|
|
t.Fatalf("expected log dir mode %o, got %o", logDirPerm, got)
|
|
}
|
|
}
|
|
|
|
func TestNewRollingFileWriter_RejectsSymlinkFile(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "target.log")
|
|
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("failed to create target file: %v", err)
|
|
}
|
|
link := filepath.Join(root, "symlink.log")
|
|
requireSymlinkOrSkip(t, target, link)
|
|
|
|
if _, err := newRollingFileWriter(Config{FilePath: link}); err == nil {
|
|
t.Fatal("expected error for symlink log file path")
|
|
}
|
|
}
|
|
|
|
func TestNewRollingFileWriter_RejectsSymlinkDir(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
root := t.TempDir()
|
|
realDir := filepath.Join(root, "real-logs")
|
|
if err := os.MkdirAll(realDir, 0o755); err != nil {
|
|
t.Fatalf("failed to create real log dir: %v", err)
|
|
}
|
|
linkDir := filepath.Join(root, "logs-link")
|
|
requireSymlinkOrSkip(t, realDir, linkDir)
|
|
|
|
if _, err := newRollingFileWriter(Config{FilePath: filepath.Join(linkDir, "app.log")}); err == nil {
|
|
t.Fatal("expected error for symlink log directory path")
|
|
}
|
|
}
|
|
|
|
func TestInitFileWriterError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
mkdirAllFn = func(string, os.FileMode) error {
|
|
return errors.New("mkdir failed")
|
|
}
|
|
|
|
Init(Config{
|
|
Format: "json",
|
|
FilePath: "/tmp/logs/test.log",
|
|
})
|
|
}
|
|
|
|
func TestNewRollingFileWriter_DefaultMaxSize(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
writer, err := newRollingFileWriter(Config{
|
|
FilePath: filepath.Join(dir, "app.log"),
|
|
MaxSizeMB: 0,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
w, ok := writer.(*rollingFileWriter)
|
|
if !ok {
|
|
t.Fatalf("expected rollingFileWriter, got %#v", writer)
|
|
}
|
|
if w.maxBytes != int64(defaultMaxSizeMB)*bytesPerMB {
|
|
t.Fatalf("expected default max bytes, got %d", w.maxBytes)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestNewRollingFileWriter_SecureDirectoryPermissions(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
var (
|
|
called bool
|
|
mode os.FileMode
|
|
)
|
|
origMkdirAll := mkdirAllFn
|
|
mkdirAllFn = func(path string, perm os.FileMode) error {
|
|
called = true
|
|
mode = perm
|
|
return origMkdirAll(path, perm)
|
|
}
|
|
|
|
logFile := filepath.Join(t.TempDir(), "logs", "app.log")
|
|
writer, err := newRollingFileWriter(Config{FilePath: logFile})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !called {
|
|
t.Fatal("expected mkdirAllFn to be called")
|
|
}
|
|
if mode != 0o700 {
|
|
t.Fatalf("expected log directory mode 0700, got %#o", mode)
|
|
}
|
|
w, ok := writer.(*rollingFileWriter)
|
|
if !ok {
|
|
t.Fatalf("expected rollingFileWriter, got %#v", writer)
|
|
}
|
|
if w.maxBytes != int64(defaultMaxSizeMB)*bytesPerMB {
|
|
t.Fatalf("expected default max bytes, got %d", w.maxBytes)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestNewRollingFileWriter_MaxSizeOverflowUsesDefault(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
writer, err := newRollingFileWriter(Config{
|
|
FilePath: filepath.Join(dir, "app.log"),
|
|
MaxSizeMB: int(maxSafeSizeMB + 1),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
w, ok := writer.(*rollingFileWriter)
|
|
if !ok {
|
|
t.Fatalf("expected rollingFileWriter, got %#v", writer)
|
|
}
|
|
if w.maxBytes != int64(defaultMaxSizeMB)*bytesPerMB {
|
|
t.Fatalf("expected default max bytes, got %d", w.maxBytes)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestNewRollingFileWriter_NegativeMaxAgeUsesDefault(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
writer, err := newRollingFileWriter(Config{
|
|
FilePath: filepath.Join(dir, "app.log"),
|
|
MaxSizeMB: 1,
|
|
MaxAgeDays: -1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
w, ok := writer.(*rollingFileWriter)
|
|
if !ok {
|
|
t.Fatalf("expected rollingFileWriter, got %#v", writer)
|
|
}
|
|
if w.maxAge != time.Duration(defaultMaxAgeDays)*24*time.Hour {
|
|
t.Fatalf("expected default max age, got %s", w.maxAge)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestNewRollingFileWriter_ZeroMaxAgeDisablesCleanup(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
writer, err := newRollingFileWriter(Config{
|
|
FilePath: filepath.Join(dir, "app.log"),
|
|
MaxSizeMB: 1,
|
|
MaxAgeDays: 0,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
w, ok := writer.(*rollingFileWriter)
|
|
if !ok {
|
|
t.Fatalf("expected rollingFileWriter, got %#v", writer)
|
|
}
|
|
if w.maxAge != 0 {
|
|
t.Fatalf("expected max age 0, got %s", w.maxAge)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestNewRollingFileWriter_MaxAgeOverflowClamps(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
writer, err := newRollingFileWriter(Config{
|
|
FilePath: filepath.Join(dir, "app.log"),
|
|
MaxSizeMB: 1,
|
|
MaxAgeDays: maxDurationDays + 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
w, ok := writer.(*rollingFileWriter)
|
|
if !ok {
|
|
t.Fatalf("expected rollingFileWriter, got %#v", writer)
|
|
}
|
|
if w.maxAge != time.Duration(maxDurationDays)*24*time.Hour {
|
|
t.Fatalf("expected clamped max age, got %s", w.maxAge)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestNewRollingFileWriter_OpenError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
openFileFn = func(string, int, os.FileMode) (*os.File, error) {
|
|
return nil, errors.New("open failed")
|
|
}
|
|
|
|
_, err := newRollingFileWriter(Config{FilePath: filepath.Join(t.TempDir(), "app.log")})
|
|
if err == nil {
|
|
t.Fatal("expected error from openOrCreateLocked")
|
|
}
|
|
}
|
|
|
|
func TestOpenOrCreateLocked_StatError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
w := &rollingFileWriter{path: filepath.Join(dir, "app.log")}
|
|
statFileFn = func(*os.File) (os.FileInfo, error) {
|
|
return nil, errors.New("stat failed")
|
|
}
|
|
if err := w.openOrCreateLocked(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if w.currentSize != 0 {
|
|
t.Fatalf("expected current size 0, got %d", w.currentSize)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestOpenOrCreateLocked_AlreadyOpen(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
file, err := os.CreateTemp(t.TempDir(), "log")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
w := &rollingFileWriter{path: file.Name(), file: file}
|
|
if err := w.openOrCreateLocked(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestOpenOrCreateLocked_HardensFilePermissions(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
path := filepath.Join(t.TempDir(), "app.log")
|
|
if err := os.WriteFile(path, []byte("existing"), 0o644); err != nil {
|
|
t.Fatalf("failed to create log file: %v", err)
|
|
}
|
|
|
|
w := &rollingFileWriter{path: path}
|
|
if err := w.openOrCreateLocked(); err != nil {
|
|
t.Fatalf("openOrCreateLocked error: %v", err)
|
|
}
|
|
defer func() { _ = w.closeLocked() }()
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat log file: %v", err)
|
|
}
|
|
if got := info.Mode().Perm(); got != logFilePerm {
|
|
t.Fatalf("expected log file mode %o, got %o", logFilePerm, got)
|
|
}
|
|
}
|
|
|
|
func TestRollingFileWriter_WriteOpenError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
openFileFn = func(string, int, os.FileMode) (*os.File, error) {
|
|
return nil, errors.New("open failed")
|
|
}
|
|
w := &rollingFileWriter{path: filepath.Join(t.TempDir(), "app.log")}
|
|
if _, err := w.Write([]byte("data")); err == nil {
|
|
t.Fatal("expected write error")
|
|
}
|
|
}
|
|
|
|
func TestRollingFileWriter_WriteRotateError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
callCount := 0
|
|
openFileFn = func(name string, flag int, perm os.FileMode) (*os.File, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
return os.OpenFile(name, flag, perm)
|
|
}
|
|
return nil, errors.New("open failed")
|
|
}
|
|
w := &rollingFileWriter{path: path, maxBytes: 1}
|
|
if _, err := w.Write([]byte("too big")); err == nil {
|
|
t.Fatal("expected rotate error")
|
|
}
|
|
}
|
|
|
|
func TestRollingFileWriter_RotateCompress(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
if err := os.WriteFile(path, []byte("data"), 0600); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
|
|
w := &rollingFileWriter{path: path, maxBytes: 1, compress: true}
|
|
if err := w.openOrCreateLocked(); err != nil {
|
|
t.Fatalf("openOrCreateLocked error: %v", err)
|
|
}
|
|
|
|
ch := make(chan string, 1)
|
|
compressFn = func(p string) { ch <- p }
|
|
|
|
if err := w.rotateLocked(); err != nil {
|
|
t.Fatalf("rotateLocked error: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("expected compress to be triggered")
|
|
}
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestRollingFileWriter_RotateRenameError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
if err := os.WriteFile(path, []byte("data"), 0600); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
|
|
w := &rollingFileWriter{path: path, maxBytes: 1, compress: true}
|
|
if err := w.openOrCreateLocked(); err != nil {
|
|
t.Fatalf("openOrCreateLocked error: %v", err)
|
|
}
|
|
|
|
renameCalled := false
|
|
renameFn = func(oldpath, newpath string) error {
|
|
renameCalled = true
|
|
return errors.New("rename failed")
|
|
}
|
|
|
|
compressCalled := make(chan struct{}, 1)
|
|
compressFn = func(string) {
|
|
compressCalled <- struct{}{}
|
|
}
|
|
|
|
if err := w.rotateLocked(); err != nil {
|
|
t.Fatalf("rotateLocked error: %v", err)
|
|
}
|
|
if !renameCalled {
|
|
t.Fatal("expected rename to be attempted")
|
|
}
|
|
|
|
select {
|
|
case <-compressCalled:
|
|
t.Fatal("expected compression to be skipped on rename error")
|
|
default:
|
|
}
|
|
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected log file to exist after rename failure: %v", err)
|
|
}
|
|
|
|
_ = w.closeLocked()
|
|
}
|
|
|
|
func TestRotateLockedCloseError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
t.Fatalf("failed to open file: %v", err)
|
|
}
|
|
w := &rollingFileWriter{path: path, file: file}
|
|
closeFileFn = func(*os.File) error {
|
|
return errors.New("close failed")
|
|
}
|
|
|
|
if err := w.rotateLocked(); err == nil {
|
|
t.Fatal("expected close error")
|
|
}
|
|
_ = file.Close()
|
|
}
|
|
|
|
func TestCloseLocked(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
w := &rollingFileWriter{}
|
|
if err := w.closeLocked(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
file, err := os.CreateTemp(t.TempDir(), "log")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
w.file = file
|
|
if err := w.closeLocked(); err != nil {
|
|
t.Fatalf("unexpected close error: %v", err)
|
|
}
|
|
if w.file != nil {
|
|
t.Fatal("expected file to be cleared")
|
|
}
|
|
if w.currentSize != 0 {
|
|
t.Fatalf("expected size reset, got %d", w.currentSize)
|
|
}
|
|
}
|
|
|
|
func TestCleanupOldFilesNoMaxAge(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
w := &rollingFileWriter{path: filepath.Join(t.TempDir(), "app.log"), maxAge: 0}
|
|
w.cleanupOldFiles()
|
|
}
|
|
|
|
func TestCleanupOldFilesReadDirError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
readDirFn = func(string) ([]os.DirEntry, error) {
|
|
return nil, errors.New("read dir failed")
|
|
}
|
|
w := &rollingFileWriter{path: filepath.Join(t.TempDir(), "app.log"), maxAge: time.Hour}
|
|
w.cleanupOldFiles()
|
|
}
|
|
|
|
func TestCleanupOldFilesInfoError(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
readDirFn = func(string) ([]os.DirEntry, error) {
|
|
return []os.DirEntry{errDirEntry{name: "app.log.20200101"}}, nil
|
|
}
|
|
w := &rollingFileWriter{path: filepath.Join(t.TempDir(), "app.log"), maxAge: time.Hour}
|
|
w.cleanupOldFiles()
|
|
}
|
|
|
|
func TestCleanupOldFilesRemovesOld(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
oldFile := filepath.Join(dir, "app.log.20200101-000000")
|
|
newFile := filepath.Join(dir, "app.log.20250101-000000")
|
|
otherFile := filepath.Join(dir, "other.log.20200101")
|
|
|
|
if err := os.WriteFile(oldFile, []byte("old"), 0600); err != nil {
|
|
t.Fatalf("failed to write old file: %v", err)
|
|
}
|
|
if err := os.WriteFile(newFile, []byte("new"), 0600); err != nil {
|
|
t.Fatalf("failed to write new file: %v", err)
|
|
}
|
|
if err := os.WriteFile(otherFile, []byte("other"), 0600); err != nil {
|
|
t.Fatalf("failed to write other file: %v", err)
|
|
}
|
|
|
|
fixedNow := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
|
|
nowFn = func() time.Time { return fixedNow }
|
|
|
|
if err := os.Chtimes(oldFile, fixedNow.Add(-48*time.Hour), fixedNow.Add(-48*time.Hour)); err != nil {
|
|
t.Fatalf("failed to set old file time: %v", err)
|
|
}
|
|
if err := os.Chtimes(newFile, fixedNow.Add(-time.Hour), fixedNow.Add(-time.Hour)); err != nil {
|
|
t.Fatalf("failed to set new file time: %v", err)
|
|
}
|
|
|
|
w := &rollingFileWriter{path: path, maxAge: 24 * time.Hour}
|
|
w.cleanupOldFiles()
|
|
|
|
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
|
t.Fatalf("expected old file to be removed")
|
|
}
|
|
if _, err := os.Stat(newFile); err != nil {
|
|
t.Fatalf("expected new file to remain: %v", err)
|
|
}
|
|
if _, err := os.Stat(otherFile); err != nil {
|
|
t.Fatalf("expected other file to remain: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStatFileFnDefault(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
file, err := os.CreateTemp(t.TempDir(), "log")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = file.Close() })
|
|
|
|
if _, err := statFileFn(file); err != nil {
|
|
t.Fatalf("statFileFn error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCompressAndRemove(t *testing.T) {
|
|
t.Run("OpenError", func(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
openFn = func(string) (*os.File, error) {
|
|
return nil, errors.New("open failed")
|
|
}
|
|
compressAndRemove("/does/not/exist")
|
|
})
|
|
|
|
t.Run("OpenFileError", func(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
if err := os.WriteFile(path, []byte("data"), 0600); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
openFileFn = func(string, int, os.FileMode) (*os.File, error) {
|
|
return nil, errors.New("open file failed")
|
|
}
|
|
compressAndRemove(path)
|
|
})
|
|
|
|
t.Run("CopyError", func(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
if err := os.WriteFile(path, []byte("data"), 0600); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
copyFn = func(io.Writer, io.Reader) (int64, error) {
|
|
return 0, errors.New("copy failed")
|
|
}
|
|
compressAndRemove(path)
|
|
})
|
|
|
|
t.Run("CloseError", func(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
if err := os.WriteFile(path, []byte("data"), 0600); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
errWriter := errWriteCloser{err: errors.New("write failed")}
|
|
gzipNewWriterFn = func(io.Writer) *gzip.Writer {
|
|
return gzip.NewWriter(errWriter)
|
|
}
|
|
copyFn = func(io.Writer, io.Reader) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
compressAndRemove(path)
|
|
})
|
|
|
|
t.Run("Success", func(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
if err := os.WriteFile(path, []byte("data"), 0600); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
compressAndRemove(path)
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatal("expected original file to be removed")
|
|
}
|
|
if _, err := os.Stat(path + ".gz"); err != nil {
|
|
t.Fatalf("expected gzip file to exist: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("RejectsSymlinkSource", func(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "target.log")
|
|
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("failed to write target file: %v", err)
|
|
}
|
|
symlinkPath := filepath.Join(dir, "app.log")
|
|
requireSymlinkOrSkip(t, target, symlinkPath)
|
|
|
|
compressAndRemove(symlinkPath)
|
|
|
|
if _, err := os.Stat(symlinkPath + ".gz"); err == nil {
|
|
t.Fatal("expected no gzip output for symlink source path")
|
|
}
|
|
})
|
|
|
|
t.Run("RejectsSymlinkDestination", func(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "app.log")
|
|
if err := os.WriteFile(path, []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("failed to write source file: %v", err)
|
|
}
|
|
target := filepath.Join(dir, "target.gz")
|
|
if err := os.WriteFile(target, []byte("target"), 0o600); err != nil {
|
|
t.Fatalf("failed to write destination target file: %v", err)
|
|
}
|
|
requireSymlinkOrSkip(t, target, path+".gz")
|
|
|
|
compressAndRemove(path)
|
|
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected source file to remain when destination path is unsafe: %v", err)
|
|
}
|
|
if _, err := os.Stat(path + ".gz"); err != nil {
|
|
t.Fatalf("expected symlink destination path to remain: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Test that the logging package doesn't panic under concurrent use
|
|
func TestConcurrentLogging(t *testing.T) {
|
|
t.Cleanup(resetLoggingState)
|
|
|
|
buf := &lockedBuffer{}
|
|
mu.Lock()
|
|
baseWriter = buf
|
|
baseLogger = zerolog.New(buf).With().Timestamp().Logger()
|
|
log.Logger = baseLogger
|
|
mu.Unlock()
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
log.Info().Int("iteration", n).Msg("concurrent log")
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
if buf.Len() == 0 {
|
|
t.Fatal("expected log output from concurrent logging")
|
|
}
|
|
}
|
|
|
|
type lockedBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *lockedBuffer) Len() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Len()
|
|
}
|
|
|
|
type errDirEntry struct {
|
|
name string
|
|
}
|
|
|
|
func (e errDirEntry) Name() string { return e.name }
|
|
func (e errDirEntry) IsDir() bool { return false }
|
|
func (e errDirEntry) Type() os.FileMode {
|
|
return 0
|
|
}
|
|
func (e errDirEntry) Info() (os.FileInfo, error) {
|
|
return nil, errors.New("info error")
|
|
}
|
|
|
|
type errWriteCloser struct {
|
|
err error
|
|
}
|
|
|
|
func (e errWriteCloser) Write(p []byte) (int, error) {
|
|
return 0, e.err
|
|
}
|
|
|
|
func requireSymlinkOrSkip(t *testing.T, target, link string) {
|
|
t.Helper()
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Skipf("symlink not supported in this environment: %v", err)
|
|
}
|
|
}
|