Files
secret/internal/cli/version_test.go
clawbot bdb1c7ec18
check / check (push) Waiting to run
Accept a version argument only if the secret has that version (closes #67)
version rm, version promote and get --version joined the version
argument into a path unchecked, so "", ".", "..", "../../.." removed or
read every version, the secret, the vault or directories above it.

A version is now accepted only if it is one of the versions
ListVersions lists for the secret, compared by name before any path is
built (secret.VersionExists, used by all three). An empty --version is
rejected instead of meaning the current version: GetSecretVersion no
longer treats "" as current, and GetSecret looks the current version
up itself.

Model: opus-5-5
2026-10-04 03:25:00 +02:00

344 lines
9.3 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
// - 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"
"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"
)
// 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()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default")
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)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsCommand(t *testing.T) {
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)
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)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsNonExistentSecret(t *testing.T) {
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")
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteVersionCommand(t *testing.T) {
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)
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)
assert.Equal(t, []byte("version-2"), value)
// 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
value, err = vlt.GetSecret("test/secret")
require.NoError(t, err)
assert.Equal(t, []byte("version-1"), value)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteNonExistentVersion(t *testing.T) {
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")
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWithVersion(t *testing.T) {
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)
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())
}
//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)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsEmptyOutput(t *testing.T) {
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)
}