// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package audit import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) func TestDisabledLogWritesNothing(t *testing.T) { l := New("") if l.Enabled() { t.Fatal("Enabled = true for empty path") } l.Record(Entry{User: "admin", Action: "login"}) } func TestRecordAppendsJSONLines(t *testing.T) { path := filepath.Join(t.TempDir(), "sub", "audit.log") l := New(path) if !l.Enabled() { t.Fatal("Enabled = false for a real path") } l.Record(Entry{User: "admin", Action: "post.created", Resource: "hello", IP: "127.0.0.1"}) l.Record(Entry{User: "admin", Action: "post.deleted", Detail: map[string]any{"slug": "hello"}}) data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") if len(lines) != 2 { t.Fatalf("got %d lines, want 2: %q", len(lines), data) } var first map[string]any if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { t.Fatalf("line is not JSON: %v", err) } if first["user"] != "admin" || first["action"] != "post.created" || first["resource"] != "hello" || first["ip"] != "127.0.0.1" { t.Fatalf("first entry = %v", first) } if _, ok := first["detail"]; ok { t.Fatalf("unexpected detail in first entry: %v", first) } var second map[string]any if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { t.Fatalf("line is not JSON: %v", err) } detail, ok := second["detail"].(map[string]any) if !ok || detail["slug"] != "hello" { t.Fatalf("second entry detail = %v", second) } ts, _ := first["ts"].(string) if len(ts) < 19 || !strings.Contains(ts, "T") { t.Fatalf("ts = %q, want ISO timestamp", ts) } } func TestRecordSurvivesUnwritablePath(t *testing.T) { dir := t.TempDir() l := New(filepath.Join(dir, "file-as-dir", "x", "audit.log")) if err := os.WriteFile(filepath.Join(dir, "file-as-dir"), []byte("x"), 0o600); err != nil { t.Fatalf("WriteFile: %v", err) } l.Record(Entry{User: "admin", Action: "login"}) // must not panic } // The handler opens the file once and appends to it, and Close releases // the handle. func TestFileIsOpenedOnceAndClosed(t *testing.T) { path := filepath.Join(t.TempDir(), "audit.log") l := New(path) for range 50 { l.Record(Entry{User: "admin", Action: "post.updated", Resource: "hello"}) } if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } if got := strings.Count(string(data), "\n"); got != 50 { t.Fatalf("lines = %d, want 50", got) } // A write after Close reopens it rather than losing the line. l.Record(Entry{User: "admin", Action: "post.deleted"}) data, err = os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } if got := strings.Count(string(data), "\n"); got != 51 { t.Fatalf("lines = %d, want 51", got) } if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } } // An unwritable destination is reported once and never fails the caller. func TestUnwritableDestinationIsNotFatal(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.log") if err := os.MkdirAll(path, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } l := New(path) l.Record(Entry{User: "admin", Action: "post.created"}) l.Record(Entry{User: "admin", Action: "post.created"}) if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } } // A transient open failure must not silence the log for the life of the // process: once the obstruction is gone, the next record writes. func TestRecordRecoversAfterThePathBecomesWritable(t *testing.T) { dir := t.TempDir() // A regular file where the log's parent directory should be makes // MkdirAll fail. blocker := filepath.Join(dir, "blocked") if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { t.Fatalf("write blocker: %v", err) } log := New(filepath.Join(blocker, "sub", "audit.log")) log.Record(Entry{User: "u", Action: "first"}) if err := log.Close(); err != nil { t.Fatalf("close: %v", err) } if err := os.Remove(blocker); err != nil { t.Fatalf("remove blocker: %v", err) } log.Record(Entry{User: "u", Action: "second"}) if err := log.Close(); err != nil { t.Fatalf("close: %v", err) } raw, err := os.ReadFile(filepath.Join(dir, "blocked", "sub", "audit.log")) if err != nil { t.Fatalf("the audit log never recovered: %v", err) } if !strings.Contains(string(raw), `"action":"second"`) { t.Fatalf("recovered log = %s", raw) } }