check / check (push) Failing after 2s
secret rm, secret version rm, secret vault remove and secret unlocker remove ask [y/N] on a terminal, naming what they remove, and go ahead only on y or yes. Without --force, a command whose stdin is not a terminal fails at once. --force, now also on rm and version rm, removes without asking; it replaces the old refusals to remove a vault with secrets or the last unlocker without --force. The checks run and the question is asked before the state directory lock is taken; under the lock the checks run again, and nothing is removed if they would ask a different question. Model: opus-5-5
438 lines
14 KiB
Go
438 lines
14 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
// testStateDir is the in-memory state directory of the test vaults.
|
|
testStateDir = "/test/state"
|
|
|
|
// testPassphrase protects the passphrase unlocker of each test vault.
|
|
testPassphrase = "test-passphrase"
|
|
|
|
// testVersion is a version name in the format the vault uses.
|
|
testVersion = "20260101.001"
|
|
|
|
// missingFile is an import source that does not exist, so an import
|
|
// that opened it before checking the name would fail with another error.
|
|
missingFile = "/no/such/file"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
|
|
// Creating a passphrase unlocker is slow by design, so the vaults are made
|
|
// once, by the first test that needs them.
|
|
//
|
|
//nolint:gochecknoglobals // shared by the tests that use newTwoVaultFs
|
|
var (
|
|
twoVaultsOnce sync.Once
|
|
twoVaults map[string]string
|
|
)
|
|
|
|
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
|
// and "default", the current one. Each holds the secret "x" and a
|
|
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
|
|
// Every call returns a new copy of the same vaults.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newTwoVaultFs(t *testing.T) afero.Fs {
|
|
t.Helper()
|
|
|
|
twoVaultsOnce.Do(func() {
|
|
fs := afero.NewMemMapFs()
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
for _, name := range []string{"work", "default"} {
|
|
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
|
|
require.NoError(t, err)
|
|
|
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
|
require.NoError(t, err)
|
|
|
|
_, err = vlt.CreatePassphraseUnlocker(
|
|
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
twoVaults = snapshotStateDir(t, fs)
|
|
})
|
|
|
|
require.NotNil(t, twoVaults, "making the vaults failed in an earlier test")
|
|
|
|
return newFsFromSnapshot(t, twoVaults)
|
|
}
|
|
|
|
// snapshotStateDir maps every file under the state directory to its
|
|
// contents, and every directory, written with a trailing "/", to "". Two
|
|
// snapshots are equal only if nothing in it was added, removed or changed.
|
|
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
|
t.Helper()
|
|
|
|
tree := map[string]string{}
|
|
|
|
err := afero.Walk(fs, testStateDir, func(
|
|
path string, info os.FileInfo, err error,
|
|
) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
tree[path+"/"] = ""
|
|
|
|
return nil
|
|
}
|
|
|
|
content, err := afero.ReadFile(fs, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tree[path] = string(content)
|
|
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return tree
|
|
}
|
|
|
|
// newFsFromSnapshot returns a new in-memory filesystem holding exactly the
|
|
// directories and files recorded by snapshotStateDir.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// In sorted order every directory comes before its contents.
|
|
for _, path := range slices.Sorted(maps.Keys(tree)) {
|
|
dir, isDir := strings.CutSuffix(path, "/")
|
|
if isDir {
|
|
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
|
|
|
continue
|
|
}
|
|
|
|
err := afero.WriteFile(fs, path, []byte(tree[path]), secret.FilePerms)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
return fs
|
|
}
|
|
|
|
// requireRejectedAndUnchanged runs a command on a copy of the state
|
|
// directory recorded in before. It requires an error with exactly the
|
|
// message of want, so that a later check rejecting the argument does not
|
|
// count, and everything under the state directory as it was: the error
|
|
// alone proves nothing, since it could come after the vault had already
|
|
// been deleted.
|
|
func requireRejectedAndUnchanged(
|
|
t *testing.T, before map[string]string, want error,
|
|
run func(c *cli.Instance) error,
|
|
) {
|
|
t.Helper()
|
|
|
|
fs := newFsFromSnapshot(t, before)
|
|
|
|
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
|
|
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
require.EqualError(t, err, want.Error())
|
|
}
|
|
|
|
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
|
|
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
|
|
// Removals, moves and imports use --force, so that only the name check
|
|
// stands in the way.
|
|
//
|
|
//nolint:paralleltest // the cases share cmd
|
|
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|
// Creating a passphrase unlocker is slow by design, so the vaults are
|
|
// created once and each case runs on its own copy of them.
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
|
|
vaultDir := testStateDir + "/vaults.d/default"
|
|
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
|
require.Contains(t, before, vaultDir+"/current-unlocker")
|
|
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
|
|
|
cmd := &cobra.Command{}
|
|
|
|
tests := []struct {
|
|
command string
|
|
rejected string // the secret name the command must reject
|
|
run func(c *cli.Instance) error
|
|
}{
|
|
{"rm --force ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "..", true)
|
|
}},
|
|
{"rm --force .", ".", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, ".", true)
|
|
}},
|
|
{`rm --force ""`, "", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "", true)
|
|
}},
|
|
{"rm --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "../../etc", true)
|
|
}},
|
|
{"mv --force .. x", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "..", "x", true)
|
|
}},
|
|
{"mv --force x ..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "x", "..", true)
|
|
}},
|
|
{`mv --force x ""`, "", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "x", "", true)
|
|
}},
|
|
// "work" is not the current vault: a move within it must not
|
|
// select it when a name is rejected.
|
|
{"mv --force work:.. work:x", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "work:..", "work:x", true)
|
|
}},
|
|
{"mv --force work:x work:..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "work:x", "work:..", true)
|
|
}},
|
|
{"mv --force default:.. work", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:..", "work", true)
|
|
}},
|
|
{"mv --force default:.. work:y", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:..", "work:y", true)
|
|
}},
|
|
{"mv --force default:x work:..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:x", "work:..", true)
|
|
}},
|
|
{"import --force ..", "..", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, "..", missingFile, true)
|
|
}},
|
|
{"import --force .", ".", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, ".", missingFile, true)
|
|
}},
|
|
{"import --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, "../../etc", missingFile, true)
|
|
}},
|
|
{"version list ..", "..", func(c *cli.Instance) error {
|
|
return c.ListVersions(cmd, "..")
|
|
}},
|
|
{"version promote ..", "..", func(c *cli.Instance) error {
|
|
return c.PromoteVersion(cmd, "..", testVersion)
|
|
}},
|
|
{"version rm --force ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveVersion(cmd, "..", testVersion, true)
|
|
}},
|
|
{"encrypt ..", "..", func(c *cli.Instance) error {
|
|
return c.Encrypt("..", "", "")
|
|
}},
|
|
{"decrypt ..", "..", func(c *cli.Instance) error {
|
|
return c.Decrypt("..", "", "")
|
|
}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.command, func(t *testing.T) {
|
|
requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(tt.rejected), tt.run)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInvalidVersionLeavesVaultsUnchanged is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/67, where
|
|
// `secret version rm x ../../..` deleted the whole vault,
|
|
// `secret version rm x ..` the secret x, and `secret version rm x .` or
|
|
// `secret version rm x ""` every version of x. A version argument is
|
|
// accepted only if it is one of the versions `secret version list` lists.
|
|
//
|
|
//nolint:paralleltest // the cases share cmd
|
|
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
|
|
cmd := &cobra.Command{}
|
|
|
|
commands := []struct {
|
|
command string
|
|
run func(c *cli.Instance, version string) error
|
|
}{
|
|
{"version rm --force x", func(c *cli.Instance, version string) error {
|
|
return c.RemoveVersion(cmd, "x", version, true)
|
|
}},
|
|
{"version promote x", func(c *cli.Instance, version string) error {
|
|
return c.PromoteVersion(cmd, "x", version)
|
|
}},
|
|
{"get x --version", func(c *cli.Instance, version string) error {
|
|
return c.GetSecretWithVersion(cmd, "x", version)
|
|
}},
|
|
}
|
|
|
|
for _, tt := range commands {
|
|
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
|
|
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
|
|
want := fmt.Errorf("version '%s' %w '%s'",
|
|
version, vault.ErrVersionNotFound, "x")
|
|
requireRejectedAndUnchanged(t, before, want,
|
|
func(c *cli.Instance) error { return tt.run(c, version) })
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/68, where
|
|
// `secret vault import ..` wrote a long-term key and an unlocker into the
|
|
// state directory itself, and `secret vault select ..` made it the current
|
|
// vault. Each command that takes a vault name must reject an invalid one
|
|
// before building a path from it. The instance is given the mnemonic and
|
|
// the passphrase, and moves and removals use --force, so that only the name
|
|
// check stands in the way.
|
|
//
|
|
//nolint:paralleltest // the cases share cmd
|
|
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
|
|
mnemonic := testMnemonicBuffer(t)
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
t.Cleanup(passphrase.Destroy)
|
|
|
|
cmd := &cobra.Command{}
|
|
|
|
// Each command is a format with %q where the vault name goes.
|
|
commands := []struct {
|
|
command string
|
|
run func(c *cli.Instance, name string) error
|
|
}{
|
|
{"vault create %q", func(c *cli.Instance, name string) error {
|
|
return c.CreateVault(cmd, name)
|
|
}},
|
|
{"vault import %q", func(c *cli.Instance, name string) error {
|
|
return c.VaultImport(cmd, name)
|
|
}},
|
|
{"vault select %q", func(c *cli.Instance, name string) error {
|
|
return c.SelectVault(cmd, name)
|
|
}},
|
|
{"vault remove --force %q", func(c *cli.Instance, name string) error {
|
|
return c.RemoveVault(cmd, name, true)
|
|
}},
|
|
{"mv --force %q:x work:x", func(c *cli.Instance, name string) error {
|
|
return c.MoveSecret(cmd, name+":x", "work:x", true)
|
|
}},
|
|
{"mv --force default:x %q:x", func(c *cli.Instance, name string) error {
|
|
return c.MoveSecret(cmd, "default:x", name+":x", true)
|
|
}},
|
|
}
|
|
|
|
for _, tt := range commands {
|
|
for _, name := range []string{"", ".", "..", "a/b"} {
|
|
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
|
|
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
|
|
func(c *cli.Instance) error {
|
|
c.Mnemonic = mnemonic
|
|
c.UnlockPassphrase = passphrase
|
|
|
|
return tt.run(c, name)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRemoveVersionRemovesOnlyThatVersion checks that
|
|
// `secret version rm --force` with a version that is not the current one
|
|
// removes that version and changes nothing else.
|
|
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newTwoVaultFs(t)
|
|
|
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
require.NoError(t, err)
|
|
|
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
|
|
// A second version of "x" becomes the current one.
|
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
|
|
require.NoError(t, err)
|
|
|
|
secretDir := testStateDir + "/vaults.d/default/secrets.d/x"
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
// ListVersions lists the newest version first.
|
|
oldDir := secretDir + "/versions/" + versions[1] + "/"
|
|
before := snapshotStateDir(t, fs)
|
|
require.Contains(t, before, oldDir)
|
|
|
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1], true)
|
|
require.NoError(t, err)
|
|
|
|
// Expected: the state as before without everything under oldDir.
|
|
want := map[string]string{}
|
|
|
|
for path, content := range before {
|
|
if !strings.HasPrefix(path, oldDir) {
|
|
want[path] = content
|
|
}
|
|
}
|
|
|
|
require.Equal(t, want, snapshotStateDir(t, fs))
|
|
}
|
|
|
|
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
|
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
|
// in the current vault and changes nothing else.
|
|
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
fs := newFsFromSnapshot(t, before)
|
|
|
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
err := c.MoveSecret(&cobra.Command{}, "x", "work", false)
|
|
require.NoError(t, err)
|
|
|
|
// Expected: the state as before, with everything under the current
|
|
// vault's secrets.d/x/ now under secrets.d/work/.
|
|
oldDir := testStateDir + "/vaults.d/default/secrets.d/x/"
|
|
newDir := testStateDir + "/vaults.d/default/secrets.d/work/"
|
|
want := map[string]string{}
|
|
|
|
for path, content := range before {
|
|
rest, found := strings.CutPrefix(path, oldDir)
|
|
if found {
|
|
path = newDir + rest
|
|
}
|
|
|
|
want[path] = content
|
|
}
|
|
|
|
require.Contains(t, want, newDir)
|
|
require.Equal(t, want, snapshotStateDir(t, fs))
|
|
}
|