check / check (push) Failing after 2s
vault.CreateVault takes the unlocker passphrase and writes the vault directory, its metadata, long-term public key and passphrase unlocker into a temporary directory, renames that into vaults.d once complete, and only then makes the vault current. secret init and secret vault create call it once instead of adding the unlocker afterwards, so a kill part-way leaves either no vault, whose temporary directory the next command that takes the lock deletes, or a complete one. A test records the state directory before every change the call makes and checks each state, and the command run again from it. 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, 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)
|
|
}
|