// Confirmation Tests // // `secret rm`, `secret version rm`, `secret vault remove` and // `secret unlocker remove` ask the user to confirm on a terminal, naming // what they are about to remove, and remove it only on y or yes. --force // skips the question. Without --force, a command whose stdin is not a // terminal fails at once, since nobody is there to answer. // // The tests answer through Instance.terminal, which stands in for a // terminal. Without it, whether stdin is a terminal decides; the tests in // integration_test.go that run `secret rm` on a pseudo-terminal cover that. //nolint:testpackage // sets the unexported terminal field of Instance package cli import ( "bufio" "bytes" "fmt" "io" "os" "path/filepath" "strings" "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" ) const ( // confirmTestSecret is the secret the tests remove, or remove a // version of, in the vault "work". confirmTestSecret = "test/secret" // lastUnlockerRemoval names the case that removes the only unlocker. lastUnlockerRemoval = "unlocker rm, the last one" ) // removal is one removal command, set up on its own state directory. type removal struct { fs afero.Fs run func(cli *Instance, cmd *cobra.Command, force bool) error // removed is the directory the command removes. removed string // question is the question the command asks. question string } // newConfirmTestVaults returns an in-memory state directory with the // vaults "other" and "work", the current one. "work" holds two versions of // confirmTestSecret and the given number of PGP unlockers. It returns the // directory of "work" and the older version. func newConfirmTestVaults( t *testing.T, unlockers int, ) (*afero.MemMapFs, string, string) { t.Helper() fs := &afero.MemMapFs{} mnemonic := testMnemonicBuffer(t) _, err := vault.CreateVault(fs, testStateDir, "other", mnemonic) require.NoError(t, err) vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic) require.NoError(t, err) addTestSecret(t, vlt, []byte("older"), false) addTestSecret(t, vlt, []byte("newer"), true) vaultDir, err := vlt.GetDirectory() require.NoError(t, err) versions, err := secret.ListVersions(fs, filepath.Join(vaultDir, "secrets.d", "test%secret")) require.NoError(t, err) require.Len(t, versions, 2) for i := range unlockers { writePGPUnlocker(t, fs, filepath.Join(vaultDir, "unlockers.d"), fmt.Sprintf("pgp-%d", i), time.Date(2026, time.October, 4, 12, i, 0, 0, time.UTC), listTestGPGKeyID+string(rune('A'+i))) } // ListVersions lists the newest version first. return fs, vaultDir, versions[1] } // newRemoval sets up the removal the command names. func newRemoval(t *testing.T, command string) removal { t.Helper() unlockers := 2 if command == lastUnlockerRemoval { unlockers = 1 } fs, workDir, older := newConfirmTestVaults(t, unlockers) unlockerID := "pgp-" + listTestGPGKeyID + "A" removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error { return cli.UnlockersRemove(unlockerID, force, cmd) } switch command { case "rm": return removal{ fs: fs, run: func(cli *Instance, cmd *cobra.Command, force bool) error { return cli.RemoveSecret(cmd, confirmTestSecret, force) }, removed: filepath.Join(workDir, "secrets.d", "test%secret"), question: "Permanently remove secret 'test/secret' and its 2 " + "version(s) from vault 'work'?", } case "version rm": return removal{ fs: fs, run: func(cli *Instance, cmd *cobra.Command, force bool) error { return cli.RemoveVersion(cmd, confirmTestSecret, older, force) }, removed: filepath.Join( workDir, "secrets.d", "test%secret", "versions", older), question: "Permanently remove version " + older + " of secret 'test/secret' from vault 'work'?", } case "vault rm": return removal{ fs: fs, run: func(cli *Instance, cmd *cobra.Command, force bool) error { return cli.RemoveVault(cmd, "work", force) }, removed: workDir, question: "Permanently remove vault 'work' and its 1 secret(s)?", } case "unlocker rm": return removal{ fs: fs, run: removeFirstUnlocker, removed: filepath.Join(workDir, "unlockers.d", "pgp-0"), question: "Permanently remove unlocker '" + unlockerID + "' from vault 'work'? It is not the vault's last unlocker.", } case lastUnlockerRemoval: return removal{ fs: fs, run: removeFirstUnlocker, removed: filepath.Join(workDir, "unlockers.d", "pgp-0"), question: "Permanently remove unlocker '" + unlockerID + "', the last unlocker of vault 'work', which holds 1 " + "secret(s)? Without an unlocker the vault opens only " + "with its mnemonic.", } } t.Fatalf("no removal %q", command) return removal{} } // removalCommands lists the commands newRemoval sets up. func removalCommands() []string { return []string{ "rm", "version rm", "vault rm", "unlocker rm", lastUnlockerRemoval, } } // newConfirmTestCommand returns a command whose output is discarded and // whose stderr, where the question goes, is the returned buffer. func newConfirmTestCommand() (*cobra.Command, *bytes.Buffer) { var stderr bytes.Buffer cmd := &cobra.Command{} cmd.SetOut(io.Discard) cmd.SetErr(&stderr) return cmd, &stderr } // requireExists asserts whether the directory dir exists. func requireExists(t *testing.T, fs afero.Fs, dir string, want bool) { t.Helper() exists, err := afero.DirExists(fs, dir) require.NoError(t, err) require.Equal(t, want, exists, dir) } // TestConfirmAnswers checks which answers confirm accepts: y or yes, in // any case, around which spaces do not matter. func TestConfirmAnswers(t *testing.T) { t.Parallel() for answer, want := range map[string]error{ "y\n": nil, "Y\n": nil, "yes\n": nil, " YES \n": nil, "y": nil, "\n": errNotConfirmed, "": errNotConfirmed, "n\n": errNotConfirmed, "yy\n": errNotConfirmed, "no\ny\n": errNotConfirmed, } { t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) { t.Parallel() cli := &Instance{terminal: strings.NewReader(answer)} cmd, stderr := newConfirmTestCommand() err := cli.confirm(cmd, "Remove it?") require.ErrorIs(t, err, want) assert.Equal(t, "Remove it? [y/N] ", stderr.String()) }) } } // TestRemovalAnsweredYesRemoves checks that each removal asks its question // and removes what it names when the user answers y. func TestRemovalAnsweredYesRemoves(t *testing.T) { t.Parallel() for _, command := range removalCommands() { t.Run(command, func(t *testing.T) { t.Parallel() r := newRemoval(t, command) requireExists(t, r.fs, r.removed, true) cli := NewCLIInstanceWithStateDir(r.fs, testStateDir) cli.terminal = strings.NewReader("y\n") cmd, stderr := newConfirmTestCommand() require.NoError(t, r.run(cli, cmd, false)) assert.Equal(t, r.question+" [y/N] ", stderr.String()) requireExists(t, r.fs, r.removed, false) }) } } // TestRemovalDeclinedLeavesEverything checks that each removal changes // nothing when the user answers anything but y or yes, a bare Enter // included. func TestRemovalDeclinedLeavesEverything(t *testing.T) { t.Parallel() for _, command := range removalCommands() { for _, answer := range []string{"\n", "n\n", ""} { t.Run(fmt.Sprintf("%s %q", command, answer), func(t *testing.T) { t.Parallel() r := newRemoval(t, command) before := stateDirModTimes(t, r.fs) cli := NewCLIInstanceWithStateDir(r.fs, testStateDir) cli.terminal = strings.NewReader(answer) cmd, stderr := newConfirmTestCommand() err := r.run(cli, cmd, false) require.ErrorIs(t, err, errNotConfirmed) assert.Equal(t, r.question+" [y/N] ", stderr.String()) assert.Equal(t, before, stateDirModTimes(t, r.fs)) }) } } } // TestRemovalForcedAsksNothing checks that each removal with --force // removes what it would have named without asking, and without reading // its input, which is not a terminal. func TestRemovalForcedAsksNothing(t *testing.T) { t.Parallel() for _, command := range removalCommands() { t.Run(command, func(t *testing.T) { t.Parallel() r := newRemoval(t, command) input := strings.NewReader("n\n") cli := NewCLIInstanceWithStateDir(r.fs, testStateDir) cmd, stderr := newConfirmTestCommand() cmd.SetIn(input) require.NoError(t, r.run(cli, cmd, true)) assert.Empty(t, stderr.String(), "asked with --force") assert.Equal(t, 2, input.Len(), "read its input with --force") requireExists(t, r.fs, r.removed, false) }) } } // TestRemovalWithoutTerminalFailsAtOnce checks that each removal without // --force, whose input is not a terminal, fails at once telling the user // to pass --force, and changes nothing. The input is a pipe that nobody // writes to or closes, so reading it would block for good. func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) { t.Parallel() for _, command := range removalCommands() { t.Run(command, func(t *testing.T) { t.Parallel() r := newRemoval(t, command) before := stateDirModTimes(t, r.fs) input, inputWriter, err := os.Pipe() require.NoError(t, err) t.Cleanup(func() { _ = inputWriter.Close() _ = input.Close() }) cli := NewCLIInstanceWithStateDir(r.fs, testStateDir) cmd, stderr := newConfirmTestCommand() cmd.SetIn(input) done := make(chan error, 1) go func() { done <- r.run(cli, cmd, false) }() select { case err := <-done: require.ErrorIs(t, err, errNoTerminal) assert.Contains(t, err.Error(), "pass --force") case <-time.After(lockWait): // Closing the pipe ends the read, and frees the lock if // the command holds it. _ = inputWriter.Close() t.Fatal("waited for an answer on input that is not a terminal") } assert.Empty(t, stderr.String(), "asked without a terminal") assert.Equal(t, before, stateDirModTimes(t, r.fs)) }) } } // TestRemovalAsksWithoutHoldingLock checks that while `secret rm` waits // for its answer, another command can take the state directory lock and // change the secret, and that the removal then removes nothing, since the // secret is no longer what the question named. func TestRemovalAsksWithoutHoldingLock(t *testing.T) { t.Parallel() r := newRemoval(t, "rm") answers, answerWriter := io.Pipe() questions, questionWriter := io.Pipe() // Closing the answers ends the read if the test fails while the // command waits for one. t.Cleanup(func() { _ = answerWriter.Close() }) rm := NewCLIInstanceWithStateDir(r.fs, testStateDir) rm.terminal = answers cmd := &cobra.Command{} cmd.SetOut(io.Discard) cmd.SetErr(questionWriter) done := make(chan error, 1) go func() { done <- r.run(rm, cmd, false) }() question, err := bufio.NewReader(questions).ReadString(']') require.NoError(t, err) require.Equal(t, r.question+" [y/N]", question) // Adds a third version while rm waits for its answer. add := NewCLIInstanceWithStateDir(r.fs, testStateDir) add.Mnemonic = testMnemonicBuffer(t) add.cmd = &cobra.Command{} add.cmd.SetIn(strings.NewReader("newest")) add.cmd.SetOut(io.Discard) added := make(chan error, 1) go func() { added <- add.AddSecret(confirmTestSecret, true) }() select { case err := <-added: require.NoError(t, err) case <-time.After(lockWait): t.Fatal("secret add waited for the lock while secret rm asked") } _, err = answerWriter.Write([]byte("y\n")) require.NoError(t, err) select { case err := <-done: require.ErrorIs(t, err, errChangedWhileAsking) case <-time.After(lockWait): t.Fatal("secret rm did not finish once answered") } requireExists(t, r.fs, r.removed, true) }