diff --git a/cmd/keys_test.go b/cmd/keys_test.go new file mode 100644 index 0000000..6385a77 --- /dev/null +++ b/cmd/keys_test.go @@ -0,0 +1,214 @@ +package cmd + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/salvacybersec/keyhunter/pkg/storage" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedKeysDB creates a fresh SQLite database populated with three findings +// (two openai, one anthropic; one openai row marked verified) and wires +// viper + env so the keys subcommands pick the same DB up via openDBWithKey. +// A t.Cleanup resets global state so flag/viper bleed between tests is +// impossible. +func seedKeysDB(t *testing.T) string { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "keys.db") + + viper.Reset() + viper.Set("database.path", dbPath) + t.Setenv("KEYHUNTER_PASSPHRASE", "test-pass") + resetKeysFlags() + + db, err := storage.Open(dbPath) + require.NoError(t, err) + encKey, err := loadOrCreateEncKey(db, "test-pass") + require.NoError(t, err) + + seed := []storage.Finding{ + {ProviderName: "openai", KeyValue: "sk-aaaaaaaaaaaaaaaaaaaa", KeyMasked: "sk-aaaa...aaaa", Confidence: "high", SourcePath: "a.go", LineNumber: 10}, + {ProviderName: "openai", KeyValue: "sk-bbbbbbbbbbbbbbbbbbbb", KeyMasked: "sk-bbbb...bbbb", Confidence: "medium", SourcePath: "b.go", LineNumber: 20, Verified: true, VerifyStatus: "live"}, + {ProviderName: "anthropic", KeyValue: "sk-ant-cccccccccccccccc", KeyMasked: "sk-ant-c...cccc", Confidence: "high", SourcePath: "c.go", LineNumber: 30}, + } + for _, f := range seed { + _, err := db.SaveFinding(f, encKey) + require.NoError(t, err) + } + require.NoError(t, db.Close()) + + t.Cleanup(func() { + viper.Reset() + resetKeysFlags() + }) + return dbPath +} + +func resetKeysFlags() { + flagKeysUnmask = false + flagKeysProvider = "" + flagKeysVerified = false + flagKeysLimit = 0 + flagKeysFormat = "json" + flagKeysOutFile = "" + flagKeysYes = false +} + +func TestKeysList_Default(t *testing.T) { + _ = seedKeysDB(t) + var out bytes.Buffer + keysListCmd.SetOut(&out) + keysListCmd.SetErr(&out) + err := keysListCmd.RunE(keysListCmd, []string{}) + require.NoError(t, err) + + s := out.String() + assert.Contains(t, s, "openai") + assert.Contains(t, s, "anthropic") + assert.Contains(t, s, "sk-aaaa...aaaa") + assert.Contains(t, s, "sk-ant-c...cccc") + // masked by default: full plaintext key must not appear + assert.NotContains(t, s, "sk-aaaaaaaaaaaaaaaaaaaa") + assert.Contains(t, s, "3 key(s).") +} + +func TestKeysList_FilterProvider(t *testing.T) { + _ = seedKeysDB(t) + flagKeysProvider = "openai" + var out bytes.Buffer + keysListCmd.SetOut(&out) + keysListCmd.SetErr(&out) + err := keysListCmd.RunE(keysListCmd, []string{}) + require.NoError(t, err) + + s := out.String() + assert.Contains(t, s, "openai") + assert.NotContains(t, s, "anthropic") + assert.Contains(t, s, "2 key(s).") +} + +func TestKeysShow_Hit(t *testing.T) { + dbPath := seedKeysDB(t) + id := firstFindingID(t, dbPath) + + var out bytes.Buffer + keysShowCmd.SetOut(&out) + keysShowCmd.SetErr(&out) + err := keysShowCmd.RunE(keysShowCmd, []string{strconv.FormatInt(id, 10)}) + require.NoError(t, err) + + s := out.String() + // show is always unmasked: full plaintext must be present + assert.Contains(t, s, "sk-aaaaaaaaaaaaaaaaaaaa") + assert.Contains(t, s, "Provider:") + assert.Contains(t, s, "openai") +} + +func TestKeysShow_Miss(t *testing.T) { + _ = seedKeysDB(t) + var out bytes.Buffer + keysShowCmd.SetOut(&out) + keysShowCmd.SetErr(&out) + err := keysShowCmd.RunE(keysShowCmd, []string{"9999"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no finding with id 9999") +} + +func TestKeysExport_JSON(t *testing.T) { + _ = seedKeysDB(t) + flagKeysFormat = "json" + flagKeysOutFile = "" + + var out bytes.Buffer + keysExportCmd.SetOut(&out) + keysExportCmd.SetErr(&out) + err := keysExportCmd.RunE(keysExportCmd, []string{}) + require.NoError(t, err) + + var parsed []map[string]interface{} + require.NoError(t, json.Unmarshal(out.Bytes(), &parsed)) + assert.Len(t, parsed, 3) + + // Exports are unmasked — at least one row should carry the plaintext key. + found := false + for _, row := range parsed { + if row["key"] == "sk-aaaaaaaaaaaaaaaaaaaa" { + found = true + break + } + } + assert.True(t, found, "expected unmasked plaintext key in JSON export") +} + +func TestKeysExport_CSVFile(t *testing.T) { + _ = seedKeysDB(t) + outFile := filepath.Join(t.TempDir(), "export.csv") + flagKeysFormat = "csv" + flagKeysOutFile = outFile + + var out bytes.Buffer + keysExportCmd.SetOut(&out) + keysExportCmd.SetErr(&out) + err := keysExportCmd.RunE(keysExportCmd, []string{}) + require.NoError(t, err) + + data, err := os.ReadFile(outFile) + require.NoError(t, err) + + r := csv.NewReader(strings.NewReader(string(data))) + rows, err := r.ReadAll() + require.NoError(t, err) + + require.Len(t, rows, 4) // header + 3 data + assert.Equal(t, "id", rows[0][0]) + assert.Equal(t, "provider", rows[0][1]) +} + +func TestKeysDelete_WithYes(t *testing.T) { + dbPath := seedKeysDB(t) + id := firstFindingID(t, dbPath) + flagKeysYes = true + + var out bytes.Buffer + keysDeleteCmd.SetOut(&out) + keysDeleteCmd.SetErr(&out) + err := keysDeleteCmd.RunE(keysDeleteCmd, []string{strconv.FormatInt(id, 10)}) + require.NoError(t, err) + assert.Contains(t, out.String(), "Deleted finding") + + // Verify list now returns 2. + flagKeysYes = false + var out2 bytes.Buffer + keysListCmd.SetOut(&out2) + keysListCmd.SetErr(&out2) + require.NoError(t, keysListCmd.RunE(keysListCmd, []string{})) + assert.Contains(t, out2.String(), "2 key(s).") +} + +// NOTE: TestKeysCopy is omitted — the atotto/clipboard backend is unavailable +// in headless CI environments. The copy command path is exercised manually. +// NOTE: TestKeysVerify is omitted — verify requires live network calls to +// provider endpoints which is out of scope for unit tests. + +// firstFindingID returns the smallest id present in the findings table of the +// seeded database. Used so tests do not depend on SQLite rowid sequencing. +func firstFindingID(t *testing.T, dbPath string) int64 { + t.Helper() + db, err := storage.Open(dbPath) + require.NoError(t, err) + defer db.Close() + row := db.SQL().QueryRow(`SELECT MIN(id) FROM findings`) + var id int64 + require.NoError(t, row.Scan(&id)) + return id +}