Files
secret/internal/cli/move_test.go
T
sneak 10a709e897
check / check (push) Failing after 2s
Create a vault whole in a temporary directory, then select it (closes #105)
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
2026-10-04 18:25:43 +00:00

236 lines
7.2 KiB
Go

package cli_test
import (
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret
// onto itself deleted it, also when "work" was spelled two ways, and a failed
// move within "work" left "work" the current vault. "default" is the current
// vault in every case, and each case runs on its own copy of the state
// directory.
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t))
require.Equal(t, "default", before[testStateDir+"/currentvault"])
const (
ontoItself = "secret 'x' cannot be moved onto itself"
workX = "work:x"
)
tests := []struct {
command string
source, dest string
force bool
wantErr string
}{
{"mv x x", "x", "x", false, ontoItself},
{"mv --force x x", "x", "x", true, ontoItself},
{"mv --force work:x work:", workX, "work:", true, ontoItself},
// An empty destination name defaults to the source name.
{`mv --force work:x ""`, workX, "", true, ontoItself},
// "work" is a vault name, so the destination is work:x.
{"mv --force work:x work", workX, "work", true, ontoItself},
{
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
"secret 'nosuch' not found",
},
// Only an existing vault is used.
{
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
"vault 'nosuch' does not exist",
},
// Each of these spells "work" a second way. The spelling is not a
// valid vault name, so the move is not taken for a move between two
// vaults, which would delete the destination, here the source.
{
"mv --force work:x work/:x", workX, "work/:x", true,
vault.ValidateVaultName("work/").Error(),
},
{
"mv --force work/:x work:", "work/:x", "work:", true,
vault.ValidateVaultName("work/").Error(),
},
{
"mv --force work:x ./work:x", workX, "./work:x", true,
vault.ValidateVaultName("./work").Error(),
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := newFsFromSnapshot(t, before)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
require.Equal(t, before, snapshotStateDir(t, fs))
require.EqualError(t, err, tt.wantErr)
})
}
}
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
// leaves "default" the current vault.
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err := c.MoveSecret(&cobra.Command{}, "work:x", "work:y", false)
require.NoError(t, err)
after := snapshotStateDir(t, fs)
workSecrets := testStateDir + "/vaults.d/work/secrets.d/"
require.Equal(t, "default", after[testStateDir+"/currentvault"])
require.Contains(t, after, workSecrets+"y/")
require.NotContains(t, after, workSecrets+"x/")
}
// TestMoveOntoSameSecretUnderAnotherNameIsRejected is a regression test for
// https://git.eeqj.de/sneak/secret/issues/78: on a case-insensitive
// filesystem "Foo" and "foo" are one secret, and `secret mv --force Foo foo`
// removed the destination, which was the source. Symbolic links on the real
// filesystem give one secret two names here: in "default", "y" is a link to
// the secret "x", and the secrets.d of "other" is a link to that of
// "default", so other:x is default:x. Each move must be rejected and leave
// the secret and the links as they were.
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
t.Parallel()
const isSame = "is the same secret on this filesystem"
tests := []struct {
command string
source, dest string
force bool
wantErr string
}{
{
"mv --force y x", "y", "x", true,
"secret 'y' cannot be moved onto itself: 'x' " + isSame,
},
{
"mv --force x y", "x", "y", true,
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
},
{
"mv x y", "x", "y", false,
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
},
{
"mv --force default:x other:x", "default:x", "other:x", true,
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
isSame,
},
{
"mv default:x other", "default:x", "other", false,
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
isSame,
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
stateDir := t.TempDir()
vaultsDir := filepath.Join(stateDir, "vaults.d")
// "default" is created last, so it is the current vault.
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
require.NoError(t, err)
defaultSecrets := filepath.Join(vaultsDir, "default", "secrets.d")
otherSecrets := filepath.Join(vaultsDir, "other", "secrets.d")
link := filepath.Join(defaultSecrets, "y")
require.NoError(t, os.Symlink("x", link))
require.NoError(t, os.Remove(otherSecrets))
require.NoError(t, os.Symlink(defaultSecrets, otherSecrets))
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
moveErr := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
value, err := vlt.GetSecret("x")
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, []byte("value"), value.Bytes())
target, err := os.Readlink(link)
require.NoError(t, err)
require.Equal(t, "x", target)
target, err = os.Readlink(otherSecrets)
require.NoError(t, err)
require.Equal(t, defaultSecrets, target)
require.EqualError(t, moveErr, tt.wantErr)
})
}
}
// TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem checks that where "Foo"
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
// with "Foo".
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
require.NoError(t, err)
_, err = os.Stat(filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "foo"))
if err == nil {
t.Skip("the temporary directory is on a case-insensitive filesystem")
}
err = vlt.AddSecret("foo", memguard.NewBufferFromBytes([]byte("lower")), false)
require.NoError(t, err)
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
err = c.MoveSecret(&cobra.Command{}, "Foo", "foo", true)
require.NoError(t, err)
value, err := vlt.GetSecret("foo")
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, []byte("upper"), value.Bytes())
_, err = vlt.GetSecret("Foo")
require.ErrorIs(t, err, vault.ErrSecretNotFound)
}