check / check (push) Waiting to run
When `secret mv` copied a secret into another vault but could not remove the source, it printed a warning and the usual "Moved" line and exited 0. It now fails. If the source secret's directory is still in place, the error says the copy holds all versions and the source remains. If the source left its vault and only the temporary directory it was moved into could not be deleted, the error says the secret is moved and names that directory, which the error of RemoveDirAtomic now does. The tests are in package cli and call moveSecret, because the state directory lock refuses the wrapped filesystems that make the removal fail. Model: opus-5-5
153 lines
4.5 KiB
Go
153 lines
4.5 KiB
Go
//nolint:testpackage // white-box test of unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/secret/internal/vault"
|
|
)
|
|
|
|
// errPermissionDenied is returned by renameFailFs and removeAllFailFs in
|
|
// place of a rename or removal that succeeds.
|
|
var errPermissionDenied = errors.New("permission denied")
|
|
|
|
// renameFailFs fails every Rename of one path, as a read-only parent
|
|
// directory does.
|
|
type renameFailFs struct {
|
|
afero.Fs
|
|
|
|
path string
|
|
}
|
|
|
|
func (f *renameFailFs) Rename(oldname, newname string) error {
|
|
if oldname == f.path {
|
|
return errPermissionDenied
|
|
}
|
|
|
|
return f.Fs.Rename(oldname, newname)
|
|
}
|
|
|
|
// removeAllFailFs fails every RemoveAll of an entry of the directory dir,
|
|
// as a file in it that cannot be deleted does, and records that entry's
|
|
// path in failed.
|
|
type removeAllFailFs struct {
|
|
afero.Fs
|
|
|
|
dir string
|
|
failed string
|
|
}
|
|
|
|
func (f *removeAllFailFs) RemoveAll(path string) error {
|
|
if filepath.Dir(path) == f.dir {
|
|
f.failed = path
|
|
|
|
return errPermissionDenied
|
|
}
|
|
|
|
return f.Fs.RemoveAll(path)
|
|
}
|
|
|
|
// createCrossVaults creates in fs the vaults "work" and "default", where
|
|
// "default" holds the secret "x" with the value "value".
|
|
func createCrossVaults(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
_, err := vault.CreateVault(fs, testStateDir, "work",
|
|
testMnemonicBuffer(t), nil)
|
|
require.NoError(t, err)
|
|
|
|
source, err := vault.CreateVault(fs, testStateDir, "default",
|
|
testMnemonicBuffer(t), nil)
|
|
require.NoError(t, err)
|
|
|
|
err = source.AddSecret("x",
|
|
memguard.NewBufferFromBytes([]byte("value")), false)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// requireValue requires that the vault vaultName in fs holds secretName
|
|
// with the value createCrossVaults stored.
|
|
func requireValue(t *testing.T, fs afero.Fs, vaultName, secretName string) {
|
|
t.Helper()
|
|
|
|
vlt := vault.NewVault(fs, testStateDir, vaultName)
|
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
|
|
value, err := vlt.GetSecret(secretName)
|
|
require.NoError(t, err, vaultName+":"+secretName)
|
|
|
|
defer value.Destroy()
|
|
|
|
require.Equal(t, []byte("value"), value.Bytes())
|
|
}
|
|
|
|
// TestCrossVaultMoveFailsWhenSourceRemains is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/159, where a move between two
|
|
// vaults whose source could not be removed reported the secret moved and
|
|
// succeeded. It must fail, and the copy and the source must both be there.
|
|
// The state directory lock refuses the wrapped filesystem, so the test
|
|
// calls moveSecret, which MoveSecret runs once it holds the lock.
|
|
func TestCrossVaultMoveFailsWhenSourceRemains(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := afero.NewMemMapFs()
|
|
createCrossVaults(t, base)
|
|
|
|
instance := NewCLIInstanceWithStateDir(&renameFailFs{
|
|
Fs: base,
|
|
path: testStateDir + "/vaults.d/default/secrets.d/x",
|
|
}, testStateDir)
|
|
instance.Mnemonic = testMnemonicBuffer(t)
|
|
|
|
err := instance.moveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
|
require.ErrorIs(t, err, errPermissionDenied)
|
|
require.ErrorContains(t, err, "secret 'default:x' is copied with all "+
|
|
"1 version(s) to 'work:y', but the source remains")
|
|
|
|
requireValue(t, base, "default", "x")
|
|
requireValue(t, base, "work", "y")
|
|
}
|
|
|
|
// TestCrossVaultMoveFailsWhenOldFilesRemain makes the removal of the source
|
|
// fail after the source has been moved out of its vault, when only the
|
|
// temporary directory it was moved into cannot be deleted. The move must
|
|
// fail, say that the secret is moved, and name that directory.
|
|
func TestCrossVaultMoveFailsWhenOldFilesRemain(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := afero.NewMemMapFs()
|
|
createCrossVaults(t, base)
|
|
|
|
sourceVaultDir := testStateDir + "/vaults.d/default"
|
|
fs := &removeAllFailFs{Fs: base, dir: sourceVaultDir}
|
|
|
|
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
instance.Mnemonic = testMnemonicBuffer(t)
|
|
|
|
err := instance.moveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
|
require.ErrorIs(t, err, errPermissionDenied)
|
|
require.ErrorContains(t, err, "secret 'default:x' is moved with all "+
|
|
"1 version(s) to 'work:y', but its old files could not all be deleted")
|
|
|
|
// The error names the directory that holds the old files
|
|
require.NotEmpty(t, fs.failed)
|
|
require.ErrorContains(t, err, fs.failed)
|
|
|
|
oldFiles, err := afero.DirExists(base, filepath.Join(fs.failed, "x"))
|
|
require.NoError(t, err)
|
|
require.True(t, oldFiles)
|
|
|
|
// The source vault no longer holds the secret
|
|
remains, err := afero.DirExists(base, sourceVaultDir+"/secrets.d/x")
|
|
require.NoError(t, err)
|
|
require.False(t, remains)
|
|
|
|
requireValue(t, base, "work", "y")
|
|
}
|