check / check (push) Successful in 1m13s
`secret rm ..` resolved to the vault directory and deleted the whole vault; `secret rm .` and `secret rm ""` deleted every secret. rm, mv and import built paths from the name without checking it, and so did the version commands, encrypt and decrypt. vault.ValidateSecretName wraps the existing name rule and returns ErrInvalidSecretName. Each of those commands calls it on the name as given, both names for a move, before building any path. AddSecret and GetSecretVersion use it too, so the rule has one implementation. The regression test snapshots every file under the state directory of two in-memory vaults and requires it unchanged after each rejected command. Model: opus-5-5
183 lines
5.3 KiB
Go
183 lines
5.3 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"os"
|
|
"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
|
|
}
|
|
|
|
// requireRejectedAndUnchanged runs a command against the two test vaults
|
|
// and requires that it fails with vault.ErrInvalidSecretName and leaves
|
|
// everything under the state directory as it was. The error alone proves
|
|
// nothing: it could be returned after the vault had already been deleted.
|
|
func requireRejectedAndUnchanged(t *testing.T, run func(c *cli.Instance) error) {
|
|
t.Helper()
|
|
|
|
fs := newTwoVaultFs(t)
|
|
before := snapshotStateDir(t, fs)
|
|
|
|
vaultDir := testStateDir + "/vaults.d/default"
|
|
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
|
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
|
|
|
|
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
|
|
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
require.ErrorIs(t, err, vault.ErrInvalidSecretName)
|
|
}
|
|
|
|
// 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 // subtests use t.Setenv via newTwoVaultFs
|
|
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|
cmd := &cobra.Command{}
|
|
|
|
tests := []struct {
|
|
command string
|
|
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", 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 default:.. work", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:..", "work", 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", 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, tt.run)
|
|
})
|
|
}
|
|
}
|