check / check (push) Failing after 2s
vault.CreateVault takes the unlocker passphrase and writes the vault directory, its metadata, long-term public key and passphrase unlocker into a temporary directory, renames that into vaults.d once complete, and only then makes the vault current. secret init and secret vault create call it once instead of adding the unlocker afterwards, so a kill part-way leaves either no vault, whose temporary directory the next command that takes the lock deletes, or a complete one. A test records the state directory before every change the call makes and checks each state, and the command run again from it. Model: opus-5-5
374 lines
9.2 KiB
Go
374 lines
9.2 KiB
Go
package vault_test
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"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), nil)
|
|
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), nil)
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCreateVaultUnlockerNeedsMnemonic checks that CreateVault, given a
|
|
// passphrase for an unlocker but no mnemonic to derive the long-term key from,
|
|
// fails without writing anything.
|
|
func TestCreateVaultUnlockerNeedsMnemonic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphrase.Destroy()
|
|
|
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName, nil, passphrase)
|
|
if !errors.Is(err, vault.ErrUnlockerWithoutMnemonic) {
|
|
t.Fatalf("Expected ErrUnlockerWithoutMnemonic, got %v", err)
|
|
}
|
|
|
|
exists, err := afero.Exists(fs, testStateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check for the state directory: %v", err)
|
|
}
|
|
|
|
if exists {
|
|
t.Errorf("CreateVault wrote the state directory")
|
|
}
|
|
}
|