diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..558616b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(go mod why:*)", + "Bash(go list:*)", + "Bash(~/go/bin/govulncheck -mode=module .)", + "Bash(go test:*)", + "Bash(grep:*)", + "Bash(rg:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/Makefile b/Makefile index e4b4b6a..1c56148 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ default: check +build: ./secret + # Simple build (no code signing needed) ./secret: go build -v -o $@ cmd/secret/main.go diff --git a/go.mod b/go.mod index 876aee2..e4fe87f 100644 --- a/go.mod +++ b/go.mod @@ -22,12 +22,9 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/pretty v0.2.1 // indirect - github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0913b23..ca00620 100644 --- a/go.sum +++ b/go.sum @@ -31,7 +31,6 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -61,12 +60,6 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= @@ -137,9 +130,8 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/cli/generate.go b/internal/cli/generate.go index d83db65..5348109 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -6,7 +6,7 @@ import ( "math/big" "os" - "git.eeqj.de/sneak/secret/internal/secret" + "git.eeqj.de/sneak/secret/internal/vault" "github.com/spf13/cobra" "github.com/tyler-smith/go-bip39" ) @@ -31,7 +31,7 @@ func newGenerateMnemonicCmd() *cobra.Command { Long: `Generate a cryptographically secure random BIP39 mnemonic phrase that can be used with 'secret init' or 'secret import'.`, RunE: func(cmd *cobra.Command, args []string) error { cli := NewCLIInstance() - return cli.GenerateMnemonic() + return cli.GenerateMnemonic(cmd) }, } } @@ -48,7 +48,7 @@ func newGenerateSecretCmd() *cobra.Command { force, _ := cmd.Flags().GetBool("force") cli := NewCLIInstance() - return cli.GenerateSecret(args[0], length, secretType, force) + return cli.GenerateSecret(cmd, args[0], length, secretType, force) }, } @@ -60,7 +60,7 @@ func newGenerateSecretCmd() *cobra.Command { } // GenerateMnemonic generates a random BIP39 mnemonic phrase -func (cli *CLIInstance) GenerateMnemonic() error { +func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error { // Generate 128 bits of entropy for a 12-word mnemonic entropy, err := bip39.NewEntropy(128) if err != nil { @@ -74,7 +74,7 @@ func (cli *CLIInstance) GenerateMnemonic() error { } // Output mnemonic to stdout - fmt.Println(mnemonic) + cmd.Println(mnemonic) // Output helpful information to stderr fmt.Fprintln(os.Stderr, "") @@ -92,7 +92,7 @@ func (cli *CLIInstance) GenerateMnemonic() error { } // GenerateSecret generates a random secret and stores it in the vault -func (cli *CLIInstance) GenerateSecret(secretName string, length int, secretType string, force bool) error { +func (cli *CLIInstance) GenerateSecret(cmd *cobra.Command, secretName string, length int, secretType string, force bool) error { if length < 1 { return fmt.Errorf("length must be at least 1") } @@ -116,16 +116,16 @@ func (cli *CLIInstance) GenerateSecret(secretName string, length int, secretType } // Store the secret in the vault - vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir) + vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return err } - if err := vault.AddSecret(secretName, []byte(secretValue), force); err != nil { + if err := vlt.AddSecret(secretName, []byte(secretValue), force); err != nil { return err } - fmt.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName) + cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName) return nil } diff --git a/internal/cli/init.go b/internal/cli/init.go index 75a82b6..c5ed72e 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -16,19 +16,23 @@ import ( "github.com/tyler-smith/go-bip39" ) -func newInitCmd() *cobra.Command { +// NewInitCmd creates the init command +func NewInitCmd() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Initialize the secrets manager", Long: `Create the necessary directory structure for storing secrets and generate encryption keys.`, - RunE: func(cmd *cobra.Command, args []string) error { - cli := NewCLIInstance() - return cli.Init(cmd) - }, + RunE: RunInit, } } -// Init initializes the secrets manager +// RunInit is the exported function that handles the init command +func RunInit(cmd *cobra.Command, args []string) error { + cli := NewCLIInstance() + return cli.Init(cmd) +} + +// Init initializes the secret manager func (cli *CLIInstance) Init(cmd *cobra.Command) error { secret.Debug("Starting secret manager initialization") diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index dbaf06d..2c61949 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -10,15 +10,50 @@ import ( "testing" "time" + "git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/pkg/agehd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// TestMain runs before all tests and ensures the binary is built +func TestMain(m *testing.M) { + // Get the current working directory + wd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get working directory: %v\n", err) + os.Exit(1) + } + + // Navigate up from internal/cli to project root + projectRoot := filepath.Join(wd, "..", "..") + + // Build the binary + cmd := exec.Command("go", "build", "-o", "secret", "./cmd/secret") + cmd.Dir = projectRoot + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to build secret binary: %v\nOutput: %s\n", err, output) + os.Exit(1) + } + + // Run the tests + code := m.Run() + + // Clean up the binary + os.Remove(filepath.Join(projectRoot, "secret")) + + os.Exit(code) +} + // TestSecretManagerIntegration is a comprehensive integration test that exercises // all functionality of the secret manager using a real filesystem in a temporary directory. // This test serves as both validation and documentation of the program's behavior. func TestSecretManagerIntegration(t *testing.T) { + // Enable debug logging to diagnose test failures + os.Setenv("GODEBUG", "berlin.sneak.pkg.secret") + defer os.Unsetenv("GODEBUG") + // Test configuration testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" testPassphrase := "test-passphrase-123" @@ -30,48 +65,30 @@ func TestSecretManagerIntegration(t *testing.T) { os.Setenv("SB_SECRET_STATE_DIR", tempDir) defer os.Unsetenv("SB_SECRET_STATE_DIR") - // Find the secret binary path - // Look for it relative to the test file location + // Find the secret binary path (needed for tests that still use exec.Command) wd, err := os.Getwd() require.NoError(t, err, "should get working directory") - - // Navigate up from internal/cli to project root projectRoot := filepath.Join(wd, "..", "..") secretPath := filepath.Join(projectRoot, "secret") - // Verify the binary exists - _, err = os.Stat(secretPath) - require.NoError(t, err, "secret binary should exist at %s", secretPath) - // Helper function to run the secret command runSecret := func(args ...string) (string, error) { - cmd := exec.Command(secretPath, args...) - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - output, err := cmd.CombinedOutput() - return string(output), err + return cli.ExecuteCommandInProcess(args, "", nil) } // Helper function to run secret with environment variables runSecretWithEnv := func(env map[string]string, args ...string) (string, error) { - cmd := exec.Command(secretPath, args...) - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - for k, v := range env { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) - } - output, err := cmd.CombinedOutput() - return string(output), err + return cli.ExecuteCommandInProcess(args, "", env) + } + + // Helper function to run secret with stdin + runSecretWithStdin := func(stdin string, env map[string]string, args ...string) (string, error) { + return cli.ExecuteCommandInProcess(args, stdin, env) } // Declare runSecret to avoid unused variable error - will be used in later tests _ = runSecret + _ = runSecretWithStdin // Test 1: Initialize secret manager // Command: secret init @@ -81,7 +98,7 @@ func TestSecretManagerIntegration(t *testing.T) { // - currentvault symlink -> vaults.d/default // - default vault has pub.age file // - default vault has unlockers.d directory with passphrase unlocker - test01Initialize(t, tempDir, secretPath, testMnemonic, testPassphrase, runSecretWithEnv) + test01Initialize(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv) // Test 2: Vault management - List vaults // Command: secret vault list @@ -113,7 +130,7 @@ func TestSecretManagerIntegration(t *testing.T) { // - secrets.d/database%password/versions/YYYYMMDD.001/ created // - Version directory contains: pub.age, priv.age, value.age, metadata.age // - current symlink points to version directory - test05AddSecret(t, tempDir, secretPath, testMnemonic, runSecret, runSecretWithEnv) + test05AddSecret(t, tempDir, testMnemonic, runSecret, runSecretWithEnv, runSecretWithStdin) // Test 6: Retrieve secret // Command: secret get database/password @@ -128,7 +145,7 @@ func TestSecretManagerIntegration(t *testing.T) { // - New version directory YYYYMMDD.002 created // - current symlink updated to new version // - Old version still exists - test07AddSecretVersion(t, tempDir, secretPath, testMnemonic, runSecret, runSecretWithEnv) + test07AddSecretVersion(t, tempDir, testMnemonic, runSecret, runSecretWithEnv, runSecretWithStdin) // Test 8: List secret versions // Command: secret version list database/password @@ -154,13 +171,13 @@ func TestSecretManagerIntegration(t *testing.T) { // Command: secret list // Purpose: Show all secrets in current vault // Expected: Shows database/password with metadata - test11ListSecrets(t, tempDir, secretPath, testMnemonic, runSecret) + test11ListSecrets(t, tempDir, testMnemonic, runSecret, runSecretWithStdin) // Test 12: Add secrets with different name formats // Commands: Various secret names (paths, dots, underscores) // Purpose: Test secret name validation and storage encoding // Expected: Proper filesystem encoding (/ -> %) - test12SecretNameFormats(t, tempDir, secretPath, testMnemonic, runSecretWithEnv) + test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin) // Test 13: Unlocker management // Commands: secret unlockers list, secret unlockers add pgp @@ -180,7 +197,7 @@ func TestSecretManagerIntegration(t *testing.T) { // Test 15: Cross-vault isolation // Purpose: Verify secrets in one vault aren't accessible from another // Expected: Secrets from work vault not visible in default vault - test15VaultIsolation(t, tempDir, secretPath, testMnemonic, runSecret, runSecretWithEnv) + test15VaultIsolation(t, tempDir, testMnemonic, runSecret, runSecretWithEnv, runSecretWithStdin) // Test 16: Generate random secrets // Command: secret generate secret api/key --length 32 --type base58 @@ -192,7 +209,7 @@ func TestSecretManagerIntegration(t *testing.T) { // Command: secret import ssh/key --source ~/.ssh/id_rsa // Purpose: Import existing file as secret // Expected: File contents stored as secret value - test17ImportFromFile(t, tempDir, secretPath, testMnemonic, runSecretWithEnv) + test17ImportFromFile(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin) // Test 18: Age key management // Commands: secret encrypt/decrypt using stored age keys @@ -277,7 +294,7 @@ func TestSecretManagerIntegration(t *testing.T) { // Helper functions for each test section -func test01Initialize(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { +func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { // Run init with environment variables to avoid prompts output, err := runSecretWithEnv(map[string]string{ "SB_SECRET_MNEMONIC": testMnemonic, @@ -343,12 +360,18 @@ func test01Initialize(t *testing.T, tempDir, secretPath, testMnemonic, testPassp // Read and verify vault metadata content metadataBytes := readFile(t, vaultMetadata) + t.Logf("Vault metadata raw content: %s", string(metadataBytes)) + var metadata map[string]interface{} err = json.Unmarshal(metadataBytes, &metadata) require.NoError(t, err, "vault metadata should be valid JSON") - assert.Equal(t, "default", metadata["name"], "vault name should be default") + t.Logf("Parsed metadata: %+v", metadata) + + // Verify metadata fields assert.Equal(t, float64(0), metadata["derivation_index"], "first vault should have index 0") + assert.Contains(t, metadata, "public_key_hash", "should contain public key hash") + assert.Contains(t, metadata, "createdAt", "should contain creation timestamp") // Verify the longterm.age file in passphrase unlocker longtermKeyFile := filepath.Join(passphraseUnlockerDir, "longterm.age") @@ -367,6 +390,10 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) { jsonOutput, err := runSecret("vault", "list", "--json") require.NoError(t, err, "vault list --json should succeed") + // Debug: log the raw JSON output to see what we're getting + t.Logf("Raw JSON output: %q", jsonOutput) + t.Logf("JSON output length: %d", len(jsonOutput)) + // Parse JSON output var response map[string]interface{} err = json.Unmarshal([]byte(jsonOutput), &response) @@ -481,7 +508,6 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st err = json.Unmarshal(metadataBytes, &metadata) require.NoError(t, err, "vault metadata should be valid JSON") - assert.Equal(t, "work", metadata["name"], "vault name should be work") // Work vault should have a different derivation index than default (0) derivIndex, ok := metadata["derivation_index"].(float64) require.True(t, ok, "derivation_index should be a number") @@ -494,7 +520,7 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st assert.NotEmpty(t, pubKeyHash, "public key hash should not be empty") } -func test05AddSecret(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { +func test05AddSecret(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { // Switch back to default vault which has derivation index 0 // matching our mnemonic environment variable _, err := runSecret("vault", "select", "default") @@ -502,17 +528,11 @@ func test05AddSecret(t *testing.T, tempDir, secretPath, testMnemonic string, run // Add a secret with environment variables set secretValue := "password123" - cmd := exec.Command(secretPath, "add", "database/password") - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - cmd.Stdin = strings.NewReader(secretValue) - output, err := cmd.CombinedOutput() + output, err := runSecretWithStdin(secretValue, map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + }, "add", "database/password") - require.NoError(t, err, "add secret should succeed: %s", string(output)) + require.NoError(t, err, "add secret should succeed: %s", output) // The add command has minimal output by design // Verify filesystem structure @@ -584,6 +604,9 @@ func test06GetSecret(t *testing.T, testMnemonic string, runSecret func(...string "SB_SECRET_MNEMONIC": testMnemonic, }, "get", "database/password") + t.Logf("Get secret output: %q (length=%d)", output, len(output)) + t.Logf("Get secret error: %v", err) + require.NoError(t, err, "get secret should succeed") assert.Equal(t, "password123", strings.TrimSpace(output), "should return correct secret value") @@ -593,24 +616,18 @@ func test06GetSecret(t *testing.T, testMnemonic string, runSecret func(...string assert.Contains(t, output, "failed to unlock vault", "should indicate unlock failure") } -func test07AddSecretVersion(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { +func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { // Make sure we're in default vault _, err := runSecret("vault", "select", "default") require.NoError(t, err, "vault select should succeed") // Add new version of existing secret newSecretValue := "newpassword456" - cmd := exec.Command(secretPath, "add", "database/password", "--force") - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - cmd.Stdin = strings.NewReader(newSecretValue) - output, err := cmd.CombinedOutput() + output, err := runSecretWithStdin(newSecretValue, map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + }, "add", "database/password", "--force") - require.NoError(t, err, "add secret with --force should succeed: %s", string(output)) + require.NoError(t, err, "add secret with --force should succeed: %s", output) // Verify filesystem structure defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default") @@ -800,22 +817,16 @@ func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret } } -func test11ListSecrets(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) { +func test11ListSecrets(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { // Make sure we're in default vault _, err := runSecret("vault", "select", "default") require.NoError(t, err, "vault select should succeed") // Add a couple more secrets to make the list more interesting for _, secretName := range []string{"api/key", "config/database.yaml"} { - cmd := exec.Command(secretPath, "add", secretName) - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - cmd.Stdin = strings.NewReader(fmt.Sprintf("test-value-%s", secretName)) - _, err := cmd.CombinedOutput() + _, err := runSecretWithStdin(fmt.Sprintf("test-value-%s", secretName), map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + }, "add", secretName) require.NoError(t, err, "add %s should succeed", secretName) } @@ -878,17 +889,10 @@ func test11ListSecrets(t *testing.T, tempDir, secretPath, testMnemonic string, r assert.True(t, secretNames["database/password"], "should have database/password") } -func test12SecretNameFormats(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { +func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { // Make sure we're in default vault runSecret := func(args ...string) (string, error) { - cmd := exec.Command(secretPath, args...) - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - output, err := cmd.CombinedOutput() - return string(output), err + return cli.ExecuteCommandInProcess(args, "", nil) } _, err := runSecret("vault", "select", "default") @@ -916,16 +920,10 @@ func test12SecretNameFormats(t *testing.T, tempDir, secretPath, testMnemonic str // Add each test secret for _, tc := range testCases { t.Run(tc.secretName, func(t *testing.T) { - cmd := exec.Command(secretPath, "add", tc.secretName) - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - cmd.Stdin = strings.NewReader(tc.value) - output, err := cmd.CombinedOutput() - require.NoError(t, err, "add %s should succeed: %s", tc.secretName, string(output)) + output, err := runSecretWithStdin(tc.value, map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + }, "add", tc.secretName) + require.NoError(t, err, "add %s should succeed: %s", tc.secretName, output) // Verify filesystem storage secretDir := filepath.Join(secretsDir, tc.storageName) @@ -971,15 +969,9 @@ func test12SecretNameFormats(t *testing.T, tempDir, secretPath, testMnemonic str } t.Run("invalid_"+testName, func(t *testing.T) { - cmd := exec.Command(secretPath, "add", invalidName) - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - cmd.Stdin = strings.NewReader("test-value") - output, err := cmd.CombinedOutput() + output, err := runSecretWithStdin("test-value", map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + }, "add", invalidName) // Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed) // For now, just check the ones we know should definitely fail @@ -1105,21 +1097,15 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) ( assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist") } -func test15VaultIsolation(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { +func test15VaultIsolation(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { // Make sure we're in default vault _, err := runSecret("vault", "select", "default") require.NoError(t, err, "vault select should succeed") // Add a unique secret to default vault - cmd := exec.Command(secretPath, "add", "default-only/secret", "--force") - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - cmd.Stdin = strings.NewReader("default-vault-secret") - _, err = cmd.CombinedOutput() + _, err = runSecretWithStdin("default-vault-secret", map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + }, "add", "default-only/secret", "--force") require.NoError(t, err, "add secret to default vault should succeed") // Switch to work vault @@ -1134,15 +1120,9 @@ func test15VaultIsolation(t *testing.T, tempDir, secretPath, testMnemonic string assert.Contains(t, output, "not found", "should indicate secret not found") // Add a unique secret to work vault - cmd = exec.Command(secretPath, "add", "work-only/secret", "--force") - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("SB_SECRET_MNEMONIC=%s", testMnemonic), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - cmd.Stdin = strings.NewReader("work-vault-secret") - _, err = cmd.CombinedOutput() + _, err = runSecretWithStdin("work-vault-secret", map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + }, "add", "work-only/secret", "--force") require.NoError(t, err, "add secret to work vault should succeed") // Switch back to default vault @@ -1225,17 +1205,10 @@ func test16GenerateSecret(t *testing.T, tempDir, testMnemonic string, runSecret verifyFileExists(t, versionsDir) } -func test17ImportFromFile(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { +func test17ImportFromFile(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { // Make sure we're in default vault runSecret := func(args ...string) (string, error) { - cmd := exec.Command(secretPath, args...) - cmd.Env = []string{ - fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), - fmt.Sprintf("PATH=%s", os.Getenv("PATH")), - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - output, err := cmd.CombinedOutput() - return string(output), err + return cli.ExecuteCommandInProcess(args, "", nil) } _, err := runSecret("vault", "select", "default") diff --git a/internal/cli/root.go b/internal/cli/root.go index 409bbb0..4dbebb8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ func newRootCmd() *cobra.Command { secret.Debug("Adding subcommands to root command") // Add subcommands - cmd.AddCommand(newInitCmd()) + cmd.AddCommand(NewInitCmd()) cmd.AddCommand(newGenerateCmd()) cmd.AddCommand(newVaultCmd()) cmd.AddCommand(newAddCmd()) diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index 6636333..ab5b0ae 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -42,7 +42,7 @@ func newGetCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { version, _ := cmd.Flags().GetString("version") cli := NewCLIInstance() - return cli.GetSecretWithVersion(args[0], version) + return cli.GetSecretWithVersion(cmd, args[0], version) }, } @@ -66,7 +66,7 @@ func newListCmd() *cobra.Command { } cli := NewCLIInstance() - return cli.ListSecrets(jsonOutput, filter) + return cli.ListSecrets(cmd, jsonOutput, filter) }, } @@ -85,7 +85,7 @@ func newImportCmd() *cobra.Command { force, _ := cmd.Flags().GetBool("force") cli := NewCLIInstance() - return cli.ImportSecret(args[0], sourceFile, force) + return cli.ImportSecret(cmd, args[0], sourceFile, force) }, } @@ -135,15 +135,18 @@ func (cli *CLIInstance) AddSecret(secretName string, force bool) error { } // GetSecret retrieves and prints a secret from the current vault -func (cli *CLIInstance) GetSecret(secretName string) error { - return cli.GetSecretWithVersion(secretName, "") +func (cli *CLIInstance) GetSecret(cmd *cobra.Command, secretName string) error { + return cli.GetSecretWithVersion(cmd, secretName, "") } // GetSecretWithVersion retrieves and prints a specific version of a secret -func (cli *CLIInstance) GetSecretWithVersion(secretName string, version string) error { +func (cli *CLIInstance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error { + secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version) + // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { + secret.Debug("Failed to get current vault", "error", err) return err } @@ -155,16 +158,20 @@ func (cli *CLIInstance) GetSecretWithVersion(secretName string, version string) value, err = vlt.GetSecretVersion(secretName, version) } if err != nil { + secret.Debug("Failed to get secret", "error", err) return err } + secret.Debug("Got secret value", "valueLength", len(value)) + // Print the secret value to stdout - fmt.Print(string(value)) + cmd.Print(string(value)) + secret.Debug("Printed value to cmd") return nil } // ListSecrets lists all secrets in the current vault -func (cli *CLIInstance) ListSecrets(jsonOutput bool, filter string) error { +func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error { // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { @@ -220,27 +227,27 @@ func (cli *CLIInstance) ListSecrets(jsonOutput bool, filter string) error { return fmt.Errorf("failed to marshal JSON: %w", err) } - fmt.Println(string(jsonBytes)) + cmd.Println(string(jsonBytes)) } else { // Pretty table output if len(filteredSecrets) == 0 { if filter != "" { - fmt.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) + cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) } else { - fmt.Println("No secrets found in current vault.") - fmt.Println("Run 'secret add ' to create one.") + cmd.Println("No secrets found in current vault.") + cmd.Println("Run 'secret add ' to create one.") } return nil } // Get current vault name for display if filter != "" { - fmt.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) + cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) } else { - fmt.Printf("Secrets in vault '%s':\n\n", vlt.GetName()) + cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName()) } - fmt.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED") - fmt.Printf("%-40s %-20s\n", "----", "------------") + cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED") + cmd.Printf("%-40s %-20s\n", "----", "------------") for _, secretName := range filteredSecrets { lastUpdated := "unknown" @@ -248,21 +255,21 @@ func (cli *CLIInstance) ListSecrets(jsonOutput bool, filter string) error { metadata := secretObj.GetMetadata() lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04") } - fmt.Printf("%-40s %-20s\n", secretName, lastUpdated) + cmd.Printf("%-40s %-20s\n", secretName, lastUpdated) } - fmt.Printf("\nTotal: %d secret(s)", len(filteredSecrets)) + cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets)) if filter != "" { - fmt.Printf(" (filtered from %d)", len(secrets)) + cmd.Printf(" (filtered from %d)", len(secrets)) } - fmt.Println() + cmd.Println() } return nil } // ImportSecret imports a secret from a file -func (cli *CLIInstance) ImportSecret(secretName, sourceFile string, force bool) error { +func (cli *CLIInstance) ImportSecret(cmd *cobra.Command, secretName, sourceFile string, force bool) error { // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { @@ -280,6 +287,6 @@ func (cli *CLIInstance) ImportSecret(secretName, sourceFile string, force bool) return err } - fmt.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile) + cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile) return nil } diff --git a/internal/cli/test_helpers.go b/internal/cli/test_helpers.go new file mode 100644 index 0000000..ec12269 --- /dev/null +++ b/internal/cli/test_helpers.go @@ -0,0 +1,58 @@ +package cli + +import ( + "bytes" + "os" + "strings" + + "git.eeqj.de/sneak/secret/internal/secret" +) + +// ExecuteCommandInProcess executes a CLI command in-process for testing +func ExecuteCommandInProcess(args []string, stdin string, env map[string]string) (string, error) { + secret.Debug("ExecuteCommandInProcess called", "args", args) + + // Save current environment + savedEnv := make(map[string]string) + for k := range env { + savedEnv[k] = os.Getenv(k) + } + + // Set test environment + for k, v := range env { + os.Setenv(k, v) + } + + // Create root command + rootCmd := newRootCmd() + + // Capture output + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + + // Set stdin if provided + if stdin != "" { + rootCmd.SetIn(strings.NewReader(stdin)) + } + + // Set args + rootCmd.SetArgs(args) + + // Execute command + err := rootCmd.Execute() + + output := buf.String() + secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output) + + // Restore environment + for k, v := range savedEnv { + if v == "" { + os.Unsetenv(k) + } else { + os.Setenv(k, v) + } + } + + return output, err +} diff --git a/internal/cli/test_output_test.go b/internal/cli/test_output_test.go new file mode 100644 index 0000000..2365f6a --- /dev/null +++ b/internal/cli/test_output_test.go @@ -0,0 +1,22 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOutputCapture(t *testing.T) { + // Test vault list command which we fixed + output, err := ExecuteCommandInProcess([]string{"vault", "list"}, "", nil) + require.NoError(t, err) + assert.Contains(t, output, "Available vaults", "should capture vault list output") + t.Logf("vault list output: %q", output) + + // Test help command + output, err = ExecuteCommandInProcess([]string{"--help"}, "", nil) + require.NoError(t, err) + assert.NotEmpty(t, output, "help output should not be empty") + t.Logf("help output length: %d", len(output)) +} diff --git a/internal/cli/vault.go b/internal/cli/vault.go index 65b273f..fa88992 100644 --- a/internal/cli/vault.go +++ b/internal/cli/vault.go @@ -38,7 +38,7 @@ func newVaultListCmd() *cobra.Command { jsonOutput, _ := cmd.Flags().GetBool("json") cli := NewCLIInstance() - return cli.ListVaults(jsonOutput) + return cli.ListVaults(cmd, jsonOutput) }, } @@ -53,7 +53,7 @@ func newVaultCreateCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cli := NewCLIInstance() - return cli.CreateVault(args[0]) + return cli.CreateVault(cmd, args[0]) }, } } @@ -65,7 +65,7 @@ func newVaultSelectCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cli := NewCLIInstance() - return cli.SelectVault(args[0]) + return cli.SelectVault(cmd, args[0]) }, } } @@ -83,13 +83,13 @@ func newVaultImportCmd() *cobra.Command { } cli := NewCLIInstance() - return cli.VaultImport(vaultName) + return cli.VaultImport(cmd, vaultName) }, } } // ListVaults lists all available vaults -func (cli *CLIInstance) ListVaults(jsonOutput bool) error { +func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error { vaults, err := vault.ListVaults(cli.fs, cli.stateDir) if err != nil { return err @@ -111,12 +111,12 @@ func (cli *CLIInstance) ListVaults(jsonOutput bool) error { if err != nil { return err } - fmt.Println(string(jsonBytes)) + cmd.Println(string(jsonBytes)) } else { // Text output - fmt.Println("Available vaults:") + cmd.Println("Available vaults:") if len(vaults) == 0 { - fmt.Println(" (none)") + cmd.Println(" (none)") } else { // Try to get current vault for marking currentVault := "" @@ -126,9 +126,9 @@ func (cli *CLIInstance) ListVaults(jsonOutput bool) error { for _, vaultName := range vaults { if vaultName == currentVault { - fmt.Printf(" %s (current)\n", vaultName) + cmd.Printf(" %s (current)\n", vaultName) } else { - fmt.Printf(" %s\n", vaultName) + cmd.Printf(" %s\n", vaultName) } } } @@ -138,7 +138,7 @@ func (cli *CLIInstance) ListVaults(jsonOutput bool) error { } // CreateVault creates a new vault -func (cli *CLIInstance) CreateVault(name string) error { +func (cli *CLIInstance) CreateVault(cmd *cobra.Command, name string) error { secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir) vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name) @@ -146,22 +146,22 @@ func (cli *CLIInstance) CreateVault(name string) error { return err } - fmt.Printf("Created vault '%s'\n", vlt.GetName()) + cmd.Printf("Created vault '%s'\n", vlt.GetName()) return nil } // SelectVault selects a vault as the current one -func (cli *CLIInstance) SelectVault(name string) error { +func (cli *CLIInstance) SelectVault(cmd *cobra.Command, name string) error { if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil { return err } - fmt.Printf("Selected vault '%s' as current\n", name) + cmd.Printf("Selected vault '%s' as current\n", name) return nil } // VaultImport imports a mnemonic into a specific vault -func (cli *CLIInstance) VaultImport(vaultName string) error { +func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error { secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir) // Get the specific vault by name @@ -269,9 +269,9 @@ func (cli *CLIInstance) VaultImport(vaultName string) error { return fmt.Errorf("failed to create unlocker: %w", err) } - fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) - fmt.Printf("Long-term public key: %s\n", ltPublicKey) - fmt.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID()) + cmd.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) + cmd.Printf("Long-term public key: %s\n", ltPublicKey) + cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID()) return nil } diff --git a/internal/cli/version.go b/internal/cli/version.go index 00f9afb..a249c2c 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -33,7 +33,7 @@ func VersionCommands(cli *CLIInstance) *cobra.Command { Short: "List all versions of a secret", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return cli.ListVersions(args[0]) + return cli.ListVersions(cmd, args[0]) }, } @@ -44,7 +44,7 @@ func VersionCommands(cli *CLIInstance) *cobra.Command { Long: "Updates the current symlink to point to the specified version without modifying timestamps", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return cli.PromoteVersion(args[0], args[1]) + return cli.PromoteVersion(cmd, args[0], args[1]) }, } @@ -53,42 +53,46 @@ func VersionCommands(cli *CLIInstance) *cobra.Command { } // ListVersions lists all versions of a secret -func (cli *CLIInstance) ListVersions(secretName string) error { - secret.Debug("Listing versions for secret", "secret_name", secretName) +func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) error { + secret.Debug("ListVersions called", "secret_name", secretName) // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { - return fmt.Errorf("failed to get current vault: %w", err) + secret.Debug("Failed to get current vault", "error", err) + return err } - // Get vault directory vaultDir, err := vlt.GetDirectory() if err != nil { - return fmt.Errorf("failed to get vault directory: %w", err) + secret.Debug("Failed to get vault directory", "error", err) + return err } - // Convert secret name to storage name - storageName := strings.ReplaceAll(secretName, "/", "%") - secretDir := filepath.Join(vaultDir, "secrets.d", storageName) + // Get the encoded secret name + encodedName := strings.ReplaceAll(secretName, "/", "%") + secretDir := filepath.Join(vaultDir, "secrets.d", encodedName) // Check if secret exists exists, err := afero.DirExists(cli.fs, secretDir) if err != nil { + secret.Debug("Failed to check if secret exists", "error", err) return fmt.Errorf("failed to check if secret exists: %w", err) } if !exists { - return fmt.Errorf("secret %s not found", secretName) + secret.Debug("Secret not found", "secret_name", secretName) + return fmt.Errorf("secret '%s' not found", secretName) } - // Get all versions + // List all versions versions, err := secret.ListVersions(cli.fs, secretDir) if err != nil { + secret.Debug("Failed to list versions", "error", err) return fmt.Errorf("failed to list versions: %w", err) } if len(versions) == 0 { - fmt.Println("No versions found") + cmd.Println("No versions found") return nil } @@ -155,49 +159,44 @@ func (cli *CLIInstance) ListVersions(secretName string) error { } // PromoteVersion promotes a specific version to current -func (cli *CLIInstance) PromoteVersion(secretName string, version string) error { - secret.Debug("Promoting version", "secret_name", secretName, "version", version) - +func (cli *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error { // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { - return fmt.Errorf("failed to get current vault: %w", err) + return err } - // Get vault directory vaultDir, err := vlt.GetDirectory() if err != nil { - return fmt.Errorf("failed to get vault directory: %w", err) + return err } - // Convert secret name to storage name - storageName := strings.ReplaceAll(secretName, "/", "%") - secretDir := filepath.Join(vaultDir, "secrets.d", storageName) - - // Check if secret exists - exists, err := afero.DirExists(cli.fs, secretDir) - if err != nil { - return fmt.Errorf("failed to check if secret exists: %w", err) - } - if !exists { - return fmt.Errorf("secret %s not found", secretName) - } + // Get the encoded secret name + encodedName := strings.ReplaceAll(secretName, "/", "%") + secretDir := filepath.Join(vaultDir, "secrets.d", encodedName) // Check if version exists - versionPath := filepath.Join(secretDir, "versions", version) - exists, err = afero.DirExists(cli.fs, versionPath) + versionDir := filepath.Join(secretDir, "versions", version) + exists, err := afero.DirExists(cli.fs, versionDir) if err != nil { return fmt.Errorf("failed to check if version exists: %w", err) } if !exists { - return fmt.Errorf("version %s not found for secret %s", version, secretName) + return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName) } - // Update current symlink - if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil { - return fmt.Errorf("failed to promote version: %w", err) + // Update the current symlink + currentLink := filepath.Join(secretDir, "current") + + // Remove existing symlink + _ = cli.fs.Remove(currentLink) + + // Create new symlink to the selected version + relativePath := filepath.Join("versions", version) + if err := afero.WriteFile(cli.fs, currentLink, []byte(relativePath), 0644); err != nil { + return fmt.Errorf("failed to update current version: %w", err) } - fmt.Printf("Promoted version %s to current for secret '%s'\n", version, secretName) + cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName) return nil } diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index b29ee47..c84d18c 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -17,8 +17,7 @@ package cli import ( - "io" - "os" + "bytes" "strings" "testing" "time" @@ -78,20 +77,18 @@ func TestListVersionsCommand(t *testing.T) { err = vlt.AddSecret("test/secret", []byte("version-2"), true) require.NoError(t, err) - // Capture output - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + // Create a command for output capture + cmd := newRootCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) // List versions - err = cli.ListVersions("test/secret") + err = cli.ListVersions(cmd, "test/secret") require.NoError(t, err) - // Restore stdout and read output - w.Close() - os.Stdout = oldStdout - output, _ := io.ReadAll(r) - outputStr := string(output) + // Read output + outputStr := buf.String() // Verify output contains version headers assert.Contains(t, outputStr, "VERSION") @@ -122,8 +119,14 @@ func TestListVersionsNonExistentSecret(t *testing.T) { // Set up vault with long-term key setupTestVault(t, fs, stateDir) + // Create a command for output capture + cmd := newRootCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + // Try to list versions of non-existent secret - err := cli.ListVersions("nonexistent/secret") + err := cli.ListVersions(cmd, "nonexistent/secret") assert.Error(t, err) assert.Contains(t, err.Error(), "not found") } @@ -163,19 +166,17 @@ func TestPromoteVersionCommand(t *testing.T) { // Promote first version firstVersion := versions[1] // Older version - // Capture output - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + // Create a command for output capture + cmd := newRootCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) - err = cli.PromoteVersion("test/secret", firstVersion) + err = cli.PromoteVersion(cmd, "test/secret", firstVersion) require.NoError(t, err) - // Restore stdout and read output - w.Close() - os.Stdout = oldStdout - output, _ := io.ReadAll(r) - outputStr := string(output) + // Read output + outputStr := buf.String() // Verify success message assert.Contains(t, outputStr, "Promoted version") @@ -202,8 +203,14 @@ func TestPromoteNonExistentVersion(t *testing.T) { err = vlt.AddSecret("test/secret", []byte("value"), false) require.NoError(t, err) + // Create a command for output capture + cmd := newRootCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + // Try to promote non-existent version - err = cli.PromoteVersion("test/secret", "20991231.999") + err = cli.PromoteVersion(cmd, "test/secret", "20991231.999") assert.Error(t, err) assert.Contains(t, err.Error(), "not found") } @@ -235,33 +242,22 @@ func TestGetSecretWithVersion(t *testing.T) { require.NoError(t, err) require.Len(t, versions, 2) + // Create a command for output capture + cmd := newRootCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + // Test getting current version (empty version string) - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - err = cli.GetSecretWithVersion("test/secret", "") + err = cli.GetSecretWithVersion(cmd, "test/secret", "") require.NoError(t, err) - - w.Close() - os.Stdout = oldStdout - output, _ := io.ReadAll(r) - - assert.Equal(t, "version-2", string(output)) + assert.Equal(t, "version-2", buf.String()) // Test getting specific version - r, w, _ = os.Pipe() - os.Stdout = w - + buf.Reset() firstVersion := versions[1] // Older version - err = cli.GetSecretWithVersion("test/secret", firstVersion) + err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion) require.NoError(t, err) - - w.Close() - os.Stdout = oldStdout - output, _ = io.ReadAll(r) - - assert.Equal(t, "version-1", string(output)) + assert.Equal(t, "version-1", buf.String()) } func TestVersionCommandStructure(t *testing.T) { @@ -296,8 +292,14 @@ func TestListVersionsEmptyOutput(t *testing.T) { err := fs.MkdirAll(secretDir, 0755) require.NoError(t, err) + // Create a command for output capture + cmd := newRootCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + // List versions - should show "No versions found" - err = cli.ListVersions("test/secret") + err = cli.ListVersions(cmd, "test/secret") // Should succeed even with no versions assert.NoError(t, err) diff --git a/internal/secret/keychainunlocker.go b/internal/secret/keychainunlocker.go index 51e636b..8f2e262 100644 --- a/internal/secret/keychainunlocker.go +++ b/internal/secret/keychainunlocker.go @@ -136,9 +136,9 @@ func (k *KeychainUnlocker) GetID() string { // Generate ID using keychain item name keychainItemName, err := k.GetKeychainItemName() if err != nil { - // Fallback to creation time-based ID if we can't read the keychain item name - createdAt := k.Metadata.CreatedAt - return fmt.Sprintf("%s-keychain", createdAt.Format("2006-01-02.15.04")) + // The vault metadata is corrupt - this is a fatal error + // We cannot continue with a fallback ID as that would mask data corruption + panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err)) } return fmt.Sprintf("%s-keychain", keychainItemName) } diff --git a/internal/secret/pgpunlocker.go b/internal/secret/pgpunlocker.go index 488b430..8dc6d03 100644 --- a/internal/secret/pgpunlocker.go +++ b/internal/secret/pgpunlocker.go @@ -111,9 +111,9 @@ func (p *PGPUnlocker) GetID() string { // Generate ID using GPG key ID: -pgp gpgKeyID, err := p.GetGPGKeyID() if err != nil { - // Fallback to creation time-based ID if we can't read the GPG key ID - createdAt := p.Metadata.CreatedAt - return fmt.Sprintf("%s-pgp", createdAt.Format("2006-01-02.15.04")) + // The vault metadata is corrupt - this is a fatal error + // We cannot continue with a fallback ID as that would mask data corruption + panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err)) } return fmt.Sprintf("%s-pgp", gpgKeyID) } diff --git a/internal/vault/unlockers.go b/internal/vault/unlockers.go index 4d8acd7..1224216 100644 --- a/internal/vault/unlockers.go +++ b/internal/vault/unlockers.go @@ -139,20 +139,20 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) { metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) if err != nil { - continue + return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err) } if !exists { - continue + return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name()) } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { - continue + return nil, fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err) } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { - continue + return nil, fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err) } unlockers = append(unlockers, metadata) @@ -185,18 +185,22 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error { // Read metadata file metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) - if err != nil || !exists { + if err != nil { + return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err) + } + if !exists { + // Skip directories without metadata - they might not be unlockers continue } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { - continue + return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err) } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { - continue + return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err) } unlockerDirPath = filepath.Join(unlockersDir, file.Name()) @@ -255,18 +259,22 @@ func (v *Vault) SelectUnlocker(unlockerID string) error { // Read metadata file metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) - if err != nil || !exists { + if err != nil { + return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err) + } + if !exists { + // Skip directories without metadata - they might not be unlockers continue } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { - continue + return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err) } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { - continue + return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err) } unlockerDirPath := filepath.Join(unlockersDir, file.Name()) @@ -303,9 +311,11 @@ func (v *Vault) SelectUnlocker(unlockerID string) error { currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") // Remove existing symlink if it exists - if exists, _ := afero.Exists(v.fs, currentUnlockerPath); exists { + if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil { + return fmt.Errorf("failed to check if current unlocker symlink exists: %w", err) + } else if exists { if err := v.fs.Remove(currentUnlockerPath); err != nil { - secret.Debug("Failed to remove existing unlocker symlink", "error", err, "path", currentUnlockerPath) + return fmt.Errorf("failed to remove existing unlocker symlink: %w", err) } } diff --git a/pkg/agehd/agehd_test.go b/pkg/agehd/agehd_test.go index 77ffe72..dc63181 100644 --- a/pkg/agehd/agehd_test.go +++ b/pkg/agehd/agehd_test.go @@ -133,7 +133,11 @@ func TestDeterministicDerivation(t *testing.T) { } if id1.String() != id2.String() { - t.Fatalf("identities should be deterministic: %s != %s", id1.String(), id2.String()) + t.Fatalf( + "identities should be deterministic: %s != %s", + id1.String(), + id2.String(), + ) } // Test that different indices produce different identities @@ -163,7 +167,11 @@ func TestDeterministicXPRVDerivation(t *testing.T) { } if id1.String() != id2.String() { - t.Fatalf("xprv identities should be deterministic: %s != %s", id1.String(), id2.String()) + t.Fatalf( + "xprv identities should be deterministic: %s != %s", + id1.String(), + id2.String(), + ) } // Test that different indices with same xprv produce different identities @@ -181,11 +189,8 @@ func TestDeterministicXPRVDerivation(t *testing.T) { } func TestMnemonicVsXPRVConsistency(t *testing.T) { - // Test that deriving from mnemonic and from the corresponding xprv produces the same result - // Note: The test mnemonic and test xprv are from different sources - // and are not expected to produce the same results, so this test merely - // verifies that both derivation methods work without errors. - t.Log("Testing mnemonic vs XPRV derivation - note: test data is from different sources") + // FIXME This test is missing! + } func TestEntropyLength(t *testing.T) { @@ -208,7 +213,10 @@ func TestEntropyLength(t *testing.T) { } if len(entropyXPRV) != 32 { - t.Fatalf("expected 32 bytes of entropy from xprv, got %d", len(entropyXPRV)) + t.Fatalf( + "expected 32 bytes of entropy from xprv, got %d", + len(entropyXPRV), + ) } t.Logf("XPRV Entropy (32 bytes): %x", entropyXPRV) @@ -264,14 +272,49 @@ func TestClampFunction(t *testing.T) { expected []byte }{ { - name: "all zeros", - input: make([]byte, 32), - expected: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64}, + name: "all zeros", + input: make([]byte, 32), + expected: []byte{ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 64, + }, }, { - name: "all ones", - input: bytes.Repeat([]byte{255}, 32), - expected: append([]byte{248}, append(bytes.Repeat([]byte{255}, 30), 127)...), + name: "all ones", + input: bytes.Repeat([]byte{255}, 32), + expected: append( + []byte{248}, + append(bytes.Repeat([]byte{255}, 30), 127)...), }, } @@ -283,13 +326,22 @@ func TestClampFunction(t *testing.T) { // Check specific bits that should be clamped if input[0]&7 != 0 { - t.Errorf("first byte should have bottom 3 bits cleared, got %08b", input[0]) + t.Errorf( + "first byte should have bottom 3 bits cleared, got %08b", + input[0], + ) } if input[31]&128 != 0 { - t.Errorf("last byte should have top bit cleared, got %08b", input[31]) + t.Errorf( + "last byte should have top bit cleared, got %08b", + input[31], + ) } if input[31]&64 == 0 { - t.Errorf("last byte should have second-to-top bit set, got %08b", input[31]) + t.Errorf( + "last byte should have second-to-top bit set, got %08b", + input[31], + ) } }) } @@ -337,7 +389,9 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) { entropy: func() []byte { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { - panic(err) // In test context, panic is acceptable for setup failures + panic( + err, + ) // In test context, panic is acceptable for setup failures } return b }(), @@ -356,7 +410,10 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) { t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error()) } if identity != nil { - t.Errorf("expected nil identity on error, got %v", identity) + t.Errorf( + "expected nil identity on error, got %v", + identity, + ) } } else { if err != nil { @@ -531,7 +588,11 @@ func TestIndexBoundaries(t *testing.T) { t.Run(fmt.Sprintf("index_%d", index), func(t *testing.T) { identity, err := DeriveIdentity(mnemonic, index) if err != nil { - t.Fatalf("failed to derive identity at index %d: %v", index, err) + t.Fatalf( + "failed to derive identity at index %d: %v", + index, + err, + ) } // Verify the identity is valid by testing encryption/decryption @@ -628,11 +689,19 @@ func TestConcurrentDerivation(t *testing.T) { expectedResults := testNumGoroutines for result, count := range resultMap { if count != expectedResults { - t.Errorf("result %s appeared %d times, expected %d", result, count, expectedResults) + t.Errorf( + "result %s appeared %d times, expected %d", + result, + count, + expectedResults, + ) } } - t.Logf("Concurrent derivation test passed with %d unique results", len(resultMap)) + t.Logf( + "Concurrent derivation test passed with %d unique results", + len(resultMap), + ) } // Benchmark tests @@ -712,16 +781,28 @@ func BenchmarkEncryptDecrypt(b *testing.B) { // TestConstants verifies the hardcoded constants func TestConstants(t *testing.T) { if purpose != 83696968 { - t.Errorf("purpose constant mismatch: expected 83696968, got %d", purpose) + t.Errorf( + "purpose constant mismatch: expected 83696968, got %d", + purpose, + ) } if vendorID != 592366788 { - t.Errorf("vendorID constant mismatch: expected 592366788, got %d", vendorID) + t.Errorf( + "vendorID constant mismatch: expected 592366788, got %d", + vendorID, + ) } if appID != 733482323 { - t.Errorf("appID constant mismatch: expected 733482323, got %d", appID) + t.Errorf( + "appID constant mismatch: expected 733482323, got %d", + appID, + ) } if hrp != "age-secret-key-" { - t.Errorf("hrp constant mismatch: expected 'age-secret-key-', got %q", hrp) + t.Errorf( + "hrp constant mismatch: expected 'age-secret-key-', got %q", + hrp, + ) } } @@ -737,7 +818,10 @@ func TestIdentityStringFormat(t *testing.T) { // Check secret key format if !strings.HasPrefix(secretKey, "AGE-SECRET-KEY-") { - t.Errorf("secret key should start with 'AGE-SECRET-KEY-', got: %s", secretKey) + t.Errorf( + "secret key should start with 'AGE-SECRET-KEY-', got: %s", + secretKey, + ) } // Check recipient format @@ -834,14 +918,22 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) { privateKey1 := identity1.String() privateKey2 := identity2.String() if privateKey1 != privateKey2 { - t.Fatalf("private keys should be identical:\nFirst: %s\nSecond: %s", privateKey1, privateKey2) + t.Fatalf( + "private keys should be identical:\nFirst: %s\nSecond: %s", + privateKey1, + privateKey2, + ) } // Verify that both public keys (recipients) are identical publicKey1 := identity1.Recipient().String() publicKey2 := identity2.Recipient().String() if publicKey1 != publicKey2 { - t.Fatalf("public keys should be identical:\nFirst: %s\nSecond: %s", publicKey1, publicKey2) + t.Fatalf( + "public keys should be identical:\nFirst: %s\nSecond: %s", + publicKey1, + publicKey2, + ) } t.Logf("✓ Deterministic generation verified") @@ -873,10 +965,17 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) { t.Fatalf("failed to close encryptor: %v", err) } - t.Logf("✓ Encrypted %d bytes into %d bytes of ciphertext", len(testData), ciphertext.Len()) + t.Logf( + "✓ Encrypted %d bytes into %d bytes of ciphertext", + len(testData), + ciphertext.Len(), + ) // Decrypt the data using the private key - decryptor, err := age.Decrypt(bytes.NewReader(ciphertext.Bytes()), identity1) + decryptor, err := age.Decrypt( + bytes.NewReader(ciphertext.Bytes()), + identity1, + ) if err != nil { t.Fatalf("failed to create decryptor: %v", err) } @@ -890,7 +989,11 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) { // Verify that the decrypted data matches the original if len(decryptedData) != len(testData) { - t.Fatalf("decrypted data length mismatch: expected %d, got %d", len(testData), len(decryptedData)) + t.Fatalf( + "decrypted data length mismatch: expected %d, got %d", + len(testData), + len(decryptedData), + ) } if !bytes.Equal(testData, decryptedData) { @@ -917,7 +1020,10 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) { } // Decrypt with the second identity - decryptor2, err := age.Decrypt(bytes.NewReader(ciphertext2.Bytes()), identity2) + decryptor2, err := age.Decrypt( + bytes.NewReader(ciphertext2.Bytes()), + identity2, + ) if err != nil { t.Fatalf("failed to create second decryptor: %v", err) } diff --git a/test_secret_manager.sh b/test_secret_manager.sh new file mode 100755 index 0000000..bcf4428 --- /dev/null +++ b/test_secret_manager.sh @@ -0,0 +1,688 @@ +#!/bin/bash + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Test configuration +TEST_MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +TEST_PASSPHRASE="test-passphrase-123" +TEMP_DIR="$(mktemp -d)" +SECRET_BINARY="./secret" + +# Enable debug output from the secret program +export GODEBUG="berlin.sneak.pkg.secret" + +echo -e "${BLUE}=== Secret Manager Comprehensive Test Script ===${NC}" +echo -e "${YELLOW}Using temporary directory: $TEMP_DIR${NC}" +echo -e "${YELLOW}Debug output enabled: GODEBUG=$GODEBUG${NC}" +echo -e "${YELLOW}Note: All tests use environment variables (no manual input)${NC}" + +# Function to print test steps +print_step() { + echo -e "\n${BLUE}Step $1: $2${NC}" +} + +# Function to print success +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +# Function to print error and exit +print_error() { + echo -e "${RED}✗ $1${NC}" + exit 1 +} + +# Function to print warning (for expected failures) +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +# Function to clear state directory and reset environment +reset_state() { + echo -e "${YELLOW}Resetting state directory...${NC}" + + # Safety checks before removing anything + if [ -z "$TEMP_DIR" ]; then + print_error "TEMP_DIR is not set, cannot reset state safely" + fi + + if [ ! -d "$TEMP_DIR" ]; then + print_error "TEMP_DIR ($TEMP_DIR) is not a directory, cannot reset state safely" + fi + + # Additional safety: ensure TEMP_DIR looks like a temp directory + case "$TEMP_DIR" in + /tmp/* | /var/folders/* | */tmp/*) + # Looks like a reasonable temp directory path + ;; + *) + print_error "TEMP_DIR ($TEMP_DIR) does not look like a safe temporary directory path" + ;; + esac + + # Now it's safe to remove contents - use find to avoid glob expansion issues + find "${TEMP_DIR:?}" -mindepth 1 -delete 2>/dev/null || true + unset SB_SECRET_MNEMONIC + unset SB_UNLOCK_PASSPHRASE + export SB_SECRET_STATE_DIR="$TEMP_DIR" +} + +# Cleanup function +cleanup() { + echo -e "\n${YELLOW}Cleaning up...${NC}" + rm -rf "$TEMP_DIR" + unset SB_SECRET_STATE_DIR + unset SB_SECRET_MNEMONIC + unset SB_UNLOCK_PASSPHRASE + unset GODEBUG + echo -e "${GREEN}Cleanup complete${NC}" +} + +# Set cleanup trap +trap cleanup EXIT + +# Check that the secret binary exists +if [ ! -f "$SECRET_BINARY" ]; then + print_error "Secret binary not found at $SECRET_BINARY. Please run 'make build' first." +fi + +# Test 1: Set up environment variables +print_step "1" "Setting up environment variables" +export SB_SECRET_STATE_DIR="$TEMP_DIR" +export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" +print_success "Environment variables set" +echo " SB_SECRET_STATE_DIR=$SB_SECRET_STATE_DIR" +echo " SB_SECRET_MNEMONIC=$TEST_MNEMONIC" + +# Test 2: Initialize the secret manager (should create default vault) +print_step "2" "Initializing secret manager (creates default vault)" +export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" +echo " SB_UNLOCK_PASSPHRASE=$SB_UNLOCK_PASSPHRASE" + +# Verify environment variables are exported and visible to subprocesses +echo "Verifying environment variables are exported:" +env | grep -E "^SB_" || true + +echo "Running: $SECRET_BINARY init" +# Run with explicit environment to ensure variables are passed +if SB_SECRET_STATE_DIR="$SB_SECRET_STATE_DIR" \ + SB_SECRET_MNEMONIC="$SB_SECRET_MNEMONIC" \ + SB_UNLOCK_PASSPHRASE="$SB_UNLOCK_PASSPHRASE" \ + GODEBUG="$GODEBUG" \ + $SECRET_BINARY init /dev/null) +if [ "$RETRIEVED_SECRET1" = "my-super-secret-password" ]; then + print_success "Retrieved and verified secret: database/password" +else + print_error "Failed to retrieve or verify secret: database/password" +fi + +# Retrieve and verify secret 2 +RETRIEVED_SECRET2=$($SECRET_BINARY get "api/key" 2>/dev/null) +if [ "$RETRIEVED_SECRET2" = "api-key-12345" ]; then + print_success "Retrieved and verified secret: api/key" +else + print_error "Failed to retrieve or verify secret: api/key" +fi + +# Retrieve and verify secret 3 +RETRIEVED_SECRET3=$($SECRET_BINARY get "ssh/private-key" 2>/dev/null) +if [ "$RETRIEVED_SECRET3" = "ssh-private-key-content" ]; then + print_success "Retrieved and verified secret: ssh/private-key" +else + print_error "Failed to retrieve or verify secret: ssh/private-key" +fi + +# List all secrets +echo "Listing all secrets..." +echo "Running: $SECRET_BINARY list" +if $SECRET_BINARY list; then + SECRETS=$($SECRET_BINARY list) + echo "Secrets in current vault:" + echo "$SECRETS" | while read -r secret; do + echo " - $secret" + done + print_success "Listed all secrets" +else + print_error "Failed to list secrets" +fi + +# Test 7: Secret management without mnemonic (traditional unlocker approach) +print_step "7" "Testing traditional unlocker approach" + +# Create a new vault without mnemonic +echo "Running: $SECRET_BINARY vault create traditional" +$SECRET_BINARY vault create traditional + +# Add a secret using traditional unlocker approach +echo "Adding secret using traditional unlocker..." +echo "Running: echo 'traditional-secret' | $SECRET_BINARY add traditional/secret" +if echo "traditional-secret" | $SECRET_BINARY add traditional/secret; then + print_success "Added secret with traditional approach" +else + print_error "Failed to add secret with traditional approach" +fi + +# Retrieve secret using traditional unlocker approach +echo "Retrieving secret using traditional unlocker approach..." +echo "Running: $SECRET_BINARY get traditional/secret" +if RETRIEVED=$($SECRET_BINARY get traditional/secret 2>&1); then + print_success "Retrieved: $RETRIEVED" +else + print_error "Failed to retrieve secret with traditional approach" +fi + +# Test 8: Advanced unlocker management +print_step "8" "Testing advanced unlocker management" + +if [ "$PLATFORM" = "darwin" ]; then + # macOS only: Test Secure Enclave + echo "Testing Secure Enclave unlocker creation..." + if $SECRET_BINARY unlockers add sep; then + print_success "Created Secure Enclave unlocker" + else + print_warning "Secure Enclave unlocker creation not yet implemented" + fi +fi + +# Get current unlocker ID for testing +echo "Getting current unlocker for testing..." +echo "Running: $SECRET_BINARY unlockers list" +if $SECRET_BINARY unlockers list; then + CURRENT_UNLOCKER_ID=$($SECRET_BINARY unlockers list | head -n1 | awk '{print $1}') + if [ -n "$CURRENT_UNLOCKER_ID" ]; then + print_success "Found unlocker ID: $CURRENT_UNLOCKER_ID" + + # Test unlocker selection + echo "Testing unlocker selection..." + echo "Running: $SECRET_BINARY unlocker select $CURRENT_UNLOCKER_ID" + if $SECRET_BINARY unlocker select "$CURRENT_UNLOCKER_ID"; then + print_success "Selected unlocker: $CURRENT_UNLOCKER_ID" + else + print_warning "Unlocker selection not yet implemented" + fi + fi +fi + +# Test 9: Secret name validation and edge cases +print_step "9" "Testing secret name validation and edge cases" + +# Test valid names +VALID_NAMES=("valid-name" "valid.name" "valid_name" "valid/path/name" "123valid" "a" "very-long-name-with-many-parts/and/paths") +for name in "${VALID_NAMES[@]}"; do + echo "Running: echo \"test-value\" | $SECRET_BINARY add $name --force" + if echo "test-value" | $SECRET_BINARY add "$name" --force; then + print_success "Valid name accepted: $name" + else + print_error "Valid name rejected: $name" + fi +done + +# Test invalid names (these should fail) +echo "Testing invalid names (should fail)..." +INVALID_NAMES=("Invalid-Name" "invalid name" "invalid@name" "invalid#name" "invalid%name" "") +for name in "${INVALID_NAMES[@]}"; do + echo "Running: echo \"test-value\" | $SECRET_BINARY add $name" + if echo "test-value" | $SECRET_BINARY add "$name"; then + print_error "Invalid name accepted (should have been rejected): '$name'" + else + print_success "Invalid name correctly rejected: '$name'" + fi +done + +# Test 10: Overwrite protection and force flag +print_step "10" "Testing overwrite protection and force flag" + +# Try to add existing secret without --force (should fail) +echo "Running: echo \"new-value\" | $SECRET_BINARY add \"database/password\"" +if echo "new-value" | $SECRET_BINARY add "database/password"; then + print_error "Overwrite protection failed - secret was overwritten without --force" +else + print_success "Overwrite protection working - secret not overwritten without --force" +fi + +# Try to add existing secret with --force (should succeed) +echo "Running: echo \"new-password-value\" | $SECRET_BINARY add \"database/password\" --force" +if echo "new-password-value" | $SECRET_BINARY add "database/password" --force; then + print_success "Force overwrite working - secret overwritten with --force" + + # Verify the new value + RETRIEVED_NEW=$($SECRET_BINARY get "database/password" 2>/dev/null) + if [ "$RETRIEVED_NEW" = "new-password-value" ]; then + print_success "Overwritten secret has correct new value" + else + print_error "Overwritten secret has incorrect value" + fi +else + print_error "Force overwrite failed - secret not overwritten with --force" +fi + +# Test 11: Cross-vault operations +print_step "11" "Testing cross-vault operations" + +# First create and import mnemonic into work vault since it was destroyed by reset_state +echo "Creating work vault for cross-vault testing..." +echo "Running: $SECRET_BINARY vault create work" +if $SECRET_BINARY vault create work; then + print_success "Created work vault for cross-vault testing" +else + print_error "Failed to create work vault for cross-vault testing" +fi + +# Import mnemonic into work vault so it can store secrets +echo "Importing mnemonic into work vault..." +export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" +echo "Running: $SECRET_BINARY vault import work" +if $SECRET_BINARY vault import work; then + print_success "Imported mnemonic into work vault" +else + print_error "Failed to import mnemonic into work vault" +fi +unset SB_UNLOCK_PASSPHRASE + +# Switch to work vault and add secrets there +echo "Switching to 'work' vault for cross-vault testing..." +echo "Running: $SECRET_BINARY vault select work" +if $SECRET_BINARY vault select work; then + print_success "Switched to 'work' vault" + + # Add work-specific secrets + echo "Running: echo \"work-database-password\" | $SECRET_BINARY add \"work/database\"" + if echo "work-database-password" | $SECRET_BINARY add "work/database"; then + print_success "Added work-specific secret" + else + print_error "Failed to add work-specific secret" + fi + + # List secrets in work vault + echo "Running: $SECRET_BINARY list" + if $SECRET_BINARY list; then + WORK_SECRETS=$($SECRET_BINARY list) + echo "Secrets in work vault: $WORK_SECRETS" + print_success "Listed work vault secrets" + else + print_error "Failed to list work vault secrets" + fi +else + print_error "Failed to switch to 'work' vault" +fi + +# Switch back to default vault +echo "Switching back to 'default' vault..." +echo "Running: $SECRET_BINARY vault select default" +if $SECRET_BINARY vault select default; then + print_success "Switched back to 'default' vault" + + # Verify default vault secrets are still there + echo "Running: $SECRET_BINARY get \"database/password\"" + if $SECRET_BINARY get "database/password"; then + print_success "Default vault secrets still accessible" + else + print_error "Default vault secrets not accessible" + fi +else + print_error "Failed to switch back to 'default' vault" +fi + +# Test 12: File structure verification +print_step "12" "Verifying file structure" + +echo "Checking file structure in $TEMP_DIR..." +if [ -d "$TEMP_DIR/vaults.d/default/secrets.d" ]; then + print_success "Default vault structure exists" + + # Check a specific secret's file structure + SECRET_DIR="$TEMP_DIR/vaults.d/default/secrets.d/database%password" + if [ -d "$SECRET_DIR" ]; then + print_success "Secret directory exists: database%password" + + # Check required files for per-secret key architecture + FILES=("value.age" "pub.age" "priv.age" "secret-metadata.json") + for file in "${FILES[@]}"; do + if [ -f "$SECRET_DIR/$file" ]; then + print_success "Required file exists: $file" + else + print_error "Required file missing: $file" + fi + done + else + print_error "Secret directory not found" + fi +else + print_error "Default vault structure not found" +fi + +# Check work vault structure +if [ -d "$TEMP_DIR/vaults.d/work" ]; then + print_success "Work vault structure exists" +else + print_error "Work vault structure not found" +fi + +# Check configuration files +if [ -f "$TEMP_DIR/configuration.json" ]; then + print_success "Global configuration file exists" +else + print_warning "Global configuration file not found (may not be implemented yet)" +fi + +# Check current vault symlink +if [ -L "$TEMP_DIR/currentvault" ] || [ -f "$TEMP_DIR/currentvault" ]; then + print_success "Current vault link exists" +else + print_error "Current vault link not found" +fi + +# Test 13: Environment variable error handling +print_step "13" "Testing environment variable error handling" + +# Test with non-existent state directory +export SB_SECRET_STATE_DIR="$TEMP_DIR/nonexistent/directory" +echo "Running: $SECRET_BINARY get \"database/password\"" +if $SECRET_BINARY get "database/password"; then + print_error "Should have failed with non-existent state directory" +else + print_success "Correctly failed with non-existent state directory" +fi + +# Test init with non-existent directory (should work) +echo "Running: $SECRET_BINARY init (with SB_UNLOCK_PASSPHRASE set)" +export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" +if $SECRET_BINARY init; then + print_success "Init works with non-existent state directory" +else + print_error "Init should work with non-existent state directory" +fi +unset SB_UNLOCK_PASSPHRASE + +# Reset to working directory +export SB_SECRET_STATE_DIR="$TEMP_DIR" + +# Test 14: Mixed approach compatibility +print_step "14" "Testing mixed approach compatibility" + +# Verify mnemonic can access traditional secrets +RETRIEVED_MIXED=$($SECRET_BINARY get "traditional/secret" 2>/dev/null) +if [ "$RETRIEVED_MIXED" = "traditional-secret-value" ]; then + print_success "Mnemonic can access traditional secrets" +else + print_error "Mnemonic cannot access traditional secrets" +fi + +# Test without mnemonic but with unlocker +echo "Testing mnemonic-created vault access..." +echo "Testing traditional unlocker access to mnemonic-created secrets..." +echo "Running: $SECRET_BINARY get test/seed (with mnemonic set)" +if RETRIEVED=$($SECRET_BINARY get test/seed 2>&1); then + print_success "Traditional unlocker can access mnemonic-created secrets" +else + print_warning "Traditional unlocker cannot access mnemonic-created secrets (may need implementation)" +fi + +# Re-enable mnemonic for final tests +export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" + +# Final summary +echo -e "\n${GREEN}=== Test Summary ===${NC}" +echo -e "${GREEN}✓ Environment variable support (SB_SECRET_STATE_DIR, SB_SECRET_MNEMONIC)${NC}" +echo -e "${GREEN}✓ Secret manager initialization${NC}" +echo -e "${GREEN}✓ Vault management (create, list, select)${NC}" +echo -e "${GREEN}✓ Import functionality with environment variable combinations${NC}" +echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${NC}" +echo -e "${GREEN}✓ Unlocker management (passphrase, PGP, SEP)${NC}" +echo -e "${GREEN}✓ Secret generation and storage${NC}" +echo -e "${GREEN}✓ Traditional unlocker operations${NC}" +echo -e "${GREEN}✓ Secret name validation${NC}" +echo -e "${GREEN}✓ Overwrite protection and force flag${NC}" +echo -e "${GREEN}✓ Cross-vault operations${NC}" +echo -e "${GREEN}✓ Per-secret key file structure${NC}" +echo -e "${GREEN}✓ Mixed approach compatibility${NC}" +echo -e "${GREEN}✓ Error handling${NC}" + +echo -e "\n${GREEN}🎉 Comprehensive test completed with environment variable automation!${NC}" + +# Show usage examples for all implemented functionality +echo -e "\n${BLUE}=== Complete Usage Examples ===${NC}" +echo -e "${YELLOW}# Environment setup:${NC}" +echo "export SB_SECRET_STATE_DIR=\"/path/to/your/secrets\"" +echo "export SB_SECRET_MNEMONIC=\"your twelve word mnemonic phrase here\"" +echo "" +echo -e "${YELLOW}# Initialization:${NC}" +echo "secret init" +echo "" +echo -e "${YELLOW}# Vault management:${NC}" +echo "secret vault list" +echo "secret vault create work" +echo "secret vault select work" +echo "" +echo -e "${YELLOW}# Import mnemonic (automated with environment variables):${NC}" +echo "export SB_SECRET_MNEMONIC=\"abandon abandon...\"" +echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\"" +echo "secret vault import work" +echo "" +echo -e "${YELLOW}# Unlocker management:${NC}" +echo "$SECRET_BINARY unlockers add # Add unlocker (passphrase, pgp, keychain)" +echo "$SECRET_BINARY unlockers add passphrase" +echo "$SECRET_BINARY unlockers add pgp " +echo "$SECRET_BINARY unlockers add keychain # macOS only" +echo "$SECRET_BINARY unlockers list # List all unlockers" +echo "$SECRET_BINARY unlocker select # Select current unlocker" +echo "$SECRET_BINARY unlockers rm # Remove unlocker" +echo "" +echo -e "${YELLOW}# Secret management:${NC}" +echo "echo \"my-secret\" | secret add \"app/password\"" +echo "echo \"my-secret\" | secret add \"app/password\" --force" +echo "secret get \"app/password\"" +echo "secret list" +echo "" +echo -e "${YELLOW}# Cross-vault operations:${NC}" +echo "secret vault select work" +echo "echo \"work-secret\" | secret add \"work/database\"" +echo "secret vault select default" \ No newline at end of file