check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. Under the lock, unlocker add fails if the vault was removed, or created again with another key, and refuses a GPG key added meanwhile; a forced add of a secret created meanwhile lets go of the lock, gets the key and locks again. secret.CreatePGPUnlocker takes the unlocked vault; SetMnemonic and SetUnlockPassphrase are gone. Not done: secret move between vaults still unlocks both under the lock (#189). Model: opus-5-5
405 lines
11 KiB
Go
405 lines
11 KiB
Go
// 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_test
|
|
|
|
import (
|
|
"errors"
|
|
"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"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
)
|
|
|
|
const (
|
|
testSecretDir = "/test/secret"
|
|
testVaultName = "test"
|
|
testVaultStateDir = "/test"
|
|
)
|
|
|
|
var errNotImplementedInMock = errors.New("not implemented in mock")
|
|
|
|
// MockVersionVault 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 errNotImplementedInMock
|
|
}
|
|
|
|
func (m *MockVersionVault) GetName() string {
|
|
return m.Name
|
|
}
|
|
|
|
//nolint:ireturn // implements VaultInterface
|
|
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
|
return m.fs
|
|
}
|
|
|
|
//nolint:ireturn // implements VaultInterface
|
|
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|
return nil, errNotImplementedInMock
|
|
}
|
|
|
|
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|
return nil, errNotImplementedInMock
|
|
}
|
|
|
|
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
|
_ *memguard.LockedBuffer,
|
|
) (*secret.PassphraseUnlocker, error) {
|
|
return nil, errNotImplementedInMock
|
|
}
|
|
|
|
func TestGenerateVersionName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
secretDir := testSecretDir
|
|
|
|
// Test first version generation
|
|
version1, err := secret.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 := secret.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 TestNewVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
vault := &MockVersionVault{
|
|
Name: testVaultName,
|
|
fs: fs,
|
|
stateDir: testVaultStateDir,
|
|
}
|
|
|
|
sv := secret.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) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
vault := &MockVersionVault{
|
|
Name: testVaultName,
|
|
fs: fs,
|
|
stateDir: testVaultStateDir,
|
|
}
|
|
|
|
// 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 := secret.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) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
vault := &MockVersionVault{
|
|
Name: testVaultName,
|
|
fs: fs,
|
|
stateDir: testVaultStateDir,
|
|
}
|
|
|
|
// 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 := secret.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 := secret.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) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
vault := &MockVersionVault{
|
|
Name: testVaultName,
|
|
fs: fs,
|
|
stateDir: testVaultStateDir,
|
|
}
|
|
|
|
// 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 := secret.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) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
secretDir := testSecretDir
|
|
versionsDir := filepath.Join(secretDir, "versions")
|
|
|
|
// No versions directory
|
|
versions, err := secret.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 = secret.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) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
secretDir := testSecretDir
|
|
|
|
// 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 := secret.GetCurrentVersion(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "20231216.001", version)
|
|
}
|
|
|
|
func TestSetCurrentVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
secretDir := testSecretDir
|
|
|
|
err := fs.MkdirAll(secretDir, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
// Set current version
|
|
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
|
|
require.NoError(t, err)
|
|
|
|
// Verify it was set
|
|
version, err := secret.GetCurrentVersion(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "20231216.002", version)
|
|
|
|
// Update to different version
|
|
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
|
|
require.NoError(t, err)
|
|
|
|
version, err = secret.GetCurrentVersion(fs, secretDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "20231217.001", version)
|
|
}
|
|
|
|
func TestVersionMetadataTimestamps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Test that all timestamp fields behave consistently as pointers
|
|
vm := secret.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
|
|
}
|