check / check (push) Failing after 3s
internal/cli drops its copies of vault.ErrSecretNotFound, ErrVaultNotFound, ErrVersionNotFound and ErrSecretExists and of the secret package's keychain and Secure Enclave errors, and its second error for an unknown unlocker type, an invalid mnemonic, a length below 1, an unsupported secret type and an oversized secret. vault.ErrNilValueBuffer becomes secret.ErrNilValueBuffer. Every error of secret.ReadPassphrase wraps ErrPassphraseNotRead. ResolveGPGKeyFingerprint returns ErrGPGKeyNotFound for a key the keyring lacks. storeInKeychain returns errNilDataBuffer. bip85's ErrPasswordTooShort and ErrEncodedTooShort go with their unreachable checks, as does the macOS check in macOS-only code. Tests that matched these errors' text use errors.Is. Model: opus-5-5
415 lines
11 KiB
Go
415 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), nil)
|
|
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.ErrorIs(t, err, vault.ErrSecretNotFound)
|
|
}
|
|
|
|
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.ErrorIs(t, err, vault.ErrVersionNotFound)
|
|
}
|
|
|
|
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)
|
|
}
|