Skip to content

Commit

Permalink
Support non-TTY Stdin for Confirmation prompts
Browse files Browse the repository at this point in the history
Being unable to pipe responses to prompts is a known shortcoming
of Survey: AlecAivazis/survey#394.
We frequently pipe 'yes' responses to CLI confirmation prompts,
and this commit supports ONLY those cases.
  • Loading branch information
john-odonnell committed Apr 19, 2023
1 parent 6e14e68 commit 05d7e09
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 25 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/creack/pty v1.1.18
github.com/cyberark/conjur-api-go v0.10.3-0.20230217154521-e01f713716d2 // Run "go get github.com/cyberark/conjur-api-go@main" to update
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
github.com/mattn/go-isatty v0.0.8
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/spf13/cobra v1.5.0
github.com/stretchr/testify v1.8.1
Expand All @@ -27,7 +28,6 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.8 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
Expand Down
26 changes: 23 additions & 3 deletions pkg/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ var initCmdTestCases = []struct {
args []string
out string
promptResponses []promptResponse
beforeTest func(t *testing.T, conjurrcInTmpDir string)
assert func(t *testing.T, conjurrcInTmpDir string, stdout string)
// Being unable to pipe responses to prompts is a known shortcoming of Survey.
// https://github.com/go-survey/survey/issues/394
// This flag is used to enable Pipe-based, and not PTY-based, tests.
pipe bool
beforeTest func(t *testing.T, conjurrcInTmpDir string)
assert func(t *testing.T, conjurrcInTmpDir string, stdout string)
}{
{
name: "help",
Expand Down Expand Up @@ -89,6 +93,7 @@ appliance_url: http://conjur
response: "N",
},
},
pipe: true,
beforeTest: func(t *testing.T, conjurrcInTmpDir string) {
os.WriteFile(conjurrcInTmpDir, []byte("something"), 0644)
},
Expand All @@ -107,6 +112,7 @@ appliance_url: http://conjur
response: "y",
},
},
pipe: true,
beforeTest: func(t *testing.T, conjurrcInTmpDir string) {
os.WriteFile(conjurrcInTmpDir, []byte("something"), 0644)
},
Expand Down Expand Up @@ -168,6 +174,7 @@ appliance_url: http://host
response: "y",
},
},
pipe: true,
assert: func(t *testing.T, conjurrcInTmpDir string, stdout string) {
assertCertWritten(t, conjurrcInTmpDir, stdout)
},
Expand All @@ -181,6 +188,7 @@ appliance_url: http://host
response: "N",
},
},
pipe: true,
assert: func(t *testing.T, conjurrcInTmpDir string, stdout string) {
fmt.Println(stdout)
assert.Contains(t, stdout, "You decided not to trust the certificate")
Expand All @@ -205,13 +213,15 @@ appliance_url: http://host
},
},
{
name: "succeeds for self-signed certificate with --self-signed flag",
args: []string{"init", "-u=https://self-signed.badssl.com", "-a=test-account", "--self-signed"},
promptResponses: []promptResponse{
{
prompt: "Trust this certificate?",
response: "y",
},
},
pipe: true,
assert: func(t *testing.T, conjurrcInTmpDir string, stdout string) {
assert.Contains(t, stdout, "Warning: Using self-signed certificates is not recommended and could lead to exposure of sensitive data")
assertCertWritten(t, conjurrcInTmpDir, stdout)
Expand Down Expand Up @@ -313,7 +323,17 @@ func TestInitCmd(t *testing.T) {
rootCmd.AddCommand(cmd)
rootCmd.SetArgs(args)

out, _ := executeCommandForTestWithPromptResponses(t, rootCmd, tc.promptResponses)
var content string
for _, pr := range tc.promptResponses {
content = fmt.Sprintf("%s%s\n", content, pr.response)
}

var out string
if tc.pipe {
out, _ = executeCommandForTestWithPipeResponses(t, rootCmd, content)
} else {
out, _ = executeCommandForTestWithPromptResponses(t, rootCmd, tc.promptResponses)
}

if tc.assert != nil {
tc.assert(t, conjurrcInTmpDir, out)
Expand Down
44 changes: 44 additions & 0 deletions pkg/cmd/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"io"
"io/ioutil"
"os"
"regexp"
"testing"
Expand Down Expand Up @@ -49,6 +50,49 @@ func executeCommandForTest(t *testing.T, c *cobra.Command, args ...string) (stri
return stripAnsi(stdoutBuf.String()), stripAnsi(stderrBuf.String()), err
}

func executeCommandForTestWithPipeResponses(
t *testing.T, cmd *cobra.Command, content string,
) (stdOut string, cmdErr error) {
t.Helper()

mockHelpText(cmd)

// Send CLI stdOut and stdErr to Pipes and consolidate
outR, outW, _ := os.Pipe()
errR, errW, _ := os.Pipe()
combinedOutReader := io.MultiReader(outR, errR)

defer func(origOut *os.File) { os.Stdout = origOut }(os.Stdout)
defer func(origErr *os.File) { os.Stderr = origErr }(os.Stderr)
os.Stdout = outW
os.Stderr = errW

// Receive CLI stdIn from Pipe
inR, inW, _ := os.Pipe()
_, err := inW.Write([]byte(content))
if err != nil {
t.Fatal(err)
}
inW.Close()

defer func(origIn *os.File) { os.Stdin = origIn }(os.Stdin)
os.Stdin = inR

// Run CLI cmd
err = cmd.Execute()

// Collect stdOut
outW.Close()
errW.Close()
out, outErr := ioutil.ReadAll(combinedOutReader)
if outErr != nil {
t.Fatal(outErr)
}

// strip ansi from stdout and stderr because we're using promptui
return stripAnsi(string(out)), err
}

func executeCommandForTestWithPromptResponses(
t *testing.T, cmd *cobra.Command, promptResponses []promptResponse,
) (stdOut string, cmdErr error) {
Expand Down
63 changes: 42 additions & 21 deletions pkg/prompts/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"fmt"
"net/url"
"os"
"strings"

"github.com/AlecAivazis/survey/v2"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)

Expand All @@ -32,46 +34,65 @@ func newAccountPrompt() *survey.Question {
}
}

func newFileExistsPrompt(filePath string) *survey.Question {
return &survey.Question{
Prompt: &survey.Confirm{Message: fmt.Sprintf("File %s exists. Overwrite?", filePath)},
Validate: survey.Required,
func stringToBool(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "y":
return true
case "yes":
return true
default:
return false
}
}

// AskToOverwriteFile presents a prompt to get confirmation from a user to overwrite a file
func AskToOverwriteFile(filePath string) error {
var userInput bool
func basicConfirm(message string) bool {
fmt.Fprintf(os.Stdout, "%s ", message)
var answer string
fmt.Scanln(&answer)
fmt.Fprintln(os.Stdout, "")
return stringToBool(answer)
}

q := newFileExistsPrompt(filePath)
err := survey.AskOne(q.Prompt, &userInput, survey.WithValidator(q.Validate), survey.WithShowCursor(true))
func confirm(message string) (bool, error) {
var err error
var userInput bool

if userInput == false {
return fmt.Errorf("Not overwriting %s", filePath)
// When handling Pipe-based Stdin, we can't use Survey to gather responses.
// https://github.com/go-survey/survey/issues/394
// Instead, use standard fmt funcs to prompt to Stdout and gather from Stdin.
if isatty.IsTerminal(os.Stdin.Fd()) {
q := &survey.Question{
Prompt: &survey.Confirm{Message: message},
Validate: survey.Required,
}
err = survey.AskOne(q.Prompt, &userInput, survey.WithValidator(q.Validate), survey.WithShowCursor(true))
} else {
userInput = basicConfirm(message)
}
return err

return userInput, err
}

func newTrustCertPrompt() *survey.Question {
return &survey.Question{
Prompt: &survey.Confirm{Message: "Trust this certificate?"},
Validate: survey.Required,
// AskToOverwriteFile presents a prompt to get confirmation from a user to overwrite a file
func AskToOverwriteFile(filePath string) error {
userInput, err := confirm(fmt.Sprintf("File %s exists. Overwrite?", filePath))

if !userInput {
return fmt.Errorf("Not overwriting %s", filePath)
}
return err
}

// AskToTrustCert presents a prompt to get confirmation from a user to trust a certificate
func AskToTrustCert(fingerprint string) error {
var userInput bool

warning := fmt.Sprintf("\nThe server's certificate fingerprint is %s.\n", fingerprint) +
"Please verify this certificate on the appliance using command:\n" +
"openssl x509 -fingerprint -noout -in ~conjur/etc/ssl/conjur.pem\n\n"
os.Stdout.Write([]byte(warning))

q := newTrustCertPrompt()
err := survey.AskOne(q.Prompt, &userInput, survey.WithValidator(q.Validate), survey.WithShowCursor(true))
userInput, err := confirm("Trust this certificate?")

if userInput == false {
if !userInput {
return errors.New("You decided not to trust the certificate.")
}
return err
Expand Down

0 comments on commit 05d7e09

Please sign in to comment.