diff --git a/cmd/deploycreate_test.go b/cmd/deploycreate_test.go new file mode 100644 index 0000000..d2f3c06 --- /dev/null +++ b/cmd/deploycreate_test.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/render-oss/cli/pkg/client" + "github.com/render-oss/cli/pkg/command" + "github.com/render-oss/cli/pkg/dependencies" +) + +func TestDeployCreateJSONConfirmation(t *testing.T) { + for _, tty := range []bool{false, true} { + name := "non-TTY" + if tty { + name = "TTY" + } + t.Run(name, func(t *testing.T) { + for _, tt := range []struct { + name string + input string + confirm bool + wantEOF bool + }{ + {name: "closed stdin", wantEOF: true}, + {name: "confirmed prompt", input: "y\n"}, + {name: "confirm flag", confirm: true}, + } { + t.Run(tt.name, func(t *testing.T) { + // Use a focused server to observe exactly when the deploy POST occurs; + // the shared fake currently only supports listing deploys. + var requests []string + var deployBody client.CreateDeployJSONRequestBody + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /services/srv-test": + _, _ = io.WriteString(w, `{"id":"srv-test","name":"test-service"}`) + case "POST /services/srv-test/deploys": + if err := json.NewDecoder(r.Body).Decode(&deployBody); err != nil { + t.Errorf("invalid deploy body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":"dep-test","status":"queued"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + t.Setenv("RENDER_CLI_CONFIG_PATH", newTestConfigPath(t)) + t.Setenv("RENDER_HOST", server.URL) + t.Setenv("RENDER_API_KEY", "test-api-key") + c, err := client.NewClientWithResponses(server.URL) + require.NoError(t, err) + deps := dependencies.New(c) + deps.DetectRuntimeSignals = func() (command.RuntimeSignals, error) { + return command.RuntimeSignals{StdinTTY: tty, StdoutTTY: tty, StderrTTY: tty}, nil + } + root := newRootCmd() + create := &cobra.Command{Use: "create [serviceID]", RunE: deployCreateCmd.RunE} + create.Flags().String("commit", "", "") + create.Flags().String("image", "", "") + create.Flags().Bool("clear-cache", false, "") + create.Flags().Bool("wait", false, "") + deploys := &cobra.Command{Use: "deploys"} + deploys.AddCommand(create) + root.AddCommand(deploys) + setupRootCmdPersistentRun(root, deps) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetIn(strings.NewReader(tt.input)) + args := []string{"deploys", "create", "srv-test", "--commit", "0123abcd", "--output", "json"} + if tt.confirm { + args = append(args, "--confirm") + } + root.SetArgs(args) + + err = root.Execute() + if tt.wantEOF { + require.ErrorIs(t, err, io.EOF) + assert.Contains(t, err.Error(), "--confirm") + assert.Empty(t, stdout.String()) + assert.Equal(t, []string{"GET /services/srv-test"}, requests) + return + } + require.NoError(t, err) + assert.JSONEq(t, `{"id":"dep-test","status":"queued"}`, stdout.String()) + assert.Equal(t, "POST /services/srv-test/deploys", requests[len(requests)-1]) + require.NotNil(t, deployBody.CommitId) + assert.Equal(t, "0123abcd", *deployBody.CommitId) + if tt.confirm { + assert.Empty(t, stderr.String()) + } else { + assert.Contains(t, stderr.String(), "Are you sure you want to deploy test-service?") + } + }) + } + }) + } +} diff --git a/pkg/command/wrapper.go b/pkg/command/wrapper.go index c1d0646..1a753d7 100644 --- a/pkg/command/wrapper.go +++ b/pkg/command/wrapper.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -54,18 +55,21 @@ func NonInteractiveWithConfirm[T any](cmd *cobra.Command, loadData LoadDataFunc[ if err != nil { return false, err } - _, err = cmd.OutOrStdout().Write([]byte(fmt.Sprintf("%s (y/n): ", message))) + _, err = cmd.ErrOrStderr().Write([]byte(fmt.Sprintf("%s (y/n): ", message))) if err != nil { return false, err } reader := bufio.NewReader(cmd.InOrStdin()) str, err := reader.ReadString('\n') + if errors.Is(err, io.EOF) { + return false, fmt.Errorf("confirmation required: rerun with --%s to skip confirmation prompts: %w", ConfirmFlag, err) + } if err != nil { return false, err } if str != "y\n" { - _, err := cmd.OutOrStdout().Write([]byte("Aborted\n")) + _, err := cmd.ErrOrStderr().Write([]byte("Aborted\n")) return false, err } } diff --git a/pkg/command/wrapper_test.go b/pkg/command/wrapper_test.go new file mode 100644 index 0000000..f448bc6 --- /dev/null +++ b/pkg/command/wrapper_test.go @@ -0,0 +1,96 @@ +package command_test + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "testing/iotest" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/render-oss/cli/pkg/command" +) + +func TestNonInteractiveWithConfirm(t *testing.T) { + tests := []struct { + name string + input string + confirm bool + wantLoad bool + wantPrompt bool + wantAbort bool + wantEOF bool + }{ + {name: "closed stdin requires explicit confirmation", wantPrompt: true, wantEOF: true}, + {name: "unterminated input does not confirm", input: "y", wantPrompt: true, wantEOF: true}, + {name: "accepted prompt preserves JSON stdout", input: "y\n", wantLoad: true, wantPrompt: true}, + {name: "declined prompt preserves stdout", input: "n\n", wantPrompt: true, wantAbort: true}, + {name: "confirm flag skips stdin", confirm: true, wantLoad: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := command.JSON + ctx := command.SetFormatInContext(context.Background(), &output) + ctx = command.SetConfirmInContext(ctx, tt.confirm) + cmd := &cobra.Command{} + cmd.SetContext(ctx) + cmd.SetIn(strings.NewReader(tt.input)) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + loaded := false + handled, err := command.NonInteractiveWithConfirm(cmd, func() (string, error) { + loaded = true + return "created", nil + }, func(data string) string { return data }, func() (string, error) { + return "Proceed?", nil + }) + + if tt.wantEOF { + require.ErrorIs(t, err, io.EOF) + assert.Contains(t, err.Error(), "--confirm") + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantLoad, loaded) + assert.Equal(t, tt.wantLoad, handled) + if tt.wantLoad { + assert.JSONEq(t, `"created"`, stdout.String()) + } else { + assert.Empty(t, stdout.String()) + } + if tt.wantPrompt { + assert.Contains(t, stderr.String(), "Proceed? (y/n): ") + } else { + assert.Empty(t, stderr.String()) + } + assert.Equal(t, tt.wantAbort, strings.Contains(stderr.String(), "Aborted\n")) + }) + } +} + +func TestNonInteractiveWithConfirmReadError(t *testing.T) { + output := command.JSON + cmd := &cobra.Command{} + cmd.SetContext(command.SetFormatInContext(context.Background(), &output)) + readErr := errors.New("read failed") + cmd.SetIn(iotest.ErrReader(readErr)) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + _, err := command.NonInteractiveWithConfirm(cmd, func() (string, error) { + t.Fatal("action must not run when confirmation cannot be read") + return "", nil + }, func(data string) string { return data }, func() (string, error) { return "Proceed?", nil }) + + require.ErrorIs(t, err, readErr) + assert.Empty(t, stdout.String()) +}