Files
secret/internal/secret/version_test.go
T
clawbot db7d2c952e
check / check (push) Failing after 1s
Read secret environment variables once per command, then unset them (closes #60)
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
2026-10-04 16:07:57 +02:00

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
}