// 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) // Empty version should return current valueCurrent, err := vault.GetSecretVersion(secretName, "") require.NoError(t, err) assert.Equal(t, []byte("version-3-data"), valueCurrent) } 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) }