check / check (push) Failing after 3s
secret rm, secret version rm, secret vault remove and secret unlocker remove ask [y/N] on a terminal, naming what they remove, and go ahead only on y or yes. Without --force, a command whose stdin is not a terminal fails at once. --force, now also on rm and version rm, removes without asking; it replaces the old refusals to remove a vault with secrets or the last unlocker without --force. The checks run and the question is asked before the state directory lock is taken; under the lock the checks run again, and nothing is removed if they would ask a different question. Model: opus-5-5
411 lines
12 KiB
Go
411 lines
12 KiB
Go
// 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)
|
|
}
|