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. What they checked before asking is checked again under the lock: unlocker add fails if the vault was removed meanwhile and refuses a GPG key added meanwhile. A forced add of a secret that another command created meanwhile lets go of the lock, gets the key, and takes the lock again. secret.CreatePGPUnlocker now takes the vault, already unlocked; SetMnemonic and SetUnlockPassphrase, used only by it, are gone. Not done: secret move between vaults still unlocks both under the lock; the issue does not name it. Model: opus-5-5
422 lines
12 KiB
Go
422 lines
12 KiB
Go
// Vault-Level Version Operation Tests
|
|
//
|
|
// Integration tests for vault-level version operations:
|
|
//
|
|
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper
|
|
// version structure
|
|
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with
|
|
// force flag
|
|
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current
|
|
// version
|
|
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
|
// across versions
|
|
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
|
|
// taken after AddSecret unlocks the vault
|
|
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
|
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
|
//
|
|
// Version Management:
|
|
// - List versions in reverse chronological order
|
|
// - Promote any version to current
|
|
// - Promotion doesn't modify timestamps
|
|
// - Metadata remains encrypted and intact
|
|
|
|
//nolint:testpackage // white-box test of unexported updateVersionMetadata
|
|
package vault
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
"sneak.berlin/go/secret/pkg/agehd"
|
|
)
|
|
|
|
// testMnemonic is the mnemonic used to derive the vault long-term key.
|
|
//
|
|
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon about"
|
|
|
|
// Shared fixtures for white-box tests in this package.
|
|
const (
|
|
testStateDir = "/test/state"
|
|
testSecretPath = "test/secret"
|
|
)
|
|
|
|
// Helper function to add a secret to vault with proper buffer protection
|
|
func addTestSecretToVault(
|
|
t *testing.T, vault *Vault, name string, value []byte, force bool,
|
|
) {
|
|
t.Helper()
|
|
|
|
buffer := memguard.NewBufferFromBytes(value)
|
|
defer buffer.Destroy()
|
|
|
|
err := vault.AddSecret(name, buffer, force)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Helper function to create a vault named "test" with its long-term key set
|
|
// up and unlocked
|
|
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
|
t.Helper()
|
|
|
|
// 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 with the derived key
|
|
vault.Unlock(ltIdentity)
|
|
|
|
return vault
|
|
}
|
|
|
|
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add a secret
|
|
secretValue := []byte("initial-value")
|
|
expectedValue := make([]byte, len(secretValue))
|
|
copy(expectedValue, secretValue)
|
|
|
|
addTestSecretToVault(t, vault, testSecretPath, secretValue, false)
|
|
|
|
// Check that version directory was created
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
|
versionsDir := secretDir + "/versions"
|
|
|
|
// Should have one version
|
|
entries, err := afero.ReadDir(fs, versionsDir)
|
|
require.NoError(t, err)
|
|
assert.Len(t, entries, 1)
|
|
|
|
// Should have current symlink
|
|
currentPath := secretDir + "/current"
|
|
exists, err := afero.Exists(fs, currentPath)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
|
|
// Get the secret value
|
|
retrievedValue, err := vault.GetSecret(testSecretPath)
|
|
require.NoError(t, err)
|
|
|
|
defer retrievedValue.Destroy()
|
|
|
|
assert.Equal(t, expectedValue, retrievedValue.Bytes())
|
|
}
|
|
|
|
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add first version
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
|
|
|
// Try to add again without force - should fail
|
|
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
|
defer failBuffer.Destroy()
|
|
|
|
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
|
require.ErrorIs(t, err, ErrSecretExists)
|
|
|
|
// Add with force - should create new version
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
|
|
// Check that we have two versions
|
|
vaultDir, _ := vault.GetDirectory()
|
|
versionsDir := vaultDir + "/secrets.d/test%secret/versions"
|
|
entries, err := afero.ReadDir(fs, versionsDir)
|
|
require.NoError(t, err)
|
|
assert.Len(t, entries, 2)
|
|
|
|
// Current value should be version-2
|
|
value, err := vault.GetSecret(testSecretPath)
|
|
require.NoError(t, err)
|
|
|
|
defer value.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-2"), value.Bytes())
|
|
}
|
|
|
|
func TestVaultGetSecretVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add multiple versions
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
|
|
|
// Small delay to ensure different version names
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
|
|
// Get versions list
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
// Get specific version (first one)
|
|
firstVersion := versions[1] // Last in list is first created
|
|
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
|
require.NoError(t, err)
|
|
|
|
defer first.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-1"), first.Bytes())
|
|
|
|
// Get specific version (second one)
|
|
secondVersion := versions[0] // First in list is most recent
|
|
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
|
|
require.NoError(t, err)
|
|
|
|
defer second.Destroy()
|
|
|
|
assert.Equal(t, []byte("version-2"), second.Bytes())
|
|
|
|
// An empty version is not one of the versions; GetSecret gets the
|
|
// current one
|
|
_, err = vault.GetSecretVersion(testSecretPath, "")
|
|
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
}
|
|
|
|
func TestVaultVersionTimestamps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Get long-term key
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
require.NoError(t, err)
|
|
|
|
// Add first version
|
|
beforeFirst := time.Now()
|
|
|
|
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
|
defer v1Buffer.Destroy()
|
|
|
|
err = vault.AddSecret(testSecretPath, v1Buffer, false)
|
|
require.NoError(t, err)
|
|
|
|
afterFirst := time.Now()
|
|
|
|
// Get first version metadata
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
|
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 1)
|
|
|
|
firstVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
|
err = firstVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
// Check first version timestamps
|
|
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
|
assert.True(t,
|
|
firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
|
assert.True(t,
|
|
firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
|
|
|
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
|
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
|
assert.Nil(t, firstVersion.Metadata.NotAfter) // Still current
|
|
|
|
// Add second version
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
beforeSecond := time.Now()
|
|
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
|
|
afterSecond := time.Now()
|
|
|
|
// Get updated versions
|
|
versions, err = secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
// Reload first version metadata (should have notAfter now)
|
|
firstVersion = secret.NewVersion(vault, testSecretPath, versions[1])
|
|
err = firstVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
|
assert.True(t,
|
|
firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
|
assert.True(t,
|
|
firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
|
|
|
// Check second version timestamps
|
|
secondVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
|
err = secondVersion.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
|
assert.True(t,
|
|
secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
|
assert.True(t,
|
|
secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
|
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
|
}
|
|
|
|
// longTermKeyReadFs records when longterm.age, the last file an unlock
|
|
// reads, was last opened.
|
|
type longTermKeyReadFs struct {
|
|
afero.Fs
|
|
|
|
readAt time.Time
|
|
}
|
|
|
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
|
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
|
|
if filepath.Base(name) == "longterm.age" {
|
|
f.readAt = time.Now()
|
|
}
|
|
|
|
return f.Fs.Open(name)
|
|
}
|
|
|
|
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
|
|
// unlock the vault to replace a version, the new version's notBefore and the
|
|
// replaced version's notAfter are taken after the unlock, which can wait for
|
|
// the user to type the passphrase.
|
|
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
|
|
|
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
defer mnemonic.Destroy()
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
|
defer passphrase.Destroy()
|
|
|
|
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
|
|
require.NoError(t, err)
|
|
|
|
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
|
|
|
|
// A new instance is locked; AddSecret unlocks it with the passphrase
|
|
vault := NewVault(fs, testStateDir, "test")
|
|
vault.UnlockPassphrase = passphrase
|
|
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
|
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
|
|
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
require.NoError(t, err)
|
|
|
|
vaultDir, _ := vault.GetDirectory()
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
|
|
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
|
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
|
|
|
|
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
|
|
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
|
|
|
|
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
|
|
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
|
|
}
|
|
|
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Add a secret
|
|
addTestSecretToVault(t, vault, testSecretPath, []byte("value"), false)
|
|
|
|
// Try to get non-existent version
|
|
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
|
|
require.ErrorIs(t, err, ErrVersionNotFound)
|
|
}
|
|
|
|
func TestUpdateVersionMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault with long-term key
|
|
vault := createTestVaultWithKey(t, fs)
|
|
|
|
// Get long-term key
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
require.NoError(t, err)
|
|
|
|
// Create a version manually to test updateVersionMetadata
|
|
versionName := "20231215.001"
|
|
version := secret.NewVersion(vault, testSecretPath, versionName)
|
|
|
|
// Set initial metadata
|
|
now := time.Now()
|
|
epochPlusOne := time.Unix(1, 0)
|
|
version.Metadata.NotBefore = &epochPlusOne
|
|
version.Metadata.NotAfter = nil
|
|
|
|
// Save version
|
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
|
defer testBuffer.Destroy()
|
|
|
|
err = version.Save(testBuffer)
|
|
require.NoError(t, err)
|
|
|
|
// Update metadata
|
|
version.Metadata.NotAfter = &now
|
|
err = updateVersionMetadata(fs, version, ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
// Load and verify
|
|
version2 := secret.NewVersion(vault, testSecretPath, versionName)
|
|
err = version2.LoadMetadata(ltIdentity)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, version2.Metadata.NotAfter)
|
|
assert.Equal(t, now.Unix(), version2.Metadata.NotAfter.Unix())
|
|
}
|