Files
secret/internal/vault/integration_version_test.go
T
clawbot 23dcea83f9
check / check (push) Failing after 2s
Create a vault whole in a temporary directory, then select it (closes #105)
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
2026-10-04 20:42:03 +02:00

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)
}