From a9f9d15ea8595689014d7f6442eb0b3eaffc2385 Mon Sep 17 00:00:00 2001 From: Shogo Tsutsumi Date: Sat, 24 Sep 2022 19:13:24 +0900 Subject: [PATCH] A command `project secret create` added to create or update an environment variable --- api/project/project.go | 2 + api/project/project_rest.go | 53 +++++++ api/project/project_rest_test.go | 163 +++++++++++++++++++++ cmd/project/environment_variable.go | 59 ++++++++ cmd/project/project.go | 49 ++++++- cmd/project/project_test.go | 216 +++++++++++++++++++++++++++- 6 files changed, 537 insertions(+), 5 deletions(-) diff --git a/api/project/project.go b/api/project/project.go index 442ea7576..07947e43d 100644 --- a/api/project/project.go +++ b/api/project/project.go @@ -10,4 +10,6 @@ type ProjectEnvironmentVariable struct { // components. type ProjectClient interface { ListAllEnvironmentVariables(vcs, org, project string) ([]*ProjectEnvironmentVariable, error) + GetEnvironmentVariable(vcs, org, project, envName string) (*ProjectEnvironmentVariable, error) + CreateEnvironmentVariable(vcs, org, project string, v ProjectEnvironmentVariable) (*ProjectEnvironmentVariable, error) } diff --git a/api/project/project_rest.go b/api/project/project_rest.go index 1536f55ca..aae48f283 100644 --- a/api/project/project_rest.go +++ b/api/project/project_rest.go @@ -33,6 +33,11 @@ type listAllProjectEnvVarsResponse struct { NextPageToken string `json:"next_page_token"` } +type createProjectEnvVarRequest struct { + Name string `json:"name"` + Value string `json:"value"` +} + // NewProjectRestClient returns a new projectRestClient satisfying the api.ProjectInterface // interface via the REST API. func NewProjectRestClient(config settings.Config) (*projectRestClient, error) { @@ -102,3 +107,51 @@ func (c *projectRestClient) listEnvironmentVariables(params *listProjectEnvVarsP } return &resp, nil } + +// GetEnvironmentVariable retrieves and returns a variable with the given name. +// If the response status code is 404, nil is returned. +func (c *projectRestClient) GetEnvironmentVariable(vcs string, org string, project string, envName string) (*ProjectEnvironmentVariable, error) { + path := fmt.Sprintf("project/%s/%s/%s/envvar/%s", vcs, org, project, envName) + req, err := c.client.NewRequest("GET", &url.URL{Path: path}, nil) + if err != nil { + return nil, err + } + + var resp projectEnvVarResponse + code, err := c.client.DoRequest(req, &resp) + if err != nil { + if code == 404 { + // Note: 404 may mean that the project isn't found. + // The cause can't be distinguished except by the response text. + return nil, nil + } + return nil, err + } + return &ProjectEnvironmentVariable{ + Name: resp.Name, + Value: resp.Value, + }, nil +} + +// CreateEnvironmentVariable creates a variable on the given project. +// This returns the variable if successfully created. +func (c *projectRestClient) CreateEnvironmentVariable(vcs string, org string, project string, v ProjectEnvironmentVariable) (*ProjectEnvironmentVariable, error) { + path := fmt.Sprintf("project/%s/%s/%s/envvar", vcs, org, project) + req, err := c.client.NewRequest("POST", &url.URL{Path: path}, &createProjectEnvVarRequest{ + Name: v.Name, + Value: v.Value, + }) + if err != nil { + return nil, err + } + + var resp projectEnvVarResponse + _, err = c.client.DoRequest(req, &resp) + if err != nil { + return nil, err + } + return &ProjectEnvironmentVariable{ + Name: resp.Name, + Value: resp.Value, + }, nil +} diff --git a/api/project/project_rest_test.go b/api/project/project_rest_test.go index 29d3305e1..947512c82 100644 --- a/api/project/project_rest_test.go +++ b/api/project/project_rest_test.go @@ -1,6 +1,7 @@ package project_test import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -158,3 +159,165 @@ func Test_projectRestClient_ListAllEnvironmentVariables(t *testing.T) { }) } } + +func Test_projectRestClient_GetEnvironmentVariable(t *testing.T) { + const ( + vcsType = "github" + orgName = "test-org" + projName = "test-proj" + ) + tests := []struct { + name string + handler http.HandlerFunc + envName string + want *project.ProjectEnvironmentVariable + wantErr bool + }{ + { + name: "Should handle a successful request", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s/envvar/test1", vcsType, orgName, projName)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + { + "name": "foo", + "value": "xxxx1234" + }`)) + assert.NilError(t, err) + }, + envName: "test1", + want: &project.ProjectEnvironmentVariable{ + Name: "foo", + Value: "xxxx1234", + }, + }, + { + name: "Should handle an error request", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + wantErr: true, + }, + { + name: "Should handle an 404 error as a valid request", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{"message": "Environment variable not found."}`)) + assert.NilError(t, err) + }, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + p, err := getProjectRestClient(server) + assert.NilError(t, err) + + got, err := p.GetEnvironmentVariable(vcsType, orgName, projName, tt.envName) + if (err != nil) != tt.wantErr { + t.Errorf("projectRestClient.GetEnvironmentVariable() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("projectRestClient.GetEnvironmentVariable() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_projectRestClient_CreateEnvironmentVariable(t *testing.T) { + const ( + vcsType = "github" + orgName = "test-org" + projName = "test-proj" + ) + tests := []struct { + name string + handler http.HandlerFunc + variable project.ProjectEnvironmentVariable + want *project.ProjectEnvironmentVariable + wantErr bool + }{ + { + name: "Should handle a successful request", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "POST") + assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/project/%s/%s/%s/envvar", vcsType, orgName, projName)) + var pv project.ProjectEnvironmentVariable + err := json.NewDecoder(r.Body).Decode(&pv) + assert.NilError(t, err) + assert.Equal(t, pv, project.ProjectEnvironmentVariable{ + Name: "foo", + Value: "test1234", + }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(` + { + "name": "foo", + "value": "xxxx1234" + }`)) + assert.NilError(t, err) + }, + variable: project.ProjectEnvironmentVariable{ + Name: "foo", + Value: "test1234", + }, + want: &project.ProjectEnvironmentVariable{ + Name: "foo", + Value: "xxxx1234", + }, + }, + { + name: "Should handle an error request", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + variable: project.ProjectEnvironmentVariable{ + Name: "bar", + Value: "testbar", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + p, err := getProjectRestClient(server) + assert.NilError(t, err) + + got, err := p.CreateEnvironmentVariable(vcsType, orgName, projName, tt.variable) + if (err != nil) != tt.wantErr { + t.Errorf("projectRestClient.CreateEnvironmentVariable() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("projectRestClient.CreateEnvironmentVariable() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/project/environment_variable.go b/cmd/project/environment_variable.go index 2d97cf7ef..ff0b5907f 100644 --- a/cmd/project/environment_variable.go +++ b/cmd/project/environment_variable.go @@ -1,6 +1,9 @@ package project import ( + "fmt" + "strings" + projectapi "github.com/CircleCI-Public/circleci-cli/api/project" "github.com/CircleCI-Public/circleci-cli/cmd/validator" "github.com/olekukonko/tablewriter" @@ -23,7 +26,21 @@ func newProjectEnvironmentVariableCommand(ops *projectOpts, preRunE validator.Va Args: cobra.ExactArgs(3), } + var envValue string + createVarCommand := &cobra.Command{ + Short: "Create an environment variable of a project. The value is read from stdin.", + Use: "create ", + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + return createProjectEnvironmentVariable(cmd, ops.client, ops.reader, args[0], args[1], args[2], args[3], envValue) + }, + Args: cobra.ExactArgs(4), + } + + createVarCommand.Flags().StringVar(&envValue, "env-value", "", "An environment variable value to be created. You can also pass it by stdin without this option.") + cmd.AddCommand(listVarsCommand) + cmd.AddCommand(createVarCommand) return cmd } @@ -44,3 +61,45 @@ func listProjectEnvironmentVariables(cmd *cobra.Command, client projectapi.Proje return nil } + +func createProjectEnvironmentVariable(cmd *cobra.Command, client projectapi.ProjectClient, r UserInputReader, vcsType, orgName, projName, name, value string) error { + if value == "" { + val, err := r.ReadSecretString("Enter an environment variable value and press enter") + if err != nil { + return err + } + if val == "" { + return fmt.Errorf("the environment variable value must not be empty") + } + value = val + } + value = strings.Trim(value, "\r\n") + + existV, err := client.GetEnvironmentVariable(vcsType, orgName, projName, name) + if err != nil { + return err + } + if existV != nil { + msg := fmt.Sprintf("The environment variable name=%s value=%s already exists. Do you overwrite it?", existV.Name, existV.Value) + if !r.AskConfirm(msg) { + fmt.Fprintln(cmd.OutOrStdout(), "Canceled") + return nil + } + } + + v, err := client.CreateEnvironmentVariable(vcsType, orgName, projName, projectapi.ProjectEnvironmentVariable{ + Name: name, + Value: value, + }) + if err != nil { + return err + } + + table := tablewriter.NewWriter(cmd.OutOrStdout()) + + table.SetHeader([]string{"Environment Variable", "Value"}) + table.Append([]string{v.Name, v.Value}) + table.Render() + + return nil +} diff --git a/cmd/project/project.go b/cmd/project/project.go index 2d7b20f06..d0275619a 100644 --- a/cmd/project/project.go +++ b/cmd/project/project.go @@ -3,18 +3,46 @@ package project import ( projectapi "github.com/CircleCI-Public/circleci-cli/api/project" "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/prompt" "github.com/CircleCI-Public/circleci-cli/settings" "github.com/spf13/cobra" ) +// UserInputReader displays a message and reads a user input value +type UserInputReader interface { + ReadSecretString(msg string) (string, error) + AskConfirm(msg string) bool +} + type projectOpts struct { client projectapi.ProjectClient + reader UserInputReader +} + +// ProjectOption configures a command created by NewProjectCommand +type ProjectOption interface { + apply(*projectOpts) +} + +type promptReader struct{} + +func (p promptReader) ReadSecretString(msg string) (string, error) { + return prompt.ReadSecretStringFromUser(msg) +} + +func (p promptReader) AskConfirm(msg string) bool { + return prompt.AskUserToConfirm(msg) } // NewProjectCommand generates a cobra command for managing projects -func NewProjectCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command { - var opts projectOpts +func NewProjectCommand(config *settings.Config, preRunE validator.Validator, opts ...ProjectOption) *cobra.Command { + pos := projectOpts{ + reader: &promptReader{}, + } + for _, o := range opts { + o.apply(&pos) + } command := &cobra.Command{ Use: "project", Short: "Operate on projects", @@ -23,12 +51,25 @@ func NewProjectCommand(config *settings.Config, preRunE validator.Validator) *co if err != nil { return err } - opts.client = client + pos.client = client return nil }, } - command.AddCommand(newProjectEnvironmentVariableCommand(&opts, preRunE)) + command.AddCommand(newProjectEnvironmentVariableCommand(&pos, preRunE)) return command } + +type customReaderProjectOption struct { + r UserInputReader +} + +func (c customReaderProjectOption) apply(opts *projectOpts) { + opts.reader = c.r +} + +// CustomReader returns a ProjectOption that sets a given UserInputReader to a project command +func CustomReader(r UserInputReader) ProjectOption { + return customReaderProjectOption{r} +} diff --git a/cmd/project/project_test.go b/cmd/project/project_test.go index 1bf2674a7..21f006a62 100644 --- a/cmd/project/project_test.go +++ b/cmd/project/project_test.go @@ -2,9 +2,12 @@ package project_test import ( "bytes" + "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -33,6 +36,17 @@ func tableString(header []string, rows [][]string) string { return res.String() } +func equalJSON(j1, j2 string) (bool, error) { + var j1i, j2i interface{} + if err := json.Unmarshal([]byte(j1), &j1i); err != nil { + return false, fmt.Errorf("failed to convert in equalJSON from '%s': %w", j1, err) + } + if err := json.Unmarshal([]byte(j2), &j2i); err != nil { + return false, fmt.Errorf("failed to convert in equalJSON from '%s': %w", j2, err) + } + return reflect.DeepEqual(j1i, j2i), nil +} + func getListProjectsArg() []string { return []string{ "secret", @@ -118,16 +132,216 @@ func TestListSecretsErrorWithAPIResponse(t *testing.T) { assert.Error(t, err, errorMsg) } +type testCreateSecretArgs struct { + variableVal string // ignored if --env-value flag is contained + statusCodeGet int + statusCodePost int // ignored if overwriting is canceled + isOverwrite bool // ignored if statusCodeGet is http.StatusNotFound + extraArgs []string +} + +func TestCreateSecret(t *testing.T) { + const ( + variableVal = "testvar1234" + variableKey = "foo" + ) + tests := []struct { + name string + args testCreateSecretArgs + want string + wantErr bool + }{ + { + name: "Create successfully without an existing key", + args: testCreateSecretArgs{ + variableVal: variableVal, + statusCodeGet: http.StatusNotFound, + statusCodePost: http.StatusOK, + extraArgs: []string{variableKey}, + }, + want: tableString( + []string{"Environment Variable", "Value"}, + [][]string{{"foo", "xxxx1234"}}, + ), + }, + { + name: "Overwrite successfully with an existing key", + args: testCreateSecretArgs{ + variableVal: variableVal, + statusCodeGet: http.StatusOK, + statusCodePost: http.StatusOK, + isOverwrite: true, + extraArgs: []string{variableKey}, + }, + want: tableString( + []string{"Environment Variable", "Value"}, + [][]string{{"foo", "xxxx1234"}}, + ), + }, + { + name: "Cancel overwriting an existing key", + args: testCreateSecretArgs{ + variableVal: variableVal, + statusCodeGet: http.StatusOK, + isOverwrite: false, + extraArgs: []string{variableKey}, + }, + want: fmt.Sprintln("Canceled"), + }, + { + name: "Pass a variable through a commandline argument", + args: testCreateSecretArgs{ + statusCodeGet: http.StatusNotFound, + statusCodePost: http.StatusOK, + extraArgs: []string{variableKey, "--env-value", variableVal}, + }, + want: tableString( + []string{"Environment Variable", "Value"}, + [][]string{{"foo", "xxxx1234"}}, + ), + }, + { + name: "Handle an error request from GetEnvironmentVariable", + args: testCreateSecretArgs{ + variableVal: variableVal, + statusCodeGet: http.StatusInternalServerError, + statusCodePost: http.StatusOK, + extraArgs: []string{variableKey}, + }, + wantErr: true, + }, + { + name: "Handle an error request from CreateEnvironmentVariable", + args: testCreateSecretArgs{ + variableVal: variableVal, + statusCodeGet: http.StatusNotFound, + statusCodePost: http.StatusInternalServerError, + extraArgs: []string{variableKey}, + }, + wantErr: true, + }, + { + name: "The process should be rejected if the passed value is empty", + args: testCreateSecretArgs{ + variableVal: "", + statusCodeGet: http.StatusNotFound, + statusCodePost: http.StatusOK, + extraArgs: []string{variableKey}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := testCreateSecret(t, &tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("Create secret command: error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Create secret command: got = %v, want %v", got, tt.want) + } + }) + } +} + +type testInputReader struct { + secret string + yesNo bool +} + +func (s testInputReader) ReadSecretString(msg string) (string, error) { + return s.secret, nil +} + +func (s testInputReader) AskConfirm(msg string) bool { + return s.yesNo +} + +func testCreateSecret(t *testing.T, args *testCreateSecretArgs) (string, error) { + const apiResponseBody = `{ + "name": "foo", + "value": "xxxx1234" + }` + var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + assert.Equal(t, r.URL.String(), fmt.Sprintf("/project/%s/%s/%s/envvar/foo", vcsType, orgName, projectName)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(args.statusCodeGet) + if args.statusCodeGet == http.StatusOK { + _, err := w.Write([]byte(apiResponseBody)) + assert.NilError(t, err) + } + case "POST": + expect := `{ + "name": "foo", + "value": "testvar1234" + }` + assert.Equal(t, r.URL.String(), fmt.Sprintf("/project/%s/%s/%s/envvar", vcsType, orgName, projectName)) + isRequestBodyValid(t, r, expect) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(args.statusCodePost) + if args.statusCodePost == http.StatusOK { + _, err := w.Write([]byte(apiResponseBody)) + assert.NilError(t, err) + } + } + } + + server := httptest.NewServer(handler) + defer server.Close() + + cmd, stdout, _ := scaffoldCMD( + server.URL, + func(cmd *cobra.Command, args []string) error { + return nil + }, + project.CustomReader(testInputReader{ + secret: args.variableVal, + yesNo: args.isOverwrite, + }), + ) + cmd.SetArgs(append(getCreateSecretArgBase(), args.extraArgs...)) + + err := cmd.Execute() + if err != nil { + return "", err + } + + return stdout.String(), nil +} + +func getCreateSecretArgBase() []string { + return []string{ + "secret", + "create", + vcsType, + orgName, + projectName, + } +} + +func isRequestBodyValid(t *testing.T, r *http.Request, expect string) { + b, err := io.ReadAll(r.Body) + assert.NilError(t, err) + eq, err := equalJSON(string(b), expect) + assert.NilError(t, err) + assert.Equal(t, eq, true) +} + func scaffoldCMD( baseURL string, validator validator.Validator, + opts ...project.ProjectOption, ) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { config := &settings.Config{ Token: "testtoken", HTTPClient: http.DefaultClient, Host: baseURL, } - cmd := project.NewProjectCommand(config, validator) + cmd := project.NewProjectCommand(config, validator, opts...) stdout := new(bytes.Buffer) stderr := new(bytes.Buffer)