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
447 lines
13 KiB
Go
447 lines
13 KiB
Go
// Version Support Integration Tests
|
|
//
|
|
// Comprehensive integration tests for version functionality:
|
|
//
|
|
// - TestVersionIntegrationWorkflow: End-to-end workflow testing
|
|
// - Creating initial version with proper metadata
|
|
// - Creating multiple versions with timestamp updates
|
|
// - Retrieving specific versions by name
|
|
// - Promoting old versions to current
|
|
// - Testing version serial number limits (999/day)
|
|
// - Error cases and edge conditions
|
|
//
|
|
// - TestVersionConcurrency: Tests concurrent read operations
|
|
//
|
|
// - TestVersionCompatibility: Tests handling of legacy non-versioned secrets
|
|
//
|
|
// Test Environment:
|
|
// - Uses in-memory filesystem (afero.MemMapFs)
|
|
// - Consistent test mnemonic for reproducible keys
|
|
// - Proper cleanup and isolation between tests
|
|
|
|
//nolint:testpackage // uses white-box test helpers shared with this package
|
|
package vault
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"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"
|
|
)
|
|
|
|
// errUnexpectedValue is returned by concurrent readers when a secret value
|
|
// does not match the expected contents.
|
|
var errUnexpectedValue = errors.New("unexpected value")
|
|
|
|
// TestVersionIntegrationWorkflow tests the complete version workflow
|
|
//
|
|
//nolint:paralleltest // the subtests are steps that build on each other
|
|
func TestVersionIntegrationWorkflow(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// 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
|
|
vault.Unlock(ltIdentity)
|
|
|
|
secretName := "integration/test"
|
|
|
|
// Step 1: Create initial version
|
|
t.Run("create_initial_version", func(t *testing.T) {
|
|
testCreateInitialVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
|
})
|
|
|
|
// Step 2: Create second version
|
|
t.Run("create_second_version", func(t *testing.T) {
|
|
testCreateSecondVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
|
})
|
|
|
|
// Step 3: Create third version
|
|
t.Run("create_third_version", func(t *testing.T) {
|
|
testCreateThirdVersion(t, fs, vault, vaultDir, secretName)
|
|
})
|
|
|
|
// Step 4: Retrieve specific versions
|
|
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
|
testRetrieveSpecificVersions(t, fs, vault, vaultDir, secretName)
|
|
})
|
|
|
|
// Step 5: Promote old version to current
|
|
t.Run("promote_old_version", func(t *testing.T) {
|
|
testPromoteOldVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
|
})
|
|
|
|
// Step 6: Test version limits
|
|
t.Run("version_serial_limits", func(t *testing.T) {
|
|
testVersionSerialLimits(t, fs, vault, vaultDir)
|
|
})
|
|
|
|
// Step 7: Test error cases
|
|
t.Run("error_cases", func(t *testing.T) {
|
|
testVersionErrorCases(t, vault, secretName)
|
|
})
|
|
}
|
|
|
|
func testCreateInitialVersion(
|
|
t *testing.T, fs afero.Fs, vault *Vault,
|
|
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
addTestSecretToVault(t, vault, secretName, []byte("version-1-data"), false)
|
|
|
|
// Verify secret can be retrieved
|
|
value, err := vault.GetSecret(secretName)
|
|
require.NoError(t, err)
|
|
|
|
defer value.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
|
|
|
// Verify version directory structure
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Len(t, versions, 1)
|
|
|
|
// Verify current symlink exists
|
|
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, versions[0], currentVersion)
|
|
|
|
// Verify metadata
|
|
version := secret.NewVersion(vault, secretName, versions[0])
|
|
err = version.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, version.Metadata.CreatedAt)
|
|
assert.NotNil(t, version.Metadata.NotBefore)
|
|
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
|
// NotAfter should be nil for current version
|
|
assert.Nil(t, version.Metadata.NotAfter)
|
|
}
|
|
|
|
func testCreateSecondVersion(
|
|
t *testing.T, fs afero.Fs, vault *Vault,
|
|
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Small delay to ensure different timestamps
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Get first version name before creating second
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
|
|
firstVersionName := versions[0]
|
|
|
|
// Create second version
|
|
addTestSecretToVault(t, vault, secretName, []byte("version-2-data"), true)
|
|
|
|
// Verify new value is current
|
|
value, err := vault.GetSecret(secretName)
|
|
require.NoError(t, err)
|
|
|
|
defer value.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-2-data"), value.Bytes())
|
|
|
|
// Verify we now have two versions
|
|
versions, err = secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Len(t, versions, 2)
|
|
|
|
// Verify first version metadata was updated with notAfter
|
|
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
|
|
err = firstVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
|
|
|
// Verify second version metadata
|
|
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
|
err = secondVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
|
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
|
|
|
// NotBefore of second should equal NotAfter of first
|
|
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(),
|
|
secondVersion.Metadata.NotBefore.Unix())
|
|
}
|
|
|
|
func testCreateThirdVersion(
|
|
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
addTestSecretToVault(t, vault, secretName, []byte("version-3-data"), true)
|
|
|
|
// Verify we now have three versions
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Len(t, versions, 3)
|
|
|
|
// Current should be version-3
|
|
value, err := vault.GetSecret(secretName)
|
|
require.NoError(t, err)
|
|
|
|
defer value.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-3-data"), value.Bytes())
|
|
}
|
|
|
|
func testRetrieveSpecificVersions(
|
|
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 3)
|
|
|
|
// Get each version by its name
|
|
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
|
require.NoError(t, err)
|
|
|
|
defer value1.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
|
|
|
|
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
|
require.NoError(t, err)
|
|
|
|
defer value2.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
|
|
|
|
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
|
require.NoError(t, err)
|
|
|
|
defer value3.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
|
|
|
|
// An empty version is not one of the versions; GetSecret gets the
|
|
// current one
|
|
_, err = vault.GetSecretVersion(secretName, "")
|
|
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
}
|
|
|
|
func testPromoteOldVersion(
|
|
t *testing.T, fs afero.Fs, vault *Vault,
|
|
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
|
|
// Promote the first version (oldest) to current
|
|
oldestVersion := versions[2]
|
|
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
|
|
require.NoError(t, err)
|
|
|
|
// Verify current now returns the old version's value
|
|
value, err := vault.GetSecret(secretName)
|
|
require.NoError(t, err)
|
|
|
|
defer value.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
|
|
|
// Verify the version metadata hasn't changed
|
|
// (promoting shouldn't modify timestamps)
|
|
version := secret.NewVersion(vault, secretName, oldestVersion)
|
|
err = version.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
// should still have its old notAfter
|
|
assert.NotNil(t, version.Metadata.NotAfter)
|
|
}
|
|
|
|
func testVersionSerialLimits(
|
|
t *testing.T, fs afero.Fs, vault *Vault, vaultDir string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Create a new secret for this test
|
|
limitSecretName := "limit/test"
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
|
|
|
// Create 998 versions (we already have one from the first AddSecret)
|
|
addTestSecretToVault(t, vault, limitSecretName, []byte("initial"), false)
|
|
|
|
// Get today's date for consistent version names
|
|
today := time.Now().Format("20060102")
|
|
|
|
// Manually create many versions with same date
|
|
for i := 2; i <= 998; i++ {
|
|
versionName := fmt.Sprintf("%s.%03d", today, i)
|
|
versionDir := filepath.Join(secretDir, versionName)
|
|
err := fs.MkdirAll(versionDir, 0o755)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Should be able to create one more (999)
|
|
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, today+".999", versionName)
|
|
|
|
// Create the 999th version directory
|
|
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
|
require.NoError(t, err)
|
|
|
|
// Should fail to create 1000th version
|
|
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
|
}
|
|
|
|
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
|
t.Helper()
|
|
|
|
// Try to get non-existent version
|
|
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not found")
|
|
|
|
// Try to get version of non-existent secret
|
|
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
|
require.Error(t, err)
|
|
|
|
// Try to add secret without force when it exists
|
|
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
|
defer failBuffer.Destroy()
|
|
|
|
err = vault.AddSecret(secretName, failBuffer, false)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already exists")
|
|
}
|
|
|
|
// TestVersionConcurrency tests concurrent version operations
|
|
func TestVersionConcurrency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Set up vault
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
secretName := "concurrent/test"
|
|
|
|
// Create initial version
|
|
addTestSecretToVault(t, vault, secretName, []byte("initial"), false)
|
|
|
|
// Test concurrent reads
|
|
t.Run("concurrent_reads", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
done := make(chan bool, 10)
|
|
errCh := make(chan error, 10)
|
|
|
|
for range 10 {
|
|
go func() {
|
|
value, err := vault.GetSecret(secretName)
|
|
if err != nil {
|
|
errCh <- err
|
|
} else {
|
|
if value.String() != "initial" {
|
|
errCh <- fmt.Errorf("%w: %s",
|
|
errUnexpectedValue, value.Bytes())
|
|
}
|
|
|
|
value.Destroy()
|
|
}
|
|
|
|
done <- true
|
|
}()
|
|
}
|
|
|
|
// Wait for all goroutines
|
|
for range 10 {
|
|
<-done
|
|
}
|
|
|
|
// Check for errors
|
|
select {
|
|
case err := <-errCh:
|
|
t.Fatalf("concurrent read failed: %v", err)
|
|
default:
|
|
// No errors
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestVersionCompatibility tests that old secrets without versions still work
|
|
func TestVersionCompatibility(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Set up vault
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
require.NoError(t, err)
|
|
|
|
// Manually create an old-style secret (no versions)
|
|
secretName := "legacy/secret"
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "legacy%secret")
|
|
err = fs.MkdirAll(secretDir, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
// Create old-style encrypted value directly in secret directory
|
|
testValue := []byte("legacy-value")
|
|
|
|
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
|
defer testValueBuffer.Destroy()
|
|
|
|
ltRecipient := ltIdentity.Recipient()
|
|
|
|
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
|
require.NoError(t, err)
|
|
|
|
valuePath := filepath.Join(secretDir, "value.age")
|
|
err = afero.WriteFile(fs, valuePath, encrypted, 0o600)
|
|
require.NoError(t, err)
|
|
|
|
// Should fail to get with version-aware methods
|
|
_, err = vault.GetSecret(secretName)
|
|
require.Error(t, err)
|
|
|
|
// List versions should return empty
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, versions)
|
|
}
|