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}