Ask before removing a secret, version, vault or unlocker (closes #39)
check / check (push) Failing after 2s
check / check (push) Failing after 2s
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
This commit was merged in pull request #100.
This commit is contained in:
@@ -2,10 +2,13 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -16,7 +19,11 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/cli"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/creack/pty"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -2543,3 +2550,145 @@ func copyFile(src, dst string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// secretRmCommand makes a state directory whose vault "default" holds the
|
||||
// secret "x", and returns `secret rm x` on the built binary against it, and
|
||||
// the directory of "x". The vault has no unlocker, so making it derives no
|
||||
// key from a passphrase.
|
||||
func secretRmCommand(ctx context.Context, t *testing.T) (*exec.Cmd, string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("x", value, false))
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "rm", "x")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
return cmd, filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "x")
|
||||
}
|
||||
|
||||
// TestRemoveWithoutTerminalFailsAtOnce runs `secret rm` without --force,
|
||||
// with a stdin that is not a terminal and never delivers anything, as in a
|
||||
// script or a CI job. It must fail at once, telling the user to pass
|
||||
// --force, instead of waiting for an answer, and remove nothing.
|
||||
func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Nobody writes to or closes the pipe, so reading it would block for good.
|
||||
stdin, stdinWriter, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = stdinWriter.Close()
|
||||
_ = stdin.Close()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd, secretDir := secretRmCommand(ctx, t)
|
||||
cmd.Stdin = stdin
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
require.NoError(t, ctx.Err(), "secret rm waited for an answer")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, string(output), "pass --force")
|
||||
assert.DirExists(t, secretDir)
|
||||
}
|
||||
|
||||
// The next two tests run `secret rm` with a terminal on stdin or on stdout
|
||||
// and stderr, not both: whether it asks must depend on stdin alone, where
|
||||
// the answer is read from. pty.Open returns the two ends of a new terminal:
|
||||
// tty is the end a program uses as its terminal, and ptmx the end the test
|
||||
// reads what the terminal shows from and types into.
|
||||
|
||||
// TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a
|
||||
// terminal. stdin is a pipe, so nobody can answer there, and the command
|
||||
// must fail as in a script, removing nothing.
|
||||
func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd, secretDir := secretRmCommand(ctx, t)
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
cmd.Stdin = strings.NewReader("y\n")
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
// The read ends once secret rm has exited and so closed the terminal.
|
||||
shown, _ := io.ReadAll(ptmx)
|
||||
|
||||
require.Error(t, cmd.Wait())
|
||||
assert.Contains(t, string(shown), "pass --force")
|
||||
assert.DirExists(t, secretDir)
|
||||
}
|
||||
|
||||
// TestRemoveAsksAtTerminalOnStdin runs `secret rm x | cat` at a terminal.
|
||||
// It must ask on the terminal, and remove the secret when y is typed there.
|
||||
func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd, secretDir := secretRmCommand(ctx, t)
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
cmd.Stdin = tty
|
||||
// Not a file, so exec.Cmd connects stdout through a pipe.
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = tty
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !bytes.HasSuffix(shown, []byte("[y/N] ")) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "secret rm ended without asking: %s", shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString("y\n")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cmd.Wait())
|
||||
assert.NoDirExists(t, secretDir)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user