package vault import ( "fmt" "path/filepath" "testing" "time" "git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/pkg/agehd" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestVersionIntegrationWorkflow tests the complete version workflow func TestVersionIntegrationWorkflow(t *testing.T) { fs := afero.NewMemMapFs() stateDir := "/test/state" // Set mnemonic for testing t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") // Create vault vault, err := CreateVault(fs, stateDir, "test") require.NoError(t, err) // Derive and store long-term key from mnemonic mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" ltIdentity, err := agehd.DeriveIdentity(mnemonic, 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()), 0600) 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) { err := vault.AddSecret(secretName, []byte("version-1-data"), false) require.NoError(t, err) // 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.NewSecretVersion(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 assert.Nil(t, version.Metadata.NotAfter) // should be nil for current version }) // Step 2: Create second version var firstVersionName string t.Run("create_second_version", func(t *testing.T) { // 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 err = vault.AddSecret(secretName, []byte("version-2-data"), true) require.NoError(t, err) // 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.NewSecretVersion(vault, secretName, firstVersionName) err = firstVersion.LoadMetadata(ltIdentity) require.NoError(t, err) assert.NotNil(t, firstVersion.Metadata.NotAfter) // Verify second version metadata secondVersion := secret.NewSecretVersion(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()) }) // Step 3: Create third version t.Run("create_third_version", func(t *testing.T) { time.Sleep(10 * time.Millisecond) err := vault.AddSecret(secretName, []byte("version-3-data"), true) require.NoError(t, err) // 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) }) // Step 4: Retrieve specific versions t.Run("retrieve_specific_versions", func(t *testing.T) { 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) }) // Step 5: Promote old version to current t.Run("promote_old_version", func(t *testing.T) { 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.NewSecretVersion(vault, secretName, oldestVersion) err = version.LoadMetadata(ltIdentity) require.NoError(t, err) assert.NotNil(t, version.Metadata.NotAfter) // should still have its old notAfter }) // Step 6: Test version limits t.Run("version_serial_limits", func(t *testing.T) { // 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) err := vault.AddSecret(limitSecretName, []byte("initial"), false) require.NoError(t, err) // 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, 0755) 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, fmt.Sprintf("%s.999", today), versionName) // Create the 999th version directory err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0755) require.NoError(t, err) // Should fail to create 1000th version _, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir)) assert.Error(t, err) assert.Contains(t, err.Error(), "exceeded maximum versions per day") }) // Step 7: Test error cases t.Run("error_cases", func(t *testing.T) { // Try to get non-existent version _, err := vault.GetSecretVersion(secretName, "99991231.999") assert.Error(t, err) assert.Contains(t, err.Error(), "not found") // Try to get version of non-existent secret _, err = vault.GetSecretVersion("nonexistent/secret", "") assert.Error(t, err) // Try to add secret without force when it exists err = vault.AddSecret(secretName, []byte("should-fail"), false) assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") }) } // TestVersionConcurrency tests concurrent version operations func TestVersionConcurrency(t *testing.T) { fs := afero.NewMemMapFs() stateDir := "/test/state" // Set up vault vault := createTestVaultWithKey(t, fs, stateDir, "test") secretName := "concurrent/test" // Create initial version err := vault.AddSecret(secretName, []byte("initial"), false) require.NoError(t, err) // Test concurrent reads t.Run("concurrent_reads", func(t *testing.T) { done := make(chan bool, 10) errors := make(chan error, 10) for i := 0; i < 10; i++ { go func() { value, err := vault.GetSecret(secretName) if err != nil { errors <- err } else if string(value) != "initial" { errors <- fmt.Errorf("unexpected value: %s", value) } done <- true }() } // Wait for all goroutines for i := 0; i < 10; i++ { <-done } // Check for errors select { case err := <-errors: t.Fatalf("concurrent read failed: %v", err) default: // No errors } }) } // TestVersionCompatibility tests that old secrets without versions still work func TestVersionCompatibility(t *testing.T) { fs := afero.NewMemMapFs() stateDir := "/test/state" // Set up vault vault := createTestVaultWithKey(t, fs, stateDir, "test") 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, 0755) require.NoError(t, err) // Create old-style encrypted value directly in secret directory testValue := []byte("legacy-value") ltRecipient := ltIdentity.Recipient() encrypted, err := secret.EncryptToRecipient(testValue, ltRecipient) require.NoError(t, err) valuePath := filepath.Join(secretDir, "value.age") err = afero.WriteFile(fs, valuePath, encrypted, 0600) require.NoError(t, err) // Should fail to get with version-aware methods _, err = vault.GetSecret(secretName) assert.Error(t, err) // List versions should return empty versions, err := secret.ListVersions(fs, secretDir) require.NoError(t, err) assert.Empty(t, versions) }