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]) } }