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
389 lines
12 KiB
Go
389 lines
12 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"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, nil)
|
|
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))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
|
|
// create` killed after the passphrase prompt but before the unlocker was
|
|
// written left a vault with no unlocker, which neither command would then
|
|
// create again. After the prompt, each command changes the state directory
|
|
// only through vault.CreateVault. The test makes that call as the command
|
|
// does and records the state directory before each change it makes, and once
|
|
// after it returns: what a stop at that point leaves. Each must hold either
|
|
// no vault, and not name it current, or exactly the finished vault, which
|
|
// opens with the passphrase through its current unlocker. The command run
|
|
// again after a stop first takes the lock, which must delete what the stop
|
|
// left under a temporary name. Running the command is slow, so it runs once
|
|
// on each different state the lock leaves, and must create the vault there,
|
|
// or refuse the one there.
|
|
//
|
|
//nolint:paralleltest // commands on the in-memory filesystem share one lock
|
|
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
|
|
mnemonic := testMnemonicBuffer(t)
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
t.Cleanup(passphrase.Destroy)
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(io.Discard)
|
|
|
|
t.Run("init", func(t *testing.T) {
|
|
// From an empty state directory
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
|
|
|
|
requireStopsLeaveWholeVaultOrNone(t, fs, "default",
|
|
"failed to create default vault: vault default already exists",
|
|
mnemonic, passphrase,
|
|
func(c *cli.Instance) error { return c.Init(cmd) })
|
|
})
|
|
|
|
t.Run("vault create work", func(t *testing.T) {
|
|
// From a state directory holding the vault "default"
|
|
fs := afero.NewMemMapFs()
|
|
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
|
require.NoError(t, err)
|
|
|
|
requireStopsLeaveWholeVaultOrNone(t, fs, "work", "vault work already exists",
|
|
mnemonic, passphrase,
|
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
|
|
})
|
|
}
|
|
|
|
// requireStopsLeaveWholeVaultOrNone checks, as
|
|
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
|
|
// command run, creating the vault name on fs with mnemonic and passphrase.
|
|
// Run again where the vault is there, the command must fail with exists.
|
|
func requireStopsLeaveWholeVaultOrNone(
|
|
t *testing.T, fs afero.Fs, name, exists string,
|
|
mnemonic, passphrase *memguard.LockedBuffer,
|
|
run func(c *cli.Instance) error,
|
|
) {
|
|
t.Helper()
|
|
|
|
var stops []map[string]string
|
|
|
|
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
|
|
|
|
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
|
|
testStateDir, name, mnemonic, passphrase)
|
|
require.NoError(t, err)
|
|
record()
|
|
|
|
vaultDir := testStateDir + "/vaults.d/" + name
|
|
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
|
|
|
|
finished := entriesUnder(stops[len(stops)-1], vaultDir)
|
|
|
|
opener := vault.NewVault(fs, testStateDir, name)
|
|
opener.UnlockPassphrase = passphrase
|
|
|
|
key, err := opener.UnlockVault()
|
|
require.NoError(t, err)
|
|
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
|
|
|
|
// Each different state the command run again finds once it holds the lock
|
|
var locked []map[string]string
|
|
|
|
for i, stop := range stops {
|
|
if _, there := stop[vaultDir+"/"]; there {
|
|
require.Equal(t, finished, entriesUnder(stop, vaultDir),
|
|
"stop %d left a partial vault", i)
|
|
} else {
|
|
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
|
|
"stop %d made a missing vault current", i)
|
|
}
|
|
|
|
stopped := newFsFromSnapshot(t, stop)
|
|
release, err := vault.LockStateDir(stopped, testStateDir)
|
|
require.NoError(t, err)
|
|
release()
|
|
|
|
state := snapshotStateDir(t, stopped)
|
|
for path := range state {
|
|
require.NotContains(t, path, ".tmp-", "stop %d", i)
|
|
}
|
|
|
|
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
|
|
return maps.Equal(s, state)
|
|
}) {
|
|
locked = append(locked, state)
|
|
}
|
|
}
|
|
|
|
for _, state := range locked {
|
|
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
|
|
c.Mnemonic = mnemonic
|
|
c.UnlockPassphrase = passphrase
|
|
|
|
if _, there := state[vaultDir+"/"]; there {
|
|
require.EqualError(t, run(c), exists)
|
|
} else {
|
|
require.NoError(t, run(c))
|
|
}
|
|
}
|
|
}
|
|
|
|
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
|
|
// that are under dir.
|
|
func entriesUnder(tree map[string]string, dir string) map[string]string {
|
|
entries := map[string]string{}
|
|
|
|
for path, content := range tree {
|
|
if strings.HasPrefix(path, dir+"/") {
|
|
entries[path] = content
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
// hookFs passes every call through to Fs, but first calls before for each
|
|
// call that can change the filesystem.
|
|
type hookFs struct {
|
|
afero.Fs
|
|
|
|
before func()
|
|
}
|
|
|
|
//nolint:ireturn // implements afero.Fs
|
|
func (h hookFs) Create(name string) (afero.File, error) {
|
|
h.before()
|
|
|
|
return h.Fs.Create(name)
|
|
}
|
|
|
|
//nolint:ireturn // implements afero.Fs
|
|
func (h hookFs) OpenFile(
|
|
name string, flag int, perm os.FileMode,
|
|
) (afero.File, error) {
|
|
h.before()
|
|
|
|
return h.Fs.OpenFile(name, flag, perm)
|
|
}
|
|
|
|
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
|
h.before()
|
|
|
|
return h.Fs.Mkdir(name, perm)
|
|
}
|
|
|
|
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
|
h.before()
|
|
|
|
return h.Fs.MkdirAll(path, perm)
|
|
}
|
|
|
|
func (h hookFs) Remove(name string) error {
|
|
h.before()
|
|
|
|
return h.Fs.Remove(name)
|
|
}
|
|
|
|
func (h hookFs) RemoveAll(path string) error {
|
|
h.before()
|
|
|
|
return h.Fs.RemoveAll(path)
|
|
}
|
|
|
|
func (h hookFs) Rename(oldname, newname string) error {
|
|
h.before()
|
|
|
|
return h.Fs.Rename(oldname, newname)
|
|
}
|