crush_logs_test.go

  1package tools
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"maps"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"testing"
 11	"time"
 12
 13	"github.com/stretchr/testify/require"
 14)
 15
 16// createTestLogFile creates a temporary log file with the given entries.
 17func createTestLogFile(t *testing.T, entries []map[string]any) string {
 18	t.Helper()
 19	tempDir := t.TempDir()
 20	logFile := filepath.Join(tempDir, "crush.log")
 21
 22	file, err := os.Create(logFile)
 23	require.NoError(t, err)
 24	defer file.Close()
 25
 26	for _, entry := range entries {
 27		line, err := json.Marshal(entry)
 28		require.NoError(t, err)
 29		_, err = file.WriteString(string(line) + "\n")
 30		require.NoError(t, err)
 31	}
 32
 33	return logFile
 34}
 35
 36// makeLogEntry creates a standard log entry for testing.
 37func makeLogEntry(level, msg, source string, line int, extra map[string]any) map[string]any {
 38	entry := map[string]any{
 39		"time":  time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC).Format(time.RFC3339),
 40		"level": level,
 41		"msg":   msg,
 42		"source": map[string]any{
 43			"file": source,
 44			"line": line,
 45		},
 46	}
 47	maps.Copy(entry, extra)
 48	return entry
 49}
 50
 51func TestNewCrushLogsTool(t *testing.T) {
 52	t.Parallel()
 53	tool := NewCrushLogsTool("/tmp/test.log")
 54	require.NotNil(t, tool)
 55	require.Equal(t, CrushLogsToolName, tool.Info().Name)
 56}
 57
 58func TestCrushLogs_HappyPath(t *testing.T) {
 59	t.Parallel()
 60	entries := []map[string]any{
 61		makeLogEntry("INFO", "Application started", "app.go", 42, map[string]any{"version": "1.0.0"}),
 62		makeLogEntry("DEBUG", "Processing request", "handler.go", 100, map[string]any{"request_id": "abc123"}),
 63		makeLogEntry("ERROR", "Database connection failed", "db.go", 55, map[string]any{"retry_count": 3}),
 64	}
 65
 66	logFile := createTestLogFile(t, entries)
 67
 68	result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
 69
 70	lines := strings.Split(result, "\n")
 71	require.Len(t, lines, 3)
 72
 73	// Verify format: TIMESTAMP LEVEL SOURCE:LINE MESSAGE
 74	require.Contains(t, lines[0], "INFO")
 75	require.Contains(t, lines[0], "app.go:42")
 76	require.Contains(t, lines[0], "Application started")
 77	require.Contains(t, lines[0], "version=1.0.0")
 78
 79	require.Contains(t, lines[1], "DEBUG")
 80	require.Contains(t, lines[1], "handler.go:100")
 81
 82	require.Contains(t, lines[2], "ERROR")
 83	require.Contains(t, lines[2], "db.go:55")
 84}
 85
 86func TestCrushLogs_DefaultLines(t *testing.T) {
 87	t.Parallel()
 88	// Create 100 log entries.
 89	var entries []map[string]any
 90	for i := range 100 {
 91		entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
 92	}
 93
 94	logFile := createTestLogFile(t, entries)
 95
 96	// Call with Lines: 0 should default to 50.
 97	result := runCrushLogs(logFile, CrushLogsParams{Lines: 0})
 98
 99	lines := strings.Split(result, "\n")
100	require.Len(t, lines, 50)
101
102	// Verify we got the last 50 entries (entry 50-99).
103	require.Contains(t, lines[0], "Entry 50")
104	require.Contains(t, lines[49], "Entry 99")
105}
106
107func TestCrushLogs_MaxCap(t *testing.T) {
108	t.Parallel()
109	// Create 200 log entries.
110	var entries []map[string]any
111	for i := range 200 {
112		entries = append(entries, makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil))
113	}
114
115	logFile := createTestLogFile(t, entries)
116
117	// Request 200 lines, but should only get 100 (max cap).
118	result := runCrushLogs(logFile, CrushLogsParams{Lines: 200})
119
120	lines := strings.Split(result, "\n")
121	require.Len(t, lines, 100)
122}
123
124func TestCrushLogs_MissingFile(t *testing.T) {
125	t.Parallel()
126	result := runCrushLogs("/nonexistent/path/crush.log", CrushLogsParams{Lines: 50})
127	require.Contains(t, result, "No log file found")
128}
129
130func TestCrushLogs_EmptyFile(t *testing.T) {
131	t.Parallel()
132	tempDir := t.TempDir()
133	logFile := filepath.Join(tempDir, "crush.log")
134	_, err := os.Create(logFile)
135	require.NoError(t, err)
136
137	result := runCrushLogs(logFile, CrushLogsParams{Lines: 50})
138	require.Contains(t, result, "Log file is empty")
139}
140
141func TestCrushLogs_MalformedLines(t *testing.T) {
142	t.Parallel()
143	tempDir := t.TempDir()
144	logFile := filepath.Join(tempDir, "crush.log")
145
146	file, err := os.Create(logFile)
147	require.NoError(t, err)
148
149	// Write some valid and some invalid lines.
150	validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
151	line, _ := json.Marshal(validEntry)
152	file.WriteString(string(line) + "\n")
153	file.WriteString("this is not json\n")
154	file.WriteString(`{"incomplete": "json` + "\n")
155
156	validEntry2 := makeLogEntry("INFO", "Another valid entry", "app.go", 2, nil)
157	line2, _ := json.Marshal(validEntry2)
158	file.WriteString(string(line2) + "\n")
159
160	file.Close()
161
162	result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
163
164	lines := strings.Split(result, "\n")
165	// Only 2 valid lines should be returned.
166	require.Len(t, lines, 2)
167	require.Contains(t, lines[0], "Valid entry")
168	require.Contains(t, lines[1], "Another valid entry")
169}
170
171func TestCrushLogs_ExtraFieldsSorted(t *testing.T) {
172	t.Parallel()
173	entries := []map[string]any{
174		makeLogEntry("INFO", "Test message", "app.go", 1, map[string]any{
175			"z_field": "last",
176			"a_field": "first",
177			"m_field": "middle",
178		}),
179	}
180
181	logFile := createTestLogFile(t, entries)
182
183	result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
184
185	// Fields should be sorted alphabetically.
186	idxA := strings.Index(result, "a_field=first")
187	idxM := strings.Index(result, "m_field=middle")
188	idxZ := strings.Index(result, "z_field=last")
189
190	require.True(t, idxA < idxM, "a_field should come before m_field")
191	require.True(t, idxM < idxZ, "m_field should come before z_field")
192}
193
194func TestCrushLogs_NonStringValues(t *testing.T) {
195	t.Parallel()
196	entry := map[string]any{
197		"time":   time.Now().Format(time.RFC3339),
198		"level":  "INFO",
199		"msg":    "Test values",
200		"source": map[string]any{"file": "app.go", "line": 1},
201		"count":  42,
202		"ratio":  3.14,
203		"active": true,
204		"data":   nil,
205		"obj":    map[string]any{"key": "value"},
206		"arr":    []any{1, 2, 3},
207	}
208
209	logFile := createTestLogFile(t, []map[string]any{entry})
210
211	result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
212
213	// Numbers should be bare (not quoted).
214	require.Contains(t, result, "count=42")
215	require.Contains(t, result, "ratio=3.14")
216
217	// Booleans should be bare.
218	require.Contains(t, result, "active=true")
219
220	// Null should be bare.
221	require.Contains(t, result, "data=null")
222
223	// Objects and arrays should be JSON-encoded and quoted.
224	require.Contains(t, result, `obj="{`)
225	require.Contains(t, result, `arr="[`)
226}
227
228func TestCrushLogs_Redaction(t *testing.T) {
229	t.Parallel()
230	entries := []map[string]any{
231		makeLogEntry("INFO", "API call", "api.go", 10, map[string]any{
232			"authorization":  "Bearer secret123",
233			"api_key":        "my-api-key",
234			"api-key":        "my-api-key-2",
235			"apikey":         "myapikey",
236			"token":          "mytoken",
237			"secret":         "mysecret",
238			"password":       "mypassword",
239			"credential":     "mycred",
240			"Authorization":  "Bearer secret456",
241			"API_KEY":        "uppercase",
242			"my_token_value": "nestedtoken",
243		}),
244	}
245
246	logFile := createTestLogFile(t, entries)
247
248	result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
249
250	// All sensitive fields should be redacted.
251	require.Contains(t, result, "authorization=[REDACTED]")
252	require.Contains(t, result, "api_key=[REDACTED]")
253	require.Contains(t, result, "api-key=[REDACTED]")
254	require.Contains(t, result, "apikey=[REDACTED]")
255	require.Contains(t, result, "token=[REDACTED]")
256	require.Contains(t, result, "secret=[REDACTED]")
257	require.Contains(t, result, "password=[REDACTED]")
258	require.Contains(t, result, "credential=[REDACTED]")
259	require.Contains(t, result, "Authorization=[REDACTED]")
260	require.Contains(t, result, "API_KEY=[REDACTED]")
261	require.Contains(t, result, "my_token_value=[REDACTED]")
262
263	// Original sensitive values should not appear.
264	require.NotContains(t, result, "secret123")
265	require.NotContains(t, result, "my-api-key")
266	require.NotContains(t, result, "mytoken")
267}
268
269func TestCrushLogs_ReservedFields(t *testing.T) {
270	t.Parallel()
271	entries := []map[string]any{
272		{
273			"time":   time.Now().Format(time.RFC3339),
274			"level":  "INFO",
275			"msg":    "Test",
276			"source": map[string]any{"file": "app.go", "line": 1},
277			"Time":   "should be reserved",
278			"LEVEL":  "should be reserved",
279			"Msg":    "should be reserved",
280			"SOURCE": "should be reserved",
281			"extra":  "should appear",
282		},
283	}
284
285	logFile := createTestLogFile(t, entries)
286
287	result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
288
289	// Reserved fields (case-insensitive) should not appear in extra fields.
290	require.NotContains(t, result, "Time=")
291	require.NotContains(t, result, "LEVEL=")
292	require.NotContains(t, result, "Msg=")
293	require.NotContains(t, result, "SOURCE=")
294	require.NotContains(t, result, "time=")  // The extra time field
295	require.NotContains(t, result, "level=") // The extra level field
296
297	// Non-reserved field should appear (quoted since it has spaces).
298	require.Contains(t, result, `extra="should appear"`)
299}
300
301func TestCrushLogs_OversizedLines(t *testing.T) {
302	t.Parallel()
303	tempDir := t.TempDir()
304	logFile := filepath.Join(tempDir, "crush.log")
305
306	file, err := os.Create(logFile)
307	require.NoError(t, err)
308
309	// Create a valid entry first.
310	validEntry := makeLogEntry("INFO", "Valid entry", "app.go", 1, nil)
311	line, _ := json.Marshal(validEntry)
312	file.WriteString(string(line) + "\n")
313
314	// Create an oversized line (more than 1 MB).
315	bigValue := strings.Repeat("x", maxLogLineSize+1000)
316	bigEntry := map[string]any{
317		"time":   time.Now().Format(time.RFC3339),
318		"level":  "INFO",
319		"msg":    "Big message",
320		"source": map[string]any{"file": "big.go", "line": 1},
321		"data":   bigValue,
322	}
323	bigLine, _ := json.Marshal(bigEntry)
324	file.WriteString(string(bigLine) + "\n")
325
326	// Create another valid entry.
327	validEntry2 := makeLogEntry("INFO", "Second valid entry", "app.go", 2, nil)
328	line2, _ := json.Marshal(validEntry2)
329	file.WriteString(string(line2) + "\n")
330
331	file.Close()
332
333	result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
334
335	lines := strings.Split(result, "\n")
336
337	// Only the 2 valid entries should be returned (oversized one skipped).
338	require.Len(t, lines, 2)
339	require.Contains(t, lines[0], "Valid entry")
340	require.Contains(t, lines[1], "Second valid entry")
341}
342
343func TestCrushLogs_PartialTrailingLine(t *testing.T) {
344	t.Parallel()
345	tempDir := t.TempDir()
346	logFile := filepath.Join(tempDir, "crush.log")
347
348	file, err := os.Create(logFile)
349	require.NoError(t, err)
350
351	// Create valid entries.
352	for i := range 5 {
353		entry := makeLogEntry("INFO", fmt.Sprintf("Entry %d", i), "app.go", i, nil)
354		line, _ := json.Marshal(entry)
355		file.WriteString(string(line) + "\n")
356	}
357
358	// Write a partial/truncated line (no closing brace or newline).
359	file.WriteString(`{"time": "2024-01-15T10:00:00Z", "level": "INFO", "msg": "Truncated`)
360	file.Close()
361
362	result := runCrushLogs(logFile, CrushLogsParams{Lines: 10})
363
364	lines := strings.Split(result, "\n")
365
366	// Should get the 5 valid entries, truncated line is skipped.
367	require.Len(t, lines, 5)
368	for i, line := range lines {
369		require.Contains(t, line, fmt.Sprintf("Entry %d", i))
370	}
371}
372
373func TestCrushLogs_ValueQuoting(t *testing.T) {
374	t.Parallel()
375	entries := []map[string]any{
376		makeLogEntry("INFO", "Test", "app.go", 1, map[string]any{
377			"empty":          "",
378			"with_spaces":    "hello world",
379			"with_equals":    "a=b",
380			"with_newline":   "line1\nline2",
381			"with_quote":     `say "hello"`,
382			"with_backslash": "path\\to\\file",
383			"normal":         "simplevalue",
384		}),
385	}
386
387	logFile := createTestLogFile(t, entries)
388
389	result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
390
391	// Empty strings should be quoted.
392	require.Contains(t, result, `empty=""`)
393
394	// Strings with spaces should be quoted.
395	require.Contains(t, result, `with_spaces="hello world"`)
396
397	// Strings with = should be quoted.
398	require.Contains(t, result, `with_equals="a=b"`)
399
400	// Strings with newlines should escape them.
401	require.Contains(t, result, `with_newline="line1\nline2"`)
402
403	// Strings with quotes should escape them.
404	require.Contains(t, result, `with_quote="say \"hello\""`)
405
406	// Strings with backslashes should escape them.
407	require.Contains(t, result, `with_backslash="path\\to\\file"`)
408
409	// Normal strings without special chars should be bare.
410	require.Contains(t, result, "normal=simplevalue")
411}
412
413func TestCrushLogs_ChronologicalOrder(t *testing.T) {
414	t.Parallel()
415	// Create entries with different timestamps.
416	baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
417	entries := []map[string]any{
418		{
419			"time":   baseTime.Add(0 * time.Second).Format(time.RFC3339),
420			"level":  "INFO",
421			"msg":    "First",
422			"source": map[string]any{"file": "app.go", "line": 1},
423		},
424		{
425			"time":   baseTime.Add(1 * time.Second).Format(time.RFC3339),
426			"level":  "INFO",
427			"msg":    "Second",
428			"source": map[string]any{"file": "app.go", "line": 2},
429		},
430		{
431			"time":   baseTime.Add(2 * time.Second).Format(time.RFC3339),
432			"level":  "INFO",
433			"msg":    "Third",
434			"source": map[string]any{"file": "app.go", "line": 3},
435		},
436	}
437
438	logFile := createTestLogFile(t, entries)
439
440	result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
441
442	lines := strings.Split(result, "\n")
443
444	// Verify chronological order (oldest first).
445	require.Len(t, lines, 3)
446	require.Contains(t, lines[0], "First")
447	require.Contains(t, lines[1], "Second")
448	require.Contains(t, lines[2], "Third")
449}
450
451func TestCrushLogs_TimeOnlyFormat(t *testing.T) {
452	t.Parallel()
453	entry := map[string]any{
454		"time":   "2024-01-15T15:04:05Z",
455		"level":  "INFO",
456		"msg":    "Test",
457		"source": map[string]any{"file": "app.go", "line": 1},
458	}
459
460	logFile := createTestLogFile(t, []map[string]any{entry})
461
462	result := runCrushLogs(logFile, CrushLogsParams{Lines: 1})
463
464	// Should show time-only format.
465	require.True(t, strings.HasPrefix(result, "15:04:05"), "Expected time-only format, got: %s", result)
466}
467
468func TestCrushLogs_LevelVariations(t *testing.T) {
469	t.Parallel()
470	entries := []map[string]any{
471		makeLogEntry("DEBUG", "Debug message", "app.go", 1, nil),
472		makeLogEntry("INFO", "Info message", "app.go", 2, nil),
473		makeLogEntry("WARN", "Warn message", "app.go", 3, nil),
474		makeLogEntry("WARNING", "Warning message", "app.go", 4, nil),
475		makeLogEntry("ERROR", "Error message", "app.go", 5, nil),
476	}
477
478	logFile := createTestLogFile(t, entries)
479
480	result := runCrushLogs(logFile, CrushLogsParams{Lines: 5})
481
482	lines := strings.Split(result, "\n")
483	require.Len(t, lines, 5)
484
485	// Check level normalization.
486	require.Contains(t, lines[0], "DEBUG")
487	require.Contains(t, lines[1], "INFO")
488	require.Contains(t, lines[2], "WARN")
489	require.Contains(t, lines[3], "WARN") // WARNING -> WARN
490	require.Contains(t, lines[4], "ERROR")
491}
492
493func TestCrushLogs_SourceVariations(t *testing.T) {
494	t.Parallel()
495	entries := []map[string]any{
496		// Source as object with file and line.
497		{
498			"time":   time.Now().Format(time.RFC3339),
499			"level":  "INFO",
500			"msg":    "Object source",
501			"source": map[string]any{"file": "/path/to/app.go", "line": 42},
502		},
503		// Source as string.
504		{
505			"time":   time.Now().Format(time.RFC3339),
506			"level":  "INFO",
507			"msg":    "String source",
508			"source": "/path/to/handler.go:100",
509		},
510		// Missing source.
511		{
512			"time":  time.Now().Format(time.RFC3339),
513			"level": "INFO",
514			"msg":   "No source",
515		},
516	}
517
518	logFile := createTestLogFile(t, entries)
519
520	result := runCrushLogs(logFile, CrushLogsParams{Lines: 3})
521
522	lines := strings.Split(result, "\n")
523	require.Len(t, lines, 3)
524
525	// Check source formatting (should use basename only).
526	require.Contains(t, lines[0], "app.go:42")
527	require.Contains(t, lines[1], "handler.go") // String source gets basename too
528	require.Contains(t, lines[2], "unknown:0")  // Missing source
529}