check / check (push) Failing after 1s
Tests that asserted a failure by a fragment of its message now use errors.Is: a refactor returning the wrong error, or wrapping with %v instead of %w, now fails them. New tests return each exported error of internal/vault and pkg/bip85 that no test returned, and check wrapped causes (os.ErrNotExist, ErrMnemonicMismatch through GetSecret, ErrInvalidPathComponent through DeriveBIP85Entropy). The 999-versions test moves into package secret to name its unexported error. Checks of errors no test can name keep their text; they are listed on the issue. Model: opus-5-5
383 lines
11 KiB
Go
383 lines
11 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
|
|
run func(c *cli.Instance) error
|
|
}{
|
|
{
|
|
"init",
|
|
func(c *cli.Instance) error { return c.Init(cmd) },
|
|
},
|
|
{
|
|
"vault create default",
|
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
|
|
},
|
|
{
|
|
"vault create work",
|
|
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.ErrorIs(t, err, vault.ErrVaultExists)
|
|
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", 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", 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
|
|
// vault.ErrVaultExists.
|
|
func requireStopsLeaveWholeVaultOrNone(
|
|
t *testing.T, fs afero.Fs, name 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.ErrorIs(t, run(c), vault.ErrVaultExists)
|
|
} 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)
|
|
}
|