package logger import ( "bufio" "encoding/json" "os" "path/filepath" "strings" "testing" ) // readAuditLines parses logs/audit.log-style JSON lines. Callers assert on // individual records rather than raw text so field ordering is irrelevant. func readAuditLines(t *testing.T, path string) []map[string]any { t.Helper() f, err := os.Open(path) if err != nil { t.Fatalf("open audit log: %v", err) } defer f.Close() var out []map[string]any sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } var m map[string]any if err := json.Unmarshal([]byte(line), &m); err != nil { t.Fatalf("audit line is not JSON: %q: %v", line, err) } out = append(out, m) } if err := sc.Err(); err != nil { t.Fatalf("scan audit log: %v", err) } return out } func TestAuditWritesDedicatedFileAndMirrorsMainLog(t *testing.T) { dir := t.TempDir() auditPath := filepath.Join(dir, "audit.log") mainPath := filepath.Join(dir, "server.log") l := New(Config{ Level: LevelInfo, Console: false, File: mainPath, AuditFile: auditPath, }) t.Cleanup(func() { _ = l.Close() }) l.Audit("web.login.success", map[string]any{ "actor": "admin", "role": "admin", "ip": "203.0.113.9", }) lines := readAuditLines(t, auditPath) if len(lines) != 1 { t.Fatalf("audit lines = %d, want 1", len(lines)) } rec := lines[0] if rec["audit"] != true { t.Errorf("audit = %v, want true", rec["audit"]) } if rec["event"] != "web.login.success" { t.Errorf("event = %v", rec["event"]) } if rec["actor"] != "admin" || rec["role"] != "admin" || rec["ip"] != "203.0.113.9" { t.Errorf("fields = %+v", rec) } if _, ok := rec["time"]; !ok { t.Error("record has no timestamp") } // The same record must reach server.log so operators tailing one file // still see security events. mainLines := readAuditLines(t, mainPath) found := false for _, m := range mainLines { if m["event"] == "web.login.success" && m["audit"] == true { found = true } } if !found { t.Errorf("audit record not mirrored to main log: %+v", mainLines) } } func TestAuditSanitizesUntrustedValues(t *testing.T) { dir := t.TempDir() auditPath := filepath.Join(dir, "audit.log") l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath}) t.Cleanup(func() { _ = l.Close() }) l.Audit("web.login.failure", map[string]any{ // Newlines must not be able to forge extra log lines. "username": "evil\n{\"audit\":true,\"event\":\"web.login.success\"}", "ua": strings.Repeat("A", maxAuditValueLen+50), }) lines := readAuditLines(t, auditPath) if len(lines) != 1 { t.Fatalf("audit lines = %d, want 1 (newline forged a record?)", len(lines)) } username, _ := lines[0]["username"].(string) if strings.ContainsAny(username, "\n\r") { t.Errorf("username still contains a newline: %q", username) } if !strings.Contains(username, `{"audit":true`) { t.Errorf("username content lost: %q", username) } ua, _ := lines[0]["ua"].(string) if got := len([]rune(ua)); got != maxAuditValueLen { t.Errorf("ua rune length = %d, want %d", got, maxAuditValueLen) } } func TestAuditDisabledFileStillLogsToMain(t *testing.T) { dir := t.TempDir() mainPath := filepath.Join(dir, "server.log") l := New(Config{Console: false, File: mainPath}) // no AuditFile t.Cleanup(func() { _ = l.Close() }) l.AuditWarn("web.authz.denied", map[string]any{"role": "viewer"}) lines := readAuditLines(t, mainPath) if len(lines) != 1 || lines[0]["event"] != "web.authz.denied" { t.Fatalf("main log lines = %+v", lines) } if _, err := os.Stat(filepath.Join(dir, "audit.log")); !os.IsNotExist(err) { t.Errorf("audit.log unexpectedly created: %v", err) } } func TestClientEventEmitsDeviceAudit(t *testing.T) { dir := t.TempDir() auditPath := filepath.Join(dir, "audit.log") l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath}) t.Cleanup(func() { _ = l.Close() }) l.ClientEvent("online", 42, "198.51.100.7", "clientID", "abc", "computer", "PC-01") lines := readAuditLines(t, auditPath) if len(lines) != 1 { t.Fatalf("audit lines = %d, want 1", len(lines)) } if lines[0]["event"] != "device.online" { t.Errorf("event = %v, want device.online", lines[0]["event"]) } if lines[0]["device_id"] != "abc" { t.Errorf("device_id = %v", lines[0]["device_id"]) } }