Files
secret/internal/vault/vault_test.go
T
clawbot 682bf751e7
check / check (push) Waiting to run
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.
Nothing below the command reads the environment. README warns against
both variables.

Model: opus-5-5
2026-10-04 11:30:08 +00:00

347 lines
8.4 KiB
Go

package vault_test
import (
"bytes"
"path/filepath"
"slices"
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// testMnemonic is the shared BIP39 test mnemonic for tests in this package.
//
//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 tests in this package.
const (
testStateDir = "/test/state"
testVaultName = "test-vault"
testSecretName = "test-secret"
testPassphrase = "test-passphrase"
)
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
//nolint:paralleltest // order-dependent subtests forbid parallel
func TestVaultOperations(t *testing.T) {
// Use in-memory filesystem
fs := afero.NewMemMapFs()
t.Run("CreateVault", func(t *testing.T) {
testCreateVault(t, fs)
})
t.Run("ListVaults", func(t *testing.T) {
testListVaults(t, fs)
})
t.Run("SelectVault", func(t *testing.T) {
testSelectVault(t, fs)
})
t.Run("SecretOperations", func(t *testing.T) {
testSecretOperations(t, fs)
})
t.Run("NumSecrets", func(t *testing.T) {
testNumSecrets(t, fs)
})
t.Run("UnlockerOperations", func(t *testing.T) {
testUnlockerOperations(t, fs)
})
}
func testCreateVault(t *testing.T, fs afero.Fs) {
t.Helper()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
if vlt.GetName() != testVaultName {
t.Errorf("Expected vault name '%s', got '%s'", testVaultName, vlt.GetName())
}
// Check vault directory exists
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
t.Fatalf("Failed to check vault directory: %v", err)
}
if !exists {
t.Errorf("Vault directory should exist")
}
}
func testListVaults(t *testing.T, fs afero.Fs) {
t.Helper()
vaults, err := vault.ListVaults(fs, testStateDir)
if err != nil {
t.Fatalf("Failed to list vaults: %v", err)
}
if !slices.Contains(vaults, testVaultName) {
t.Errorf("Expected to find '%s' in vault list", testVaultName)
}
}
func testSelectVault(t *testing.T, fs afero.Fs) {
t.Helper()
err := vault.SelectVault(fs, testStateDir, testVaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Test getting current vault
currentVault, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil {
t.Fatalf("Failed to get current vault: %v", err)
}
if currentVault.GetName() != testVaultName {
t.Errorf("Expected current vault '%s', got '%s'",
testVaultName, currentVault.GetName())
}
}
func testSecretOperations(t *testing.T, fs afero.Fs) {
t.Helper()
vlt, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil {
t.Fatalf("Failed to get current vault: %v", err)
}
// First, derive the long-term key from the test mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the public key from the derived identity
ltPublicKey := ltIdentity.Recipient().String()
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write the correct public key to the pub.age file
pubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault with the derived identity
vlt.Unlock(ltIdentity)
// Now add a secret
secretName := "test/secret"
secretValue := []byte("test-secret-value")
expectedValue := make([]byte, len(secretValue))
copy(expectedValue, secretValue)
secretBuffer := memguard.NewBufferFromBytes(secretValue)
defer secretBuffer.Destroy()
err = vlt.AddSecret(secretName, secretBuffer, false)
if err != nil {
t.Fatalf("Failed to add secret: %v", err)
}
// List secrets
secrets, err := vlt.ListSecrets()
if err != nil {
t.Fatalf("Failed to list secrets: %v", err)
}
if !slices.Contains(secrets, secretName) {
t.Errorf("Expected to find secret '%s' in list", secretName)
}
// Get secret value
retrievedValue, err := vlt.GetSecret(secretName)
if err != nil {
t.Fatalf("Failed to get secret: %v", err)
}
defer retrievedValue.Destroy()
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Expected secret value '%s', got '%s'",
expectedValue, retrievedValue.Bytes())
}
}
func testNumSecrets(t *testing.T, fs afero.Fs) {
t.Helper()
vlt, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil {
t.Fatalf("Failed to get current vault: %v", err)
}
numSecrets, err := vlt.NumSecrets()
if err != nil {
t.Fatalf("Failed to count secrets: %v", err)
}
// We added one secret in SecretOperations
if numSecrets != 1 {
t.Errorf("Expected 1 secret, got %d", numSecrets)
}
}
func testUnlockerOperations(t *testing.T, fs afero.Fs) {
t.Helper()
vlt, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil {
t.Fatalf("Failed to get current vault: %v", err)
}
// Test vault unlocking (should happen automatically via mnemonic)
vlt.Mnemonic = testMnemonicBuffer(t)
if vlt.Locked() {
_, err := vlt.UnlockVault()
if err != nil {
t.Fatalf("Failed to unlock vault: %v", err)
}
}
// Create a passphrase unlocker
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphraseBuffer.Destroy()
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// List unlockers
unlockers, err := vlt.ListUnlockers()
if err != nil {
t.Fatalf("Failed to list unlockers: %v", err)
}
if len(unlockers) == 0 {
t.Errorf("Expected at least one unlocker")
}
// Check key type
keyFound := false
for _, key := range unlockers {
if key.Type == "passphrase" {
keyFound = true
break
}
}
if !keyFound {
t.Errorf("Expected to find passphrase unlocker")
}
// Test selecting unlocker
err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
if err != nil {
t.Fatalf("Failed to select unlocker: %v", err)
}
// Test getting current unlocker
currentUnlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
t.Fatalf("Failed to get current unlocker: %v", err)
}
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
t.Errorf("Expected current unlocker ID '%s', got '%s'",
passphraseUnlocker.GetID(), currentUnlocker.GetID())
}
}
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
t.Parallel()
// Use in-memory filesystem
fs := afero.NewMemMapFs()
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Create a passphrase unlocker so we have at least one valid unlocker
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphraseBuffer.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Create a bogus unlocker directory with no metadata file
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
err = fs.MkdirAll(bogusDir, 0o700)
if err != nil {
t.Fatalf("Failed to create bogus directory: %v", err)
}
// ListUnlockers should succeed, skipping the bogus directory
unlockers, err := vlt.ListUnlockers()
if err != nil {
t.Fatalf("ListUnlockers returned error when it should have skipped "+
"bad directory: %v", err)
}
// Should still have the valid passphrase unlocker
if len(unlockers) == 0 {
t.Errorf("Expected at least one unlocker, got none")
}
// Verify we only got the valid unlocker(s), not the bogus one
for _, u := range unlockers {
if u.Type == "" {
t.Errorf("Got unlocker with empty type, likely from bogus directory")
}
}
}