- 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)
215 lines
6.0 KiB
Go
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
|
|
}
|