// Version Support Test Suite Documentation // // This file contains core unit tests for version functionality: // // - TestGenerateVersionName: Tests version name generation with date and serial format // - TestGenerateVersionNameMaxSerial: Tests the 999 versions per day limit // - TestNewVersion: Tests secret version object creation // - TestSecretVersionSave: Tests saving a version with encryption // - TestSecretVersionLoadMetadata: Tests loading and decrypting version metadata // - TestSecretVersionGetValue: Tests retrieving and decrypting version values // - TestListVersions: Tests listing versions in reverse chronological order // - TestGetCurrentVersion: Tests retrieving the current version via symlink // - TestSetCurrentVersion: Tests updating the current version symlink // - TestVersionMetadataTimestamps: Tests timestamp pointer consistency // // Key Test Scenarios: // - Version Creation: First version gets notBefore = epoch + 1 second // - Subsequent versions update previous version's notAfter timestamp // - New version's notBefore equals previous version's notAfter // - Version names follow YYYYMMDD.NNN format // - Maximum 999 versions per day enforced // // Version Retrieval: // - Get current version via symlink // - Get specific version by name // - Empty version parameter returns current // - Non-existent versions return appropriate errors // // Data Integrity: // - Each version has independent encryption keys // - Metadata encryption protects version history // - Long-term key required for all operations // - Concurrent reads handled safely package secret import ( "fmt" "path/filepath" "testing" "time" "filippo.io/age" "github.com/awnumar/memguard" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // MockVault implements VaultInterface for testing type MockVersionVault struct { Name string fs afero.Fs stateDir string longTermKey *age.X25519Identity } func (m *MockVersionVault) GetDirectory() (string, error) { return filepath.Join(m.stateDir, "vaults.d", m.Name), nil } func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error { return fmt.Errorf("not implemented in mock") } func (m *MockVersionVault) GetName() string { return m.Name } func (m *MockVersionVault) GetFilesystem() afero.Fs { return m.fs } func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) { return nil, fmt.Errorf("not implemented in mock") } func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) { return nil, fmt.Errorf("not implemented in mock") } func TestGenerateVersionName(t *testing.T) { fs := afero.NewMemMapFs() secretDir := "/test/secret" // Test first version generation version1, err := GenerateVersionName(fs, secretDir) require.NoError(t, err) assert.Regexp(t, `^\d{8}\.001$`, version1) // Create the version directory versionDir := filepath.Join(secretDir, "versions", version1) err = fs.MkdirAll(versionDir, 0o755) require.NoError(t, err) // Test second version generation on same day version2, err := GenerateVersionName(fs, secretDir) require.NoError(t, err) assert.Regexp(t, `^\d{8}\.002$`, version2) // Verify they have the same date prefix assert.Equal(t, version1[:8], version2[:8]) assert.NotEqual(t, version1, version2) } func TestGenerateVersionNameMaxSerial(t *testing.T) { fs := afero.NewMemMapFs() secretDir := "/test/secret" versionsDir := filepath.Join(secretDir, "versions") // Create 999 versions today := time.Now().Format("20060102") for i := 1; i <= 999; i++ { versionName := fmt.Sprintf("%s.%03d", today, i) err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755) require.NoError(t, err) } // Try to create one more - should fail _, err := GenerateVersionName(fs, secretDir) assert.Error(t, err) assert.Contains(t, err.Error(), "exceeded maximum versions per day") } func TestNewVersion(t *testing.T) { fs := afero.NewMemMapFs() vault := &MockVersionVault{ Name: "test", fs: fs, stateDir: "/test", } sv := NewVersion(vault, "test/secret", "20231215.001") assert.Equal(t, "test/secret", sv.SecretName) assert.Equal(t, "20231215.001", sv.Version) assert.Contains(t, sv.Directory, "test%secret/versions/20231215.001") assert.NotEmpty(t, sv.Metadata.ID) assert.NotNil(t, sv.Metadata.CreatedAt) } func TestSecretVersionSave(t *testing.T) { fs := afero.NewMemMapFs() vault := &MockVersionVault{ Name: "test", fs: fs, stateDir: "/test", } // Create vault directory structure and long-term key vaultDir, _ := vault.GetDirectory() err := fs.MkdirAll(vaultDir, 0o755) require.NoError(t, err) // Generate and store long-term public key ltIdentity, err := age.GenerateX25519Identity() require.NoError(t, err) vault.longTermKey = ltIdentity ltPubKeyPath := filepath.Join(vaultDir, "pub.age") err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600) require.NoError(t, err) // Create and save a version sv := NewVersion(vault, "test/secret", "20231215.001") testValue := []byte("test-secret-value") testBuffer := memguard.NewBufferFromBytes(testValue) defer testBuffer.Destroy() err = sv.Save(testBuffer) require.NoError(t, err) // Verify files were created assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "pub.age"))) assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "priv.age"))) assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "value.age"))) assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "metadata.age"))) } func TestSecretVersionLoadMetadata(t *testing.T) { fs := afero.NewMemMapFs() vault := &MockVersionVault{ Name: "test", fs: fs, stateDir: "/test", } // Setup vault with long-term key vaultDir, _ := vault.GetDirectory() err := fs.MkdirAll(vaultDir, 0o755) require.NoError(t, err) ltIdentity, err := age.GenerateX25519Identity() require.NoError(t, err) vault.longTermKey = ltIdentity ltPubKeyPath := filepath.Join(vaultDir, "pub.age") err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600) require.NoError(t, err) // Create and save a version with custom metadata sv := NewVersion(vault, "test/secret", "20231215.001") now := time.Now() epochPlusOne := time.Unix(1, 0) sv.Metadata.NotBefore = &epochPlusOne sv.Metadata.NotAfter = &now testBuffer := memguard.NewBufferFromBytes([]byte("test-value")) defer testBuffer.Destroy() err = sv.Save(testBuffer) require.NoError(t, err) // Create new version object and load metadata sv2 := NewVersion(vault, "test/secret", "20231215.001") err = sv2.LoadMetadata(ltIdentity) require.NoError(t, err) // Verify loaded metadata assert.Equal(t, sv.Metadata.ID, sv2.Metadata.ID) assert.NotNil(t, sv2.Metadata.NotBefore) assert.Equal(t, epochPlusOne.Unix(), sv2.Metadata.NotBefore.Unix()) assert.NotNil(t, sv2.Metadata.NotAfter) } func TestSecretVersionGetValue(t *testing.T) { fs := afero.NewMemMapFs() vault := &MockVersionVault{ Name: "test", fs: fs, stateDir: "/test", } // Setup vault with long-term key vaultDir, _ := vault.GetDirectory() err := fs.MkdirAll(vaultDir, 0o755) require.NoError(t, err) ltIdentity, err := age.GenerateX25519Identity() require.NoError(t, err) vault.longTermKey = ltIdentity ltPubKeyPath := filepath.Join(vaultDir, "pub.age") err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600) require.NoError(t, err) // Create and save a version sv := NewVersion(vault, "test/secret", "20231215.001") originalValue := []byte("test-secret-value-12345") expectedValue := make([]byte, len(originalValue)) copy(expectedValue, originalValue) originalBuffer := memguard.NewBufferFromBytes(originalValue) defer originalBuffer.Destroy() err = sv.Save(originalBuffer) require.NoError(t, err) // Retrieve the value retrievedBuffer, err := sv.GetValue(ltIdentity) require.NoError(t, err) defer retrievedBuffer.Destroy() assert.Equal(t, expectedValue, retrievedBuffer.Bytes()) } func TestListVersions(t *testing.T) { fs := afero.NewMemMapFs() secretDir := "/test/secret" versionsDir := filepath.Join(secretDir, "versions") // No versions directory versions, err := ListVersions(fs, secretDir) require.NoError(t, err) assert.Empty(t, versions) // Create some versions testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"} for _, v := range testVersions { err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755) require.NoError(t, err) } // Create a file (not directory) that should be ignored err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600) require.NoError(t, err) // List versions versions, err = ListVersions(fs, secretDir) require.NoError(t, err) // Should be sorted in reverse chronological order expected := []string{"20231216.001", "20231215.002", "20231215.001", "20231214.001"} assert.Equal(t, expected, versions) } func TestGetCurrentVersion(t *testing.T) { fs := afero.NewMemMapFs() secretDir := "/test/secret" // The current file contains just the version name currentPath := filepath.Join(secretDir, "current") err := fs.MkdirAll(secretDir, 0o755) require.NoError(t, err) err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600) require.NoError(t, err) version, err := GetCurrentVersion(fs, secretDir) require.NoError(t, err) assert.Equal(t, "20231216.001", version) } func TestSetCurrentVersion(t *testing.T) { fs := afero.NewMemMapFs() secretDir := "/test/secret" err := fs.MkdirAll(secretDir, 0o755) require.NoError(t, err) // Set current version err = SetCurrentVersion(fs, secretDir, "20231216.002") require.NoError(t, err) // Verify it was set version, err := GetCurrentVersion(fs, secretDir) require.NoError(t, err) assert.Equal(t, "20231216.002", version) // Update to different version err = SetCurrentVersion(fs, secretDir, "20231217.001") require.NoError(t, err) version, err = GetCurrentVersion(fs, secretDir) require.NoError(t, err) assert.Equal(t, "20231217.001", version) } func TestVersionMetadataTimestamps(t *testing.T) { // Test that all timestamp fields behave consistently as pointers vm := VersionMetadata{ ID: "test-id", } // All should be nil initially assert.Nil(t, vm.CreatedAt) assert.Nil(t, vm.NotBefore) assert.Nil(t, vm.NotAfter) // Set timestamps now := time.Now() epoch := time.Unix(1, 0) future := now.Add(time.Hour) vm.CreatedAt = &now vm.NotBefore = &epoch vm.NotAfter = &future // All should be non-nil assert.NotNil(t, vm.CreatedAt) assert.NotNil(t, vm.NotBefore) assert.NotNil(t, vm.NotAfter) // Values should match assert.Equal(t, now.Unix(), vm.CreatedAt.Unix()) assert.Equal(t, int64(1), vm.NotBefore.Unix()) assert.Equal(t, future.Unix(), vm.NotAfter.Unix()) } // Helper function func fileExists(fs afero.Fs, path string) bool { exists, _ := afero.Exists(fs, path) return exists }