check / check (push) Failing after 1s
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both; CreatePGPUnlocker sets them on the vault it loads through SetMnemonic and SetUnlockPassphrase, new in VaultInterface. README warns against both variables. Model: opus-5-5
431 lines
12 KiB
Go
431 lines
12 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"
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
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) SetMnemonic(_ *memguard.LockedBuffer) {}
|
|
|
|
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
|
|
|
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 TestGenerateVersionNameMaxSerial(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
secretDir := testSecretDir
|
|
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 := secret.GenerateVersionName(fs, secretDir)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
|
}
|
|
|
|
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
|
|
}
|