diff --git a/pkg/output/csv_test.go b/pkg/output/csv_test.go new file mode 100644 index 0000000..9c4cfc8 --- /dev/null +++ b/pkg/output/csv_test.go @@ -0,0 +1,171 @@ +package output + +import ( + "bytes" + "encoding/csv" + "strings" + "testing" + "time" + + "github.com/salvacybersec/keyhunter/pkg/engine" +) + +func csvSampleFinding() engine.Finding { + return engine.Finding{ + ProviderName: "anthropic", + KeyValue: "sk-ant-api03-FULLKEY1234567890", + KeyMasked: "sk-ant-a...7890", + Confidence: "medium", + Source: "src/client.go", + SourceType: "file", + LineNumber: 17, + DetectedAt: time.Date(2026, 4, 5, 8, 15, 0, 0, time.UTC), + } +} + +func TestCSVFormatter_RegisteredUnderCSV(t *testing.T) { + f, err := Get("csv") + if err != nil { + t.Fatalf("Get(\"csv\") error: %v", err) + } + if _, ok := f.(CSVFormatter); !ok { + t.Fatalf("Get(\"csv\") returned %T, want CSVFormatter", f) + } +} + +func TestCSVFormatter_HeaderOnly(t *testing.T) { + var buf bytes.Buffer + if err := (CSVFormatter{}).Format(nil, &buf, Options{}); err != nil { + t.Fatalf("Format error: %v", err) + } + r := csv.NewReader(strings.NewReader(buf.String())) + rows, err := r.ReadAll() + if err != nil { + t.Fatalf("csv parse error: %v\n%s", err, buf.String()) + } + if len(rows) != 1 { + t.Fatalf("want 1 row (header only), got %d: %v", len(rows), rows) + } + wantHeader := []string{"id", "provider", "confidence", "key", "source", "line", "detected_at", "verified", "verify_status"} + if len(rows[0]) != len(wantHeader) { + t.Fatalf("header length: got %d want %d", len(rows[0]), len(wantHeader)) + } + for i, col := range wantHeader { + if rows[0][i] != col { + t.Errorf("header[%d]: got %q want %q", i, rows[0][i], col) + } + } +} + +func TestCSVFormatter_RowMasked(t *testing.T) { + f := csvSampleFinding() + var buf bytes.Buffer + if err := (CSVFormatter{}).Format([]engine.Finding{f}, &buf, Options{}); err != nil { + t.Fatalf("Format error: %v", err) + } + r := csv.NewReader(strings.NewReader(buf.String())) + rows, err := r.ReadAll() + if err != nil { + t.Fatalf("csv parse error: %v", err) + } + if len(rows) != 2 { + t.Fatalf("want 2 rows, got %d", len(rows)) + } + row := rows[1] + if row[0] != "0" { + t.Errorf("id: got %q want 0", row[0]) + } + if row[1] != "anthropic" { + t.Errorf("provider: got %q", row[1]) + } + if row[2] != "medium" { + t.Errorf("confidence: got %q", row[2]) + } + if row[3] != f.KeyMasked { + t.Errorf("key (masked): got %q want %q", row[3], f.KeyMasked) + } + if row[4] != "src/client.go" { + t.Errorf("source: got %q", row[4]) + } + if row[5] != "17" { + t.Errorf("line: got %q", row[5]) + } + if row[6] != "2026-04-05T08:15:00Z" { + t.Errorf("detected_at: got %q", row[6]) + } + if row[7] != "false" { + t.Errorf("verified: got %q", row[7]) + } +} + +func TestCSVFormatter_Unmask(t *testing.T) { + f := csvSampleFinding() + var buf bytes.Buffer + if err := (CSVFormatter{}).Format([]engine.Finding{f}, &buf, Options{Unmask: true}); err != nil { + t.Fatalf("Format error: %v", err) + } + rows, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatalf("csv parse error: %v", err) + } + if rows[1][3] != f.KeyValue { + t.Errorf("key (unmasked): got %q want %q", rows[1][3], f.KeyValue) + } +} + +func TestCSVFormatter_QuotesCommaInSource(t *testing.T) { + f := csvSampleFinding() + f.Source = "path, with, commas.txt" + var buf bytes.Buffer + if err := (CSVFormatter{}).Format([]engine.Finding{f}, &buf, Options{}); err != nil { + t.Fatalf("Format error: %v", err) + } + // Round-trip must preserve the comma-containing source verbatim. + rows, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatalf("csv parse error: %v", err) + } + if rows[1][4] != "path, with, commas.txt" { + t.Errorf("source: got %q", rows[1][4]) + } +} + +func TestCSVFormatter_VerifiedRow(t *testing.T) { + f := csvSampleFinding() + f.Verified = true + f.VerifyStatus = "live" + var buf bytes.Buffer + if err := (CSVFormatter{}).Format([]engine.Finding{f}, &buf, Options{}); err != nil { + t.Fatalf("Format error: %v", err) + } + rows, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatalf("csv parse error: %v", err) + } + if rows[1][7] != "true" { + t.Errorf("verified: got %q want true", rows[1][7]) + } + if rows[1][8] != "live" { + t.Errorf("verify_status: got %q want live", rows[1][8]) + } +} + +func TestCSVFormatter_MultipleRowsIncrementID(t *testing.T) { + a := csvSampleFinding() + b := csvSampleFinding() + b.ProviderName = "openai" + var buf bytes.Buffer + if err := (CSVFormatter{}).Format([]engine.Finding{a, b}, &buf, Options{}); err != nil { + t.Fatalf("Format error: %v", err) + } + rows, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatalf("csv parse error: %v", err) + } + if len(rows) != 3 { + t.Fatalf("want 3 rows, got %d", len(rows)) + } + if rows[1][0] != "0" || rows[2][0] != "1" { + t.Errorf("ids: got %q,%q want 0,1", rows[1][0], rows[2][0]) + } +}