check / check (push) Waiting to run
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
358 lines
10 KiB
Go
358 lines
10 KiB
Go
// Vault-Level Version Operation Tests
|
|
//
|
|
// Integration tests for vault-level version operations:
|
|
//
|
|
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper
|
|
// version structure
|
|
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with
|
|
// force flag
|
|
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current
|
|
// version
|
|
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
|
// across versions
|
|
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
|
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
|
//
|
|
// Version Management:
|
|
// - List versions in reverse chronological order
|
|
// - Promote any version to current
|
|
// - Promotion doesn't modify timestamps
|
|
// - Metadata remains encrypted and intact
|
|
|
|
//nolint:testpackage // white-box test of unexported updateVersionMetadata
|
|
package vault
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"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"
|
|
)
|
|
|
|
// testMnemonic is the mnemonic used to derive the vault long-term key.
|
|
//
|
|
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon about"
|
|
|
|
// Shared fixtures for white-box tests in this package.
|
|
const (
|
|
testStateDir = "/test/state"
|
|
testSecretPath = "test/secret"
|
|
)
|
|
|
|
// Helper function to add a secret to vault with proper buffer protection
|
|
func addTestSecretToVault(
|
|
t *testing.T, vault *Vault, name string, value []byte, force bool,
|
|
) {
|
|
t.Helper()
|
|
|
|
buffer := memguard.NewBufferFromBytes(value)
|
|
defer buffer.Destroy()
|
|
|
|
err := vault.AddSecret(name, buffer, force)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Helper function to create a vault named "test" with its long-term key set
|
|
// up and unlocked
|
|
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
|
t.Helper()
|
|
|
|
// Create vault without a long-term key, which is set up below
|
|
vault, err := CreateVault(fs, testStateDir, "test", 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, _ := vault.GetDirectory()
|
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
|
|
err = afero.WriteFile(fs, ltPubKeyPath,
|
|
[]byte(ltIdentity.Recipient().String()), 0o600)
|
|
require.NoError(t, err)
|
|
|
|
// Unlock the vault with the derived key
|
|
vault.Unlock(ltIdentity)
|
|
|
|
return vault
|
|
}
|
|
|
|
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add a secret
|
|
secretValue := []byte("initial-value")
|
|
expectedValue := make([]byte, len(secretValue))
|
|
copy(expectedValue, secretValue)
|
|
|
|
addTestSecretToVault(t, vault, testSecretPath, secretValue, false)
|
|
|
|
// Check that version directory was created
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
|
versionsDir := secretDir + "/versions"
|
|
|
|
// Should have one version
|
|
entries, err := afero.ReadDir(fs, versionsDir)
|
|
require.NoError(t, err)
|
|
assert.Len(t, entries, 1)
|
|
|
|
// Should have current symlink
|
|
currentPath := secretDir + "/current"
|
|
exists, err := afero.Exists(fs, currentPath)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
|
|
// Get the secret value
|
|
retrievedValue, err := vault.GetSecret(testSecretPath)
|
|
require.NoError(t, err)
|
|
|
|
defer retrievedValue.Destroy()
|
|
|
|
assert.Equal(t, expectedValue, retrievedValue.Bytes())
|
|
}
|
|
|
|
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add first version
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
|
|
|
// Try to add again without force - should fail
|
|
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
|
defer failBuffer.Destroy()
|
|
|
|
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already exists")
|
|
|
|
// Add with force - should create new version
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
|
|
// Check that we have two versions
|
|
vaultDir, _ := vault.GetDirectory()
|
|
versionsDir := vaultDir + "/secrets.d/test%secret/versions"
|
|
entries, err := afero.ReadDir(fs, versionsDir)
|
|
require.NoError(t, err)
|
|
assert.Len(t, entries, 2)
|
|
|
|
// Current value should be version-2
|
|
value, err := vault.GetSecret(testSecretPath)
|
|
require.NoError(t, err)
|
|
|
|
defer value.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-2"), value.Bytes())
|
|
}
|
|
|
|
func TestVaultGetSecretVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add multiple versions
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
|
|
|
// Small delay to ensure different version names
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
|
|
// Get versions list
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
// Get specific version (first one)
|
|
firstVersion := versions[1] // Last in list is first created
|
|
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
|
require.NoError(t, err)
|
|
|
|
defer first.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-1"), first.Bytes())
|
|
|
|
// Get specific version (second one)
|
|
secondVersion := versions[0] // First in list is most recent
|
|
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
|
|
require.NoError(t, err)
|
|
|
|
defer second.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-2"), second.Bytes())
|
|
|
|
// An empty version is not one of the versions; GetSecret gets the
|
|
// current one
|
|
_, err = vault.GetSecretVersion(testSecretPath, "")
|
|
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
}
|
|
|
|
func TestVaultVersionTimestamps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Get long-term key
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
require.NoError(t, err)
|
|
|
|
// Add first version
|
|
beforeFirst := time.Now()
|
|
|
|
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
|
defer v1Buffer.Destroy()
|
|
|
|
err = vault.AddSecret(testSecretPath, v1Buffer, false)
|
|
require.NoError(t, err)
|
|
|
|
afterFirst := time.Now()
|
|
|
|
// Get first version metadata
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
|
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 1)
|
|
|
|
firstVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
|
err = firstVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
// Check first version timestamps
|
|
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
|
assert.True(t,
|
|
firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
|
assert.True(t,
|
|
firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
|
|
|
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
|
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
|
assert.Nil(t, firstVersion.Metadata.NotAfter) // Still current
|
|
|
|
// Add second version
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
beforeSecond := time.Now()
|
|
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
|
|
afterSecond := time.Now()
|
|
|
|
// Get updated versions
|
|
versions, err = secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
// Reload first version metadata (should have notAfter now)
|
|
firstVersion = secret.NewVersion(vault, testSecretPath, versions[1])
|
|
err = firstVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
|
assert.True(t,
|
|
firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
|
assert.True(t,
|
|
firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
|
|
|
// Check second version timestamps
|
|
secondVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
|
err = secondVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
|
assert.True(t,
|
|
secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
|
assert.True(t,
|
|
secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
|
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
|
}
|
|
|
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add a secret
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("value"), false)
|
|
|
|
// Try to get non-existent version
|
|
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not found")
|
|
}
|
|
|
|
func TestUpdateVersionMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Get long-term key
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
require.NoError(t, err)
|
|
|
|
// Create a version manually to test updateVersionMetadata
|
|
versionName := "20231215.001"
|
|
version := secret.NewVersion(vault, testSecretPath, versionName)
|
|
|
|
// Set initial metadata
|
|
now := time.Now()
|
|
epochPlusOne := time.Unix(1, 0)
|
|
version.Metadata.NotBefore = &epochPlusOne
|
|
version.Metadata.NotAfter = nil
|
|
|
|
// Save version
|
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
|
defer testBuffer.Destroy()
|
|
|
|
err = version.Save(testBuffer)
|
|
require.NoError(t, err)
|
|
|
|
// Update metadata
|
|
version.Metadata.NotAfter = &now
|
|
err = updateVersionMetadata(fs, version, ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
// Load and verify
|
|
version2 := secret.NewVersion(vault, testSecretPath, versionName)
|
|
err = version2.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, version2.Metadata.NotAfter)
|
|
assert.Equal(t, now.Unix(), version2.Metadata.NotAfter.Unix())
|
|
}
|