check / check (push) Successful in 1m30s
version rm, version promote and get --version joined the version argument into a path unchecked, so "", ".", "..", "../../.." removed or read every version, the secret, the vault or directories above it. A version is now accepted only if it is one of the versions ListVersions lists for the secret, compared by name before any path is built (secret.VersionExists, used by all three). An empty --version is rejected instead of meaning the current version: GetSecretVersion no longer treats "" as current, and GetSecret looks the current version up itself. Model: opus-5-5
349 lines
11 KiB
Go
349 lines
11 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
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"
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newTwoVaultFs(t *testing.T) afero.Fs {
|
|
t.Helper()
|
|
|
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
for _, name := range []string{"work", "default"} {
|
|
vlt, err := vault.CreateVault(fs, testStateDir, name)
|
|
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)
|
|
}
|
|
|
|
return fs
|
|
}
|
|
|
|
// 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.
|
|
// Moves and imports use --force, so that only the name check stands in
|
|
// the way.
|
|
//
|
|
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
|
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+"/unlockers.d/passphrase/")
|
|
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 ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "..", false)
|
|
}},
|
|
{"rm .", ".", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, ".", false)
|
|
}},
|
|
{`rm ""`, "", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "", false)
|
|
}},
|
|
{"rm ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "../../etc", false)
|
|
}},
|
|
{"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 ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveVersion(cmd, "..", testVersion)
|
|
}},
|
|
{"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 // newTwoVaultFs uses t.Setenv
|
|
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 x", func(c *cli.Instance, version string) error {
|
|
return c.RemoveVersion(cmd, "x", version)
|
|
}},
|
|
{"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) })
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
|
|
// with a version that is not the current one removes that version and
|
|
// changes nothing else.
|
|
//
|
|
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
|
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
|
fs := newTwoVaultFs(t)
|
|
|
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
require.NoError(t, err)
|
|
|
|
// 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])
|
|
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.
|
|
//
|
|
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
|
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
|
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))
|
|
}
|