//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") }