check / check (push) Failing after 3s
internal/cli's copies of vault.ErrSecretNotFound, ErrVaultNotFound, ErrVersionNotFound and ErrSecretExists are removed; the commands wrap the vault errors. errUnsupportedUnlockerType is removed for errInvalidUnlockerType, which names the same failure. Every error of secret.ReadPassphrase wraps ErrPassphraseNotRead, so its callers no longer add those words. ResolveGPGKeyFingerprint returns ErrGPGKeyNotFound for a key the keyring lacks, recognised by gpg's status line. storeInKeychain returns errNilDataBuffer. bip85's ErrPasswordTooShort and ErrEncodedTooShort go with their unreachable checks. Tests that matched these errors' text use errors.Is. Model: opus-5-5
261 lines
7.8 KiB
Go
261 lines
7.8 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"
|
|
)
|
|
|
|
// internal/cli declares these errors itself and does not export them, so
|
|
// only their text can be compared.
|
|
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},
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
|
|
missing := []struct {
|
|
command string
|
|
source, dest string
|
|
force bool
|
|
want error
|
|
}{
|
|
{
|
|
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
|
|
vault.ErrSecretNotFound,
|
|
},
|
|
// Only an existing vault is used.
|
|
{
|
|
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
|
|
vault.ErrVaultNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range missing {
|
|
t.Run(tt.command, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
requireRejectedAndUnchanged(t, before, tt.want, func(c *cli.Instance) error {
|
|
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
|
})
|
|
})
|
|
}
|
|
|
|
// 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.
|
|
invalidNames := []struct{ source, dest string }{
|
|
{workX, "work/:x"},
|
|
{"work/:x", "work:"},
|
|
{workX, "./work:x"},
|
|
}
|
|
|
|
for _, tt := range invalidNames {
|
|
t.Run("mv --force "+tt.source+" "+tt.dest, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
|
|
func(c *cli.Instance) error {
|
|
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, true)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|