opnsense-portal/internal/audit/logger_test.go
Carsten Abele 2eb69bdcfe feat(audit): JSONL-Logger mit Größenrotation, gzip und SIGHUP-Reopen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
2026-08-14 09:08:19 +02:00

124 lines
3.1 KiB
Go

package audit
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func newTestLogger(t *testing.T, opts Options) (*Logger, string, *bytes.Buffer) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
var stdout bytes.Buffer
opts.Stdout = &stdout
if opts.MaxSizeMB == 0 {
opts.MaxSizeMB = 50
}
lg, err := New(path, opts)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { lg.Close() })
return lg, path, &stdout
}
func readLines(t *testing.T, path string) []map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if line == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("Zeile ist kein gültiges JSON: %q: %v", line, err)
}
out = append(out, m)
}
return out
}
func TestLogWritesJSONLWithTimezone(t *testing.T) {
fixed := time.Date(2026, 8, 14, 9, 32, 11, 0, time.FixedZone("CEST", 2*3600))
lg, path, stdout := newTestLogger(t, Options{Clock: func() time.Time { return fixed }})
lg.Log(Event{Event: EventLoginSuccess, User: "mmueller", SrcIP: "10.1.20.34", Session: "a3f9"})
if err := lg.Close(); err != nil {
t.Fatal(err)
}
lines := readLines(t, path)
if len(lines) != 1 {
t.Fatalf("got %d Zeilen, want 1", len(lines))
}
got := lines[0]
if got["ts"] != "2026-08-14T09:32:11+02:00" {
t.Errorf("ts = %v, want RFC3339 mit Zeitzone", got["ts"])
}
if got["event"] != EventLoginSuccess || got["user"] != "mmueller" {
t.Errorf("Feldwerte falsch: %v", got)
}
if _, present := got["reason"]; present {
t.Errorf("leere Felder dürfen nicht serialisiert werden: %v", got)
}
if !strings.Contains(stdout.String(), EventLoginSuccess) {
t.Error("Event muss zusätzlich nach stdout gehen")
}
}
func TestLogIsConcurrencySafe(t *testing.T) {
lg, path, _ := newTestLogger(t, Options{})
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
lg.Log(Event{Event: EventLoginFailed, User: UnknownUser, SrcIP: "10.0.0.1"})
}()
}
wg.Wait()
if err := lg.Close(); err != nil {
t.Fatal(err)
}
if n := len(readLines(t, path)); n != 50 {
t.Fatalf("got %d Zeilen, want 50 (kein Interleaving erlaubt)", n)
}
}
func TestNewCreatesFileWithTightPermissions(t *testing.T) {
_, path, _ := newTestLogger(t, Options{})
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm&0o077 != 0 {
t.Errorf("Audit-Log hat Rechte %#o — Gruppe/Welt dürfen nicht zugreifen", perm)
}
}
func TestShortSessionNeverLeaksToken(t *testing.T) {
token := "0123456789abcdef0123456789abcdef"
short := ShortSession(token)
if len(short) != 4 {
t.Errorf("ShortSession = %q, want 4 Zeichen", short)
}
if strings.Contains(token, short) {
t.Errorf("gekürzte ID %q ist ein Präfix des Tokens — muss gehasht sein", short)
}
if ShortSession(token) != short {
t.Error("ShortSession muss deterministisch sein")
}
if ShortSession("") != "" {
t.Error("leerer Token muss leere ID liefern")
}
}