Files
secret/internal/cli/version_test.go
T
clawbot 682bf751e7
check / check (push) Failing after 2s
Read secret environment variables once per command, then unset them (closes #60)
init and vault create put the mnemonic into the process environment for
vault.CreateVault to read back, so every program they ran, gpg included,
inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read
at 13 places and never unset. Each command that may need them now reads
both once, in its RunE, into locked buffers on the CLI Instance, and
unsets them at once. The buffers are passed down: vault.CreateVault
takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and
the PGP, keychain and Secure Enclave unlocker constructors take both.
Nothing below the command reads the environment. README warns against
both variables.

Model: opus-5-5
2026-10-04 11:30:08 +00:00

416 lines
11 KiB
Go

// Version CLI Command Tests
//
// Tests for version-related CLI commands:
//
// - TestListVersionsCommand: Tests `secret version list` command output
// - TestListVersionsNonExistentSecret: Tests error handling for missing secrets
// - TestPromoteVersionCommand: Tests `secret version promote` command
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
// - TestVersionCommandStructure: Tests command structure and help text
// - TestListVersionsEmptyOutput: Tests edge case with no versions
//
// Test Utilities:
// - setupTestVault(): CLI test helper for vault initialization
// - Uses consistent test mnemonic for reproducible testing
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"bytes"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf8"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// testMnemonic is the standard BIP39 mnemonic used for CLI tests.
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// testStateDir is the in-memory state directory used by CLI tests.
testStateDir = "/test/state"
)
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// Helper function to add a version of the "test/secret" secret to the
// vault with proper buffer protection
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
t.Helper()
buffer := memguard.NewBufferFromBytes(value)
defer buffer.Destroy()
err := vlt.AddSecret("test/secret", buffer, force)
require.NoError(t, err)
}
// Helper function to set up a vault with long-term key in testStateDir
func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper()
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err)
// Derive and store long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
// Store long-term public key in vault
vaultDir, _ := vlt.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
require.NoError(t, err)
// Select vault
err = vault.SelectVault(fs, testStateDir, "default")
require.NoError(t, err)
}
func TestListVersionsCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
addTestSecret(t, vlt, []byte("version-2"), true)
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
// List versions
err = cli.ListVersions(cmd, "test/secret")
require.NoError(t, err)
// Read output
outputStr := buf.String()
// Verify output contains version headers
assert.Contains(t, outputStr, "VERSION")
assert.Contains(t, outputStr, "CREATED")
assert.Contains(t, outputStr, "STATUS")
assert.Contains(t, outputStr, "NOT_BEFORE")
assert.Contains(t, outputStr, "NOT_AFTER")
// Should have current status for latest version
assert.Contains(t, outputStr, "current")
// Should have two version entries
lines := strings.Split(outputStr, "\n")
versionLines := 0
for _, line := range lines {
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
versionLines++
}
}
assert.Equal(t, 2, versionLines)
}
func TestListVersionsNonExistentSecret(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// 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(cmd, "nonexistent/secret")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestPromoteVersionCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
addTestSecret(t, vlt, []byte("version-2"), true)
// Get versions
vaultDir, _ := vlt.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Current should be version-2
value, err := vlt.GetSecret("test/secret")
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, []byte("version-2"), value.Bytes())
// Promote first version
firstVersion := versions[1] // Older version
// Create a command for output capture
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
err = cli.PromoteVersion(cmd, "test/secret", firstVersion)
require.NoError(t, err)
// Read output
outputStr := buf.String()
// Verify success message
assert.Contains(t, outputStr, "Promoted version")
assert.Contains(t, outputStr, firstVersion)
// Verify current is now version-1
promoted, err := vlt.GetSecret("test/secret")
require.NoError(t, err)
defer promoted.Destroy()
assert.Equal(t, []byte("version-1"), promoted.Bytes())
}
func TestPromoteNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("value"), false)
// 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(cmd, "test/secret", "20991231.999")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestGetSecretWithVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
addTestSecret(t, vlt, []byte("version-2"), true)
// Get versions
vaultDir, _ := vlt.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
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 the current version
err = cli.GetSecret(cmd, "test/secret")
require.NoError(t, err)
assert.Equal(t, "version-2", buf.String())
// Test getting specific version
buf.Reset()
firstVersion := versions[1] // Older version
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
require.NoError(t, err)
assert.Equal(t, "version-1", buf.String())
}
func TestGetSecretWritesBinaryValue(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
setupTestVault(t, fs)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
value := []byte{0x00, 'a', 0xff, 0xfe, 0x00, 0xc3, 0x28, 'z', 0x00}
require.False(t, utf8.Valid(value))
// A copy, since storing a value wipes the slice it came from
addTestSecret(t, vlt, bytes.Clone(value), false)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 1)
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
// Each writes exactly the stored bytes, with no trailing newline
err = cli.GetSecret(cmd, "test/secret")
require.NoError(t, err)
assert.Equal(t, value, buf.Bytes())
buf.Reset()
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
require.NoError(t, err)
assert.Equal(t, value, buf.Bytes())
}
//nolint:paralleltest // reads process environment to determine the state dir
func TestVersionCommandStructure(t *testing.T) {
// Test that version commands are properly structured
cli, err := NewCLIInstance()
if err != nil {
t.Fatalf("failed to initialize CLI: %v", err)
}
cmd := VersionCommands(cli)
assert.Equal(t, "version", cmd.Use)
assert.Equal(t, "Manage secret versions", cmd.Short)
// Check subcommands
listCmd := cmd.Commands()[0]
assert.Equal(t, "list <secret-name>", listCmd.Use)
assert.Equal(t, "List all versions of a secret", listCmd.Short)
promoteCmd := cmd.Commands()[1]
assert.Equal(t, "promote <secret-name> <version>", promoteCmd.Use)
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
}
func TestListVersionsEmptyOutput(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs)
// Create a secret directory without versions (edge case)
vaultDir := stateDir + "/vaults.d/default"
secretDir := vaultDir + "/secrets.d/test%secret"
err := fs.MkdirAll(secretDir, 0o755)
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(cmd, "test/secret")
// Should succeed even with no versions
assert.NoError(t, err)
}