Files
secret/internal/cli/create_vault_test.go
T
clawbot db7d2c952e
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;
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

196 lines
5.9 KiB
Go

package cli_test
import (
"bytes"
"os"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// TestCreateExistingVaultChangesNothing is a regression test for
// https://git.eeqj.de/sneak/secret/issues/74, where running `secret init`
// a second time, or `secret vault create` with the name of an existing
// vault, replaced that vault's keys, so that none of its secrets could be
// decrypted any more. Each must refuse, change nothing, and leave every
// vault's secret readable through its passphrase unlocker.
//
//nolint:paralleltest // the cases share cmd
func TestCreateExistingVaultChangesNothing(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
// newCLI returns an instance on fs given the mnemonic and the unlock
// passphrase, as from the environment
newCLI := func(fs afero.Fs) *cli.Instance {
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return c
}
// `secret init`, `secret vault create work`, `secret vault select
// default`, and the secret "x" in each vault. "work" is then not the
// current vault, which creating it again must not change.
fs := afero.NewMemMapFs()
c := newCLI(fs)
cmd := &cobra.Command{}
require.NoError(t, c.Init(cmd))
require.NoError(t, c.CreateVault(cmd, "work"))
require.NoError(t, c.SelectVault(cmd, "default"))
vaults, err := vault.ListVaults(fs, testStateDir)
require.NoError(t, err)
require.Len(t, vaults, 2)
for _, name := range vaults {
value := memguard.NewBufferFromBytes([]byte("value"))
err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false)
require.NoError(t, err)
}
before := snapshotStateDir(t, fs)
tests := []struct {
command string
want string
run func(c *cli.Instance) error
}{
{
"init",
"failed to create default vault: vault default already exists",
func(c *cli.Instance) error { return c.Init(cmd) },
},
{
"vault create default",
"vault default already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
},
{
"vault create work",
"vault work already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
fs := newFsFromSnapshot(t, before)
err := tt.run(newCLI(fs))
require.EqualError(t, err, tt.want)
require.Equal(t, before, snapshotStateDir(t, fs))
})
}
// Every case left the state directory exactly as recorded in before, so
// reading each vault's secret once from it shows that it still decrypts
// after each case. Without the mnemonic, reading a secret goes through
// the vault's passphrase unlocker, which is slow.
for _, name := range vaults {
vlt := vault.NewVault(fs, testStateDir, name)
vlt.UnlockPassphrase = passphrase
value, err := vlt.GetSecret("x")
require.NoError(t, err)
unchanged := bytes.Equal([]byte("value"), value.Bytes())
value.Destroy()
require.True(t, unchanged, "vault %q kept its secret", name)
}
}
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
// `secret vault create` put the mnemonic into the process environment,
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
// leave neither in the environment.
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
t.Setenv(secret.EnvStateDir, t.TempDir())
run := func(args ...string) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// With no terminal to prompt on, this succeeds only if the command
// read both variables
_, err := cli.ExecuteCommandInProcess(args, "", nil)
require.NoError(t, err)
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
_, set := os.LookupEnv(name)
require.False(t, set, "%s is set after %v", name, args)
}
}
run("init")
run("vault", "create", "work")
}
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
// `secret vault create` stopped at the passphrase prompt left a vault with
// no unlocker, which neither command would then create again. Each must ask
// for the passphrase before writing anything.
//
//nolint:paralleltest // the cases share cmd
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
// An empty state directory for `secret init`, and one holding the vault
// "default" for `secret vault create work`.
empty := afero.NewMemMapFs()
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
require.NoError(t, err)
cmd := &cobra.Command{}
tests := []struct {
command string
fs afero.Fs
run func(c *cli.Instance) error
}{
{
"init",
empty,
func(c *cli.Instance) error { return c.Init(cmd) },
},
{
"vault create work",
withDefault,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
before := snapshotStateDir(t, tt.fs)
// Given no unlock passphrase, both commands prompt for it, which
// fails because the tests do not run in a terminal.
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
c.Mnemonic = mnemonic
err := tt.run(c)
require.ErrorContains(t, err, "failed to read passphrase")
require.Equal(t, before, snapshotStateDir(t, tt.fs))
})
}
}