check / check (push) Successful in 1m33s
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
422 lines
12 KiB
Go
422 lines
12 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 // t.Setenv forbids parallel subtests
|
|
func TestVersionIntegrationWorkflow(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Set mnemonic for testing
|
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
|
|
// Create vault
|
|
vault, err := CreateVault(fs, testStateDir, "test")
|
|
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)
|
|
assert.Equal(t, []byte("version-1-data"), value)
|
|
|
|
// 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)
|
|
assert.Equal(t, []byte("version-2-data"), value)
|
|
|
|
// 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)
|
|
assert.Equal(t, []byte("version-3-data"), value)
|
|
}
|
|
|
|
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)
|
|
assert.Equal(t, []byte("version-1-data"), value1)
|
|
|
|
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []byte("version-2-data"), value2)
|
|
|
|
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []byte("version-3-data"), value3)
|
|
|
|
// 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)
|
|
assert.Equal(t, []byte("version-1-data"), value)
|
|
|
|
// 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
|
|
//
|
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
func TestVersionConcurrency(t *testing.T) {
|
|
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) {
|
|
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 string(value) != "initial" {
|
|
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value)
|
|
}
|
|
|
|
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
|
|
//
|
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
func TestVersionCompatibility(t *testing.T) {
|
|
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)
|
|
}
|