diff --git a/api/project/project.go b/api/project/project.go new file mode 100644 index 000000000..442ea7576 --- /dev/null +++ b/api/project/project.go @@ -0,0 +1,13 @@ +package project + +// ProjectEnvironmentVariable is a Environment Variable of a Project +type ProjectEnvironmentVariable struct { + Name string + Value string +} + +// ProjectClient is the interface to interact with project and it's +// components. +type ProjectClient interface { + ListAllEnvironmentVariables(vcs, org, project string) ([]*ProjectEnvironmentVariable, error) +} diff --git a/api/project/project_rest.go b/api/project/project_rest.go new file mode 100644 index 000000000..1536f55ca --- /dev/null +++ b/api/project/project_rest.go @@ -0,0 +1,104 @@ +package project + +import ( + "fmt" + "net/url" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +type projectRestClient struct { + token string + server string + client *rest.Client +} + +var _ ProjectClient = &projectRestClient{} + +type listProjectEnvVarsParams struct { + vcs string + org string + project string + pageToken string +} + +type projectEnvVarResponse struct { + Name string + Value string +} + +type listAllProjectEnvVarsResponse struct { + Items []projectEnvVarResponse + NextPageToken string `json:"next_page_token"` +} + +// NewProjectRestClient returns a new projectRestClient satisfying the api.ProjectInterface +// interface via the REST API. +func NewProjectRestClient(config settings.Config) (*projectRestClient, error) { + serverURL, err := config.ServerURL() + if err != nil { + return nil, err + } + + client := &projectRestClient{ + token: config.Token, + server: serverURL.String(), + client: rest.New(config.Host, &config), + } + + return client, nil +} + +// ListAllEnvironmentVariables returns all of the environment variables owned by the +// given project. Note that pagination is not supported - we get all +// pages of env vars and return them all. +func (p *projectRestClient) ListAllEnvironmentVariables(vcs, org, project string) ([]*ProjectEnvironmentVariable, error) { + res := make([]*ProjectEnvironmentVariable, 0) + var nextPageToken string + for { + resp, err := p.listEnvironmentVariables(&listProjectEnvVarsParams{ + vcs: vcs, + org: org, + project: project, + pageToken: nextPageToken, + }) + if err != nil { + return nil, err + } + + for _, ev := range resp.Items { + res = append(res, &ProjectEnvironmentVariable{ + Name: ev.Name, + Value: ev.Value, + }) + } + + if resp.NextPageToken == "" { + break + } + + nextPageToken = resp.NextPageToken + } + return res, nil +} + +func (c *projectRestClient) listEnvironmentVariables(params *listProjectEnvVarsParams) (*listAllProjectEnvVarsResponse, error) { + path := fmt.Sprintf("project/%s/%s/%s/envvar", params.vcs, params.org, params.project) + urlParams := url.Values{} + if params.pageToken != "" { + urlParams.Add("page-token", params.pageToken) + } + + req, err := c.client.NewRequest("GET", &url.URL{Path: path, RawQuery: urlParams.Encode()}, nil) + if err != nil { + return nil, err + } + + var resp listAllProjectEnvVarsResponse + _, err = c.client.DoRequest(req, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/api/project/project_rest_test.go b/api/project/project_rest_test.go new file mode 100644 index 000000000..29d3305e1 --- /dev/null +++ b/api/project/project_rest_test.go @@ -0,0 +1,160 @@ +package project_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/CircleCI-Public/circleci-cli/api/project" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/version" + "gotest.tools/v3/assert" +) + +func getProjectRestClient(server *httptest.Server) (project.ProjectClient, error) { + client := &http.Client{} + + return project.NewProjectRestClient(settings.Config{ + RestEndpoint: "api/v2", + Host: server.URL, + HTTPClient: client, + Token: "token", + }) +} + +func Test_projectRestClient_ListAllEnvironmentVariables(t *testing.T) { + const ( + vcsType = "github" + orgName = "test-org" + projName = "test-proj" + ) + tests := []struct { + name string + handler http.HandlerFunc + want []*project.ProjectEnvironmentVariable + wantErr bool + }{ + { + name: "Should handle a successful request with ListAllEnvironmentVariables", + 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", vcsType, orgName, projName)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + { + "items": [{ + "name": "foo", + "value": "xxxx1234" + }], + "next_page_token": "" + }`)) + assert.NilError(t, err) + }, + want: []*project.ProjectEnvironmentVariable{ + { + Name: "foo", + Value: "xxxx1234", + }, + }, + }, + { + name: "Should handle a request containing next_page_token with ListAllEnvironmentVariables", + handler: func(w http.ResponseWriter, r *http.Request) { + u, err := url.ParseQuery(r.URL.RawQuery) + assert.NilError(t, err) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusOK) + if tk := u.Get("page-token"); tk == "" { + _, err := w.Write([]byte(` + { + "items": [ + { + "name": "foo1", + "value": "xxxx1234" + }, + { + "name": "foo2", + "value": "xxxx2345" + } + ], + "next_page_token": "pagetoken" + }`)) + assert.NilError(t, err) + } else { + assert.Equal(t, tk, "pagetoken") + _, err := w.Write([]byte(` + { + "items": [ + { + "name": "bar1", + "value": "xxxxabcd" + }, + { + "name": "bar2", + "value": "xxxxbcde" + } + ], + "next_page_token": "" + }`)) + assert.NilError(t, err) + } + }, + want: []*project.ProjectEnvironmentVariable{ + { + Name: "foo1", + Value: "xxxx1234", + }, + { + Name: "foo2", + Value: "xxxx2345", + }, + { + Name: "bar1", + Value: "xxxxabcd", + }, + { + Name: "bar2", + Value: "xxxxbcde", + }, + }, + }, + { + name: "Should handle an error request with ListAllEnvironmentVariables", + 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, + }, + } + 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.ListAllEnvironmentVariables(vcsType, orgName, projName) + if (err != nil) != tt.wantErr { + t.Errorf("projectRestClient.ListAllEnvironmentVariables() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("projectRestClient.ListAllEnvironmentVariables() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/project/environment_variable.go b/cmd/project/environment_variable.go new file mode 100644 index 000000000..2d97cf7ef --- /dev/null +++ b/cmd/project/environment_variable.go @@ -0,0 +1,46 @@ +package project + +import ( + projectapi "github.com/CircleCI-Public/circleci-cli/api/project" + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" +) + +func newProjectEnvironmentVariableCommand(ops *projectOpts, preRunE validator.Validator) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret", + Short: "Operate on environment variables of projects", + } + + listVarsCommand := &cobra.Command{ + Short: "List all environment variables of a project", + Use: "list ", + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + return listProjectEnvironmentVariables(cmd, ops.client, args[0], args[1], args[2]) + }, + Args: cobra.ExactArgs(3), + } + + cmd.AddCommand(listVarsCommand) + return cmd +} + +func listProjectEnvironmentVariables(cmd *cobra.Command, client projectapi.ProjectClient, vcsType, orgName, projName string) error { + envVars, err := client.ListAllEnvironmentVariables(vcsType, orgName, projName) + if err != nil { + return err + } + + table := tablewriter.NewWriter(cmd.OutOrStdout()) + + table.SetHeader([]string{"Environment Variable", "Value"}) + + for _, envVar := range envVars { + table.Append([]string{envVar.Name, envVar.Value}) + } + table.Render() + + return nil +} diff --git a/cmd/project/project.go b/cmd/project/project.go new file mode 100644 index 000000000..2d7b20f06 --- /dev/null +++ b/cmd/project/project.go @@ -0,0 +1,34 @@ +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/settings" + "github.com/spf13/cobra" +) + +type projectOpts struct { + client projectapi.ProjectClient +} + +// NewProjectCommand generates a cobra command for managing projects +func NewProjectCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command { + var opts projectOpts + command := &cobra.Command{ + Use: "project", + Short: "Operate on projects", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + client, err := projectapi.NewProjectRestClient(*config) + if err != nil { + return err + } + opts.client = client + return nil + }, + } + + command.AddCommand(newProjectEnvironmentVariableCommand(&opts, preRunE)) + + return command +} diff --git a/cmd/project/project_test.go b/cmd/project/project_test.go new file mode 100644 index 000000000..1bf2674a7 --- /dev/null +++ b/cmd/project/project_test.go @@ -0,0 +1,138 @@ +package project_test + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/CircleCI-Public/circleci-cli/cmd/project" + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + "gotest.tools/v3/assert" +) + +const ( + vcsType = "github" + orgName = "test-org" + projectName = "test-project" +) + +func tableString(header []string, rows [][]string) string { + res := &strings.Builder{} + table := tablewriter.NewWriter(res) + table.SetHeader(header) + for _, r := range rows { + table.Append(r) + } + table.Render() + return res.String() +} + +func getListProjectsArg() []string { + return []string{ + "secret", + "list", + vcsType, + orgName, + projectName, + } +} + +func TestListSecrets(t *testing.T) { + var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.String(), fmt.Sprintf("/project/%s/%s/%s/envvar", vcsType, orgName, projectName)) + response := `{ + "items": [{ + "name": "foo", + "value": "xxxx1234" + }], + "next_page_token": "" + }` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(response)) + 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 + }, + ) + cmd.SetArgs(getListProjectsArg()) + err := cmd.Execute() + assert.NilError(t, err) + + expect := tableString( + []string{"Environment Variable", "Value"}, + [][]string{{"foo", "xxxx1234"}}, + ) + res := stdout.String() + assert.Equal(t, res, expect) +} + +func TestListSecretsErrorWithValidator(t *testing.T) { + const errorMsg = "validator error" + var handler http.HandlerFunc = func(_ http.ResponseWriter, _ *http.Request) {} + server := httptest.NewServer(handler) + defer server.Close() + + cmd, _, _ := scaffoldCMD( + server.URL, + func(_ *cobra.Command, _ []string) error { + return fmt.Errorf(errorMsg) + }, + ) + cmd.SetArgs(getListProjectsArg()) + err := cmd.Execute() + assert.Error(t, err, errorMsg) +} + +func TestListSecretsErrorWithAPIResponse(t *testing.T) { + const errorMsg = "api error" + var handler http.HandlerFunc = func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(fmt.Sprintf(`{"message": "%s"}`, errorMsg))) + assert.NilError(t, err) + } + server := httptest.NewServer(handler) + defer server.Close() + + cmd, _, _ := scaffoldCMD( + server.URL, + func(cmd *cobra.Command, args []string) error { + return nil + }, + ) + cmd.SetArgs(getListProjectsArg()) + err := cmd.Execute() + assert.Error(t, err, errorMsg) +} + +func scaffoldCMD( + baseURL string, + validator validator.Validator, +) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + config := &settings.Config{ + Token: "testtoken", + HTTPClient: http.DefaultClient, + Host: baseURL, + } + cmd := project.NewProjectCommand(config, validator) + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + return cmd, stdout, stderr +} diff --git a/cmd/root.go b/cmd/root.go index f47ff96ea..6a636cd0c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "github.com/CircleCI-Public/circleci-cli/api/header" "github.com/CircleCI-Public/circleci-cli/cmd/info" "github.com/CircleCI-Public/circleci-cli/cmd/policy" + "github.com/CircleCI-Public/circleci-cli/cmd/project" "github.com/CircleCI-Public/circleci-cli/cmd/runner" "github.com/CircleCI-Public/circleci-cli/data" "github.com/CircleCI-Public/circleci-cli/md_docs" @@ -138,6 +139,7 @@ func MakeCommands() *cobra.Command { rootCmd.AddCommand(newOpenCommand()) rootCmd.AddCommand(newTestsCommand()) rootCmd.AddCommand(newContextCommand(rootOptions)) + rootCmd.AddCommand(project.NewProjectCommand(rootOptions, validator)) rootCmd.AddCommand(newQueryCommand(rootOptions)) rootCmd.AddCommand(newConfigCommand(rootOptions)) rootCmd.AddCommand(newOrbCommand(rootOptions)) diff --git a/cmd/root_test.go b/cmd/root_test.go index 01fbd7205..6ea4c5667 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -16,7 +16,7 @@ var _ = Describe("Root", func() { Describe("subcommands", func() { It("can create commands", func() { commands := cmd.MakeCommands() - Expect(len(commands.Commands())).To(Equal(22)) + Expect(len(commands.Commands())).To(Equal(23)) }) })