crush_logs_test.go

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