Files
keyhunter/cmd/keys_test.go
salvacybersec e2394ec663 test(06-05): integration tests for keys list/show/export/delete
- Temp-file SQLite DB seeded with three findings (2 openai, 1 anthropic,
  one verified) via storage.SaveFinding + loadOrCreateEncKey
- RunE + cmd.SetOut buffers for hermetic stdout capture
- Covers: list default + provider filter, show hit (unmasked) + miss,
  export JSON stdout (parses + plaintext present), export CSV to file
  (header + 3 rows), delete --yes then list returns 2
- TestKeysCopy and TestKeysVerify are documented as intentionally skipped
  (clipboard backend unavailable headlessly; verify needs network)
2026-04-05 23:39:07 +03:00

215 lines
6.0 KiB
Go

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
}