Files
secret/internal/secret/secret_test.go
T
clawbot 2adc588ace
check / check (push) Failing after 2s
Say the mnemonic still opens a vault its unlocker cannot (closes #47)
When a vault cannot be opened through its current unlocker, the error now
ends by naming the vault, saying that it still opens with its mnemonic,
and that 'secret unlocker add passphrase' run with SB_SECRET_MNEMONIC set
gives it a new unlocker, after 'secret vault select' when it is not the
current vault. Only when the vault metadata records the key the mnemonic
derives, and not when the passphrase could not be read. 'secret encrypt'
and 'secret decrypt' read the key secret through vault.GetSecret, and
Secret.GetValue with its helpers is removed. An unreadable 'current'
file's error names 'secret version list' and 'secret version promote'.
Causes stay wrapped.

Model: opus-5-5
2026-10-04 21:59:02 +02:00

322 lines
8.7 KiB
Go

//nolint:testpackage // white-box test of unexported internals
package secret
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// testMnemonicValue is the standard BIP39 test vector mnemonic.
//
//nolint:dupword // BIP39 test mnemonic repeats words by design
const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
var (
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
errNotImplementedInMock = errors.New("not implemented in mock")
)
// MockVault is a test implementation of the VaultInterface
type MockVault struct {
name string
fs afero.Fs
directory string
derivationIndex uint32
mnemonic *memguard.LockedBuffer
}
func (m *MockVault) GetDirectory() (string, error) {
return m.directory, nil
}
func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool) error {
// Create secret directory with proper storage name conversion
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
err := m.fs.MkdirAll(secretDir, 0o700)
if err != nil {
return err
}
// Create version directory with proper path
versionName := "20240101.001" // Use a fixed version name for testing
versionDir := filepath.Join(secretDir, "versions", versionName)
err = m.fs.MkdirAll(versionDir, 0o700)
if err != nil {
return err
}
// Read the vault's long-term public key
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
// Derive long-term key using the vault's derivation index
if m.mnemonic == nil {
return errMnemonicNotSet
}
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
if err != nil {
return err
}
// Write long-term public key if it doesn't exist
_, err = m.fs.Stat(ltPubKeyPath)
if os.IsNotExist(err) {
pubKey := ltIdentity.Recipient().String()
err = afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600)
if err != nil {
return err
}
}
err = m.writeVersionFiles(versionDir, value, ltIdentity)
if err != nil {
return err
}
// Create current file pointing to the version (just the version name)
currentLink := filepath.Join(secretDir, "current")
return afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600)
}
func (m *MockVault) GetName() string {
return m.name
}
//nolint:ireturn // implements VaultInterface
func (m *MockVault) GetFilesystem() afero.Fs {
return m.fs
}
//nolint:ireturn // implements VaultInterface
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, errNotImplementedInMock
}
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
m.mnemonic = mnemonic
}
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
return nil, errNotImplementedInMock
}
// writeVersionFiles generates a version keypair and writes the version
// key and value files for the mock vault.
func (m *MockVault) writeVersionFiles(
versionDir string,
value *memguard.LockedBuffer,
ltIdentity *age.X25519Identity,
) error {
// Generate version-specific keypair
versionIdentity, err := age.GenerateX25519Identity()
if err != nil {
return err
}
// Write version public key
pubKeyPath := filepath.Join(versionDir, "pub.age")
err = afero.WriteFile(
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
if err != nil {
return err
}
// Encrypt value to version's public key (value is already a LockedBuffer)
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
if err != nil {
return err
}
// Write encrypted value
valuePath := filepath.Join(versionDir, "value.age")
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
if err != nil {
return err
}
// Encrypt version private key to long-term public key
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
defer versionPrivKeyBuffer.Destroy()
encryptedPrivKey, err := EncryptToRecipient(
versionPrivKeyBuffer, ltIdentity.Recipient())
if err != nil {
return err
}
// Write encrypted version private key
privKeyPath := filepath.Join(versionDir, "priv.age")
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
}
// setupMockVaultDirs creates the vault directory structure, long-term
// public key, and current vault pointer for tests.
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
t.Helper()
// Create vault directory structure
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Generate a long-term keypair for the vault using the test mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonicValue, 0)
if err != nil {
t.Fatalf("Failed to generate long-term identity: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(
fs,
ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()),
0o600,
)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Set current vault
currentVaultPath := filepath.Join(baseDir, "currentvault")
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
if err != nil {
t.Fatalf("Failed to set current vault: %v", err)
}
}
// verifySecretFiles checks that AddSecret created the expected version
// files for the secret.
func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
t.Helper()
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
// Check versions directory exists
versionsDir := filepath.Join(secretDir, "versions")
versionsDirExists, err := afero.DirExists(fs, versionsDir)
if err != nil || !versionsDirExists {
t.Fatalf("versions directory was not created")
}
// Check current file exists and points at a version
currentVersion, err := GetCurrentVersion(fs, secretDir)
if err != nil {
t.Fatalf("Failed to get current version: %v", err)
}
// Check value.age exists in the version directory
versionDir := filepath.Join(versionsDir, currentVersion)
valueExists, err := afero.Exists(fs, filepath.Join(versionDir, "value.age"))
if err != nil || !valueExists {
t.Fatalf("value.age file was not created in version directory")
}
}
//nolint:paralleltest // subtests share one vault, order matters
func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing
fs := afero.NewMemMapFs()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
// Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret"
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
setupMockVaultDirs(t, fs, baseDir, vaultDir)
// Create vault instance using the mock vault
vault := &MockVault{
name: "test-vault",
fs: fs,
directory: vaultDir,
derivationIndex: 0,
mnemonic: mnemonic,
}
// Test data
secretName := "test-secret"
secretValue := []byte("this is a test secret value")
// Create a secure buffer for the test value
valueBuffer := memguard.NewBufferFromBytes(secretValue)
defer valueBuffer.Destroy()
// Test AddSecret
t.Run("AddSecret", func(t *testing.T) {
err := vault.AddSecret(secretName, valueBuffer, false)
if err != nil {
t.Fatalf("AddSecret failed: %v", err)
}
// Verify that all expected files were created
verifySecretFiles(t, fs, vaultDir, secretName)
t.Logf("All expected files created successfully with versioning")
})
// Create a Secret object to test with
secret := NewSecret(vault, secretName)
// Test GetValue (this will need to be modified since we're using a mock vault)
t.Run("GetSecret", func(t *testing.T) {
// This test is simplified since we're not implementing the full encryption/decryption
// in the mock. We just verify the Secret object is created correctly.
if secret.Name != secretName {
t.Fatalf("Secret name doesn't match. Expected: %s, Got: %s", secretName, secret.Name)
}
if secret.vault != vault {
t.Fatalf("Secret vault reference doesn't match expected vault")
}
t.Logf("Successfully created Secret object with correct properties")
})
// Test Exists
t.Run("SecretExists", func(t *testing.T) {
exists, err := secret.Exists()
if err != nil {
t.Fatalf("Error checking if secret exists: %v", err)
}
if !exists {
t.Fatalf("Secret should exist but Exists() returned false")
}
t.Logf("Secret.Exists() works correctly")
})
}