//nolint:testpackage // sets the unexported fields of Instance package cli import ( "io" "path/filepath" "strconv" "strings" "sync" "testing" "time" "git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/vault" "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // addAtOnce runs one add of the secret name per value, all at once, and // returns their errors. func addAtOnce( fs afero.Fs, stateDir, name string, force bool, values []string, ) []error { errs := make(chan error, len(values)) for _, value := range values { go func() { cli := NewCLIInstanceWithStateDir(fs, stateDir) cli.cmd = &cobra.Command{} cli.cmd.SetIn(strings.NewReader(value)) errs <- cli.AddSecret(name, force) }() } results := make([]error, 0, len(values)) for range values { results = append(results, <-errs) } return results } // numbered returns count distinct values starting with prefix. func numbered(prefix string, count int) []string { values := make([]string, 0, count) for i := range count { values = append(values, prefix+"-"+strconv.Itoa(i)) } return values } // TestConcurrentAddsKeepEveryVersion runs adds of one secret at once, on // the in-memory and on the real filesystem. Without the state directory // lock, adds of a new secret all find it absent and replace each other, and // forced adds read the same highest version number and overwrite each // other's version. With it they behave as if run one after another. // //nolint:paralleltest // t.Setenv forbids parallel subtests func TestConcurrentAddsKeepEveryVersion(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) const adds = 8 for _, tc := range []struct { name string fs afero.Fs stateDir string }{ {"memory", afero.NewMemMapFs(), testStateDir}, {"real", afero.NewOsFs(), t.TempDir()}, } { t.Run(tc.name, func(t *testing.T) { _, err := vault.CreateVault(tc.fs, tc.stateDir, "default") require.NoError(t, err) // One add creates the secret; the others find that it exists created := 0 for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false, numbered("create", adds)) { if err == nil { created++ } else { require.ErrorIs(t, err, vault.ErrSecretExists) } } require.Equal(t, 1, created, "exactly one add creates the secret") // Every forced add stores a version of its own for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true, numbered("force", adds)) { require.NoError(t, err) } vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir) require.NoError(t, err) vaultDir, err := vlt.GetDirectory() require.NoError(t, err) versions, err := secret.ListVersions(tc.fs, filepath.Join(vaultDir, "secrets.d", "shared")) require.NoError(t, err) require.Len(t, versions, adds+1, "one version per successful add") values := make(map[string]bool, len(versions)) for _, version := range versions { value, err := vlt.GetSecretVersion("shared", version) require.NoError(t, err) values[string(value)] = true } assert.Len(t, values, adds+1, "every add stored its own value") }) } } // readNotifier passes reads through to Reader and closes reading at the // first one. type readNotifier struct { io.Reader reading chan struct{} once sync.Once } func (r *readNotifier) Read(p []byte) (int, error) { r.once.Do(func() { close(r.reading) }) return r.Reader.Read(p) } // TestEncryptPipedIntoAdd runs `secret encrypt key | secret add name` in // one process, starting encrypt once add is reading its input. Had add // taken the state directory lock before reading, it would hold the lock // while waiting for encrypt's output, and encrypt would wait for the lock // to store its key: neither would finish. func TestEncryptPipedIntoAdd(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) fs := afero.NewMemMapFs() _, err := vault.CreateVault(fs, testStateDir, "default") require.NoError(t, err) require.NoError(t, afero.WriteFile(fs, "/plaintext", []byte("piped"), 0o600)) pipeReader, pipeWriter := io.Pipe() // If the test gives up, this makes add's read fail, so that both // commands return and release the lock the other tests use t.Cleanup(func() { _ = pipeReader.Close() }) const commands = 2 input := &readNotifier{Reader: pipeReader, reading: make(chan struct{})} results := make(chan error, commands) go func() { add := NewCLIInstanceWithStateDir(fs, testStateDir) add.cmd = &cobra.Command{} add.cmd.SetIn(input) results <- add.AddSecret("encrypted", false) }() go func() { <-input.reading encrypt := NewCLIInstanceWithStateDir(fs, testStateDir) encrypt.cmd = &cobra.Command{} encrypt.cmd.SetOut(pipeWriter) err := encrypt.Encrypt("key", "/plaintext", "") // Ends add's input, as the end of the pipe does _ = pipeWriter.CloseWithError(err) results <- err }() timeout := time.After(10 * time.Second) for range commands { select { case err := <-results: require.NoError(t, err) case <-timeout: t.Fatal("secret encrypt piped into secret add never finished") } } } // TestFailedCommandReleasesLock checks that a command failing after it // took the state directory lock leaves the lock free for the next command. func TestFailedCommandReleasesLock(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() cli := NewCLIInstanceWithStateDir(fs, testStateDir) // Fails once it holds the lock: there is no current vault err := cli.RemoveSecret(&cobra.Command{}, "missing", false) require.Error(t, err) taken := make(chan func(), 1) go func() { release, err := vault.LockStateDir(fs, testStateDir) if assert.NoError(t, err) { taken <- release } }() select { case release := <-taken: release() case <-time.After(10 * time.Second): t.Fatal("the failed command left the state directory locked") } }