diff --git a/cmd/kosli/archive.go b/cmd/kosli/archive.go index a6c347b28..ba00b49d0 100644 --- a/cmd/kosli/archive.go +++ b/cmd/kosli/archive.go @@ -20,6 +20,7 @@ func newArchiveCmd(out io.Writer) *cobra.Command { newArchiveFlowCmd(out), newArchiveEnvironmentCmd(out), newArchiveAttestationTypeCmd(out), + newArchiveControlCmd(out), ) return cmd } diff --git a/cmd/kosli/archiveControl.go b/cmd/kosli/archiveControl.go new file mode 100644 index 000000000..037e8b84e --- /dev/null +++ b/cmd/kosli/archiveControl.go @@ -0,0 +1,62 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const archiveControlShortDesc = `Archive a Kosli control.` + +const archiveControlLongDesc = archiveControlShortDesc + ` +An archived control is no longer active. It remains visible via ^kosli get control^ and +via ^kosli list controls --archived^, and can be restored with ^kosli unarchive control^. +` + +const archiveControlExample = ` +# archive a Kosli control: +kosli archive control yourControlIdentifier \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newArchiveControlCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: archiveControlShortDesc, + Long: archiveControlLongDesc, + Example: archiveControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org, args[0], "archive") + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("control %s was archived", args[0]) + } + return err + }, + } + addDryRunFlag(cmd) + return cmd +} diff --git a/cmd/kosli/archiveControl_test.go b/cmd/kosli/archiveControl_test.go new file mode 100644 index 000000000..874f52a8f --- /dev/null +++ b/cmd/kosli/archiveControl_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ArchiveControlCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *ArchiveControlCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "archive-me", "Archive me", suite.T()) +} + +func (suite *ArchiveControlCommandTestSuite) TestArchiveControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "archive control" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + name: "archiving an existing control works", + cmd: "archive control archive-me" + suite.defaultKosliArguments, + golden: "control archive-me was archived\n", + }, + { + wantError: true, + name: "archiving a non-existing control gives a clear error", + cmd: "archive control no-such-control" + suite.defaultKosliArguments, + goldenRegex: "^Error: Control 'no-such-control' does not exist in org", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestArchiveControlCommandTestSuite(t *testing.T) { + suite.Run(t, new(ArchiveControlCommandTestSuite)) +} diff --git a/cmd/kosli/attestDecision.go b/cmd/kosli/attestDecision.go index ae20612ae..5e6c8f469 100644 --- a/cmd/kosli/attestDecision.go +++ b/cmd/kosli/attestDecision.go @@ -7,7 +7,6 @@ import ( "net/url" "os" - "github.com/kosli-dev/cli/internal/docgen" "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) @@ -97,8 +96,7 @@ func newAttestDecisionCmd(out io.Writer) *cobra.Command { Short: attestDecisionShortDesc, Long: attestDecisionLongDesc, Example: attestDecisionExample, - Hidden: true, - Annotations: map[string]string{docgen.DocHiddenAnnotation: "", betaCLIAnnotation: ""}, + Annotations: map[string]string{betaCLIAnnotation: ""}, PreRunE: func(cmd *cobra.Command, args []string) error { err := CustomMaximumNArgs(1, args) if err != nil { diff --git a/cmd/kosli/create.go b/cmd/kosli/create.go index 7e4ca5f30..5db4fa714 100644 --- a/cmd/kosli/create.go +++ b/cmd/kosli/create.go @@ -22,6 +22,7 @@ func newCreateCmd(out io.Writer) *cobra.Command { newCreateFlowCmd(out), newCreatePolicyCmd(out), newCreateAttestationTypeCmd(out), + newCreateControlCmd(out), newCreateApiKeyCmd(out), newCreateServiceAccountCmd(out), ) diff --git a/cmd/kosli/createControl.go b/cmd/kosli/createControl.go new file mode 100644 index 000000000..ca8322844 --- /dev/null +++ b/cmd/kosli/createControl.go @@ -0,0 +1,94 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const createControlShortDesc = `Create a Kosli control.` + +const createControlLongDesc = createControlShortDesc + ` + +^CONTROL-IDENTIFIER^ must start with a letter or number, and only contain letters, numbers, ^.^, ^-^, ^_^, and ^~^. +` + +const createControlExample = ` +# create a Kosli control: +kosli create control yourControlIdentifier \ + --name "Your control name" \ + --description "what this control checks" \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type createControlOptions struct { + payload ControlPayload +} + +type ControlPayload struct { + Identifier string `json:"identifier"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +func newCreateControlCmd(out io.Writer) *cobra.Command { + o := new(createControlOptions) + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: createControlShortDesc, + Long: createControlLongDesc, + Example: createControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(args) + }, + } + + cmd.Flags().StringVarP(&o.payload.Name, "name", "n", "", controlNameFlag) + cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", controlDescriptionFlag) + + addDryRunFlag(cmd) + + err := RequireFlags(cmd, []string{"name"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *createControlOptions) run(args []string) error { + o.payload.Identifier = args[0] + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Payload: o.payload, + DryRun: global.DryRun, + Token: global.ApiToken, + // A 409 here means the identifier already exists — a permanent error, + // so surface it immediately rather than retrying. + DisableConflictRetry: true, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("control %s was created", o.payload.Identifier) + } + return err +} diff --git a/cmd/kosli/createControl_test.go b/cmd/kosli/createControl_test.go new file mode 100644 index 000000000..2c218bb1e --- /dev/null +++ b/cmd/kosli/createControl_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type CreateControlTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *CreateControlTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) +} + +func (suite *CreateControlTestSuite) TestCreateControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "create control --name 'My Control'" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + wantError: true, + name: "fails when --name is missing", + cmd: "create control my-control" + suite.defaultKosliArguments, + golden: "Error: required flag(s) \"name\" not set\n", + }, + { + wantError: false, + name: "creates a control with identifier and --name", + cmd: "create control my-control --name 'My Control'" + suite.defaultKosliArguments, + golden: "control my-control was created\n", + }, + { + wantError: false, + name: "creates a control with a --description", + cmd: "create control my-control-2 --name 'My Second Control' --description 'checks something'" + suite.defaultKosliArguments, + golden: "control my-control-2 was created\n", + }, + { + // Relies on "my-control" created by the earlier test case (cases run + // sequentially against the same server within the suite). + wantError: true, + name: "fails with a clear error when the identifier already exists", + cmd: "create control my-control --name 'My Control'" + suite.defaultKosliArguments, + goldenRegex: "^Error: A control with identifier 'my-control' already exists in organization", + }, + { + wantError: true, + name: "fails when the identifier contains invalid characters", + cmd: "create control 'bad identifier' --name 'My Control'" + suite.defaultKosliArguments, + goldenRegex: "^Error: Input payload validation failed:.*Control identifier 'bad identifier' contains invalid characters", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestCreateControlTestSuite(t *testing.T) { + suite.Run(t, new(CreateControlTestSuite)) +} diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index a330c9c99..1c60ed89f 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -26,6 +26,7 @@ func newGetCmd(out io.Writer) *cobra.Command { newGetTrailCmd(out), newGetPolicyCmd(out), newGetAttestationTypeCmd(out), + newGetControlCmd(out), newGetAttestationCmd(out), newGetRepoCmd(out), newGetServiceAccountCmd(out), diff --git a/cmd/kosli/getControl.go b/cmd/kosli/getControl.go new file mode 100644 index 000000000..aa3e2e23a --- /dev/null +++ b/cmd/kosli/getControl.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const getControlShortDesc = `Get a Kosli control.` + +const getControlExample = ` +# get a control: +kosli get control yourControlIdentifier \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type getControlOptions struct { + output string +} + +func newGetControlCmd(out io.Writer) *cobra.Command { + o := new(getControlOptions) + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: getControlShortDesc, + Long: getControlShortDesc, + Example: getControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + + return cmd +} + +func (o *getControlOptions) run(out io.Writer, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org, args[0]) + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: url, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printControlAsTable, + "json": output.PrintJson, + }) +} + +func printControlAsTable(raw string, out io.Writer, page int) error { + var control map[string]interface{} + if err := json.Unmarshal([]byte(raw), &control); err != nil { + return err + } + + rows := []string{} + rows = append(rows, fmt.Sprintf("Identifier:\t%s", control["identifier"])) + rows = append(rows, fmt.Sprintf("Name:\t%s", control["name"])) + if description, ok := control["description"]; ok && description != nil { + rows = append(rows, fmt.Sprintf("Description:\t%s", description)) + } + if version, ok := control["version"]; ok && version != nil { + rows = append(rows, fmt.Sprintf("Version:\t%.0f", version)) + } + if archived, ok := control["archived"]; ok && archived != nil { + rows = append(rows, fmt.Sprintf("Archived:\t%t", archived)) + } + if createdBy, ok := control["created_by"]; ok && createdBy != nil { + rows = append(rows, fmt.Sprintf("Created by:\t%s", createdBy)) + } + if createdAt, ok := control["created_at"]; ok && createdAt != nil { + createdAtFormatted, err := formattedTimestamp(createdAt, false) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("Created at:\t%s", createdAtFormatted)) + } + + if tags, ok := control["tags"].(map[string]interface{}); ok && len(tags) > 0 { + tagKeys := make([]string, 0, len(tags)) + for key := range tags { + tagKeys = append(tagKeys, key) + } + sort.Strings(tagKeys) + tagPairs := make([]string, 0, len(tags)) + for _, key := range tagKeys { + tagPairs = append(tagPairs, fmt.Sprintf("%s=%s", key, tags[key])) + } + rows = append(rows, fmt.Sprintf("Tags:\t%s", strings.Join(tagPairs, ", "))) + } + + if links, ok := control["links"].(map[string]interface{}); ok && len(links) > 0 { + rows = append(rows, "Links:\t") + linkNames := make([]string, 0, len(links)) + for name := range links { + linkNames = append(linkNames, name) + } + sort.Strings(linkNames) + for _, name := range linkNames { + rows = append(rows, fmt.Sprintf("\t%s:\t%s", name, links[name])) + } + } + + if policies, ok := control["policies_referencing"].([]interface{}); ok && len(policies) > 0 { + policyNames := make([]string, 0, len(policies)) + for _, p := range policies { + policyNames = append(policyNames, fmt.Sprintf("%s", p)) + } + rows = append(rows, fmt.Sprintf("Policies referencing:\t%s", strings.Join(policyNames, ", "))) + } + + tabFormattedPrint(out, []string{}, rows) + return nil +} diff --git a/cmd/kosli/getControl_test.go b/cmd/kosli/getControl_test.go new file mode 100644 index 000000000..1e148189e --- /dev/null +++ b/cmd/kosli/getControl_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type GetControlCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *GetControlCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "get-control", "Gettable control", suite.T()) +} + +func (suite *GetControlCommandTestSuite) TestGetControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "get control" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + name: "getting an existing control works", + cmd: "get control get-control" + suite.defaultKosliArguments, + golden: "", + }, + { + name: "getting an existing control as json works", + cmd: "get control get-control --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"identifier", "get-control"}, {"name", "Gettable control"}}, + }, + { + wantError: true, + name: "getting a non-existing control gives a clear error", + cmd: "get control no-such-control" + suite.defaultKosliArguments, + goldenRegex: "^Error: Control 'no-such-control' does not exist in org", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestGetControlCommandTestSuite(t *testing.T) { + suite.Run(t, new(GetControlCommandTestSuite)) +} diff --git a/cmd/kosli/lifecycle_test.go b/cmd/kosli/lifecycle_test.go index 3bc6e20b0..ebf1ec0c7 100644 --- a/cmd/kosli/lifecycle_test.go +++ b/cmd/kosli/lifecycle_test.go @@ -22,20 +22,36 @@ func TestLifecycleEvaluateIsBeta(t *testing.T) { } } -func TestLifecycleAttestDecisionIsBetaAndDocHidden(t *testing.T) { +func TestLifecycleAttestDecisionIsBetaAndVisible(t *testing.T) { global = &GlobalOpts{} cmd := newAttestDecisionCmd(io.Discard) - if !cmd.Hidden { - t.Error("expected attest decision to stay Hidden") + if cmd.Hidden { + t.Error("expected attest decision to be visible (not Hidden)") } if !isBeta(cmd) { t.Error("expected attest decision to be beta") } - if _, ok := cmd.Annotations[docgen.DocHiddenAnnotation]; !ok { - t.Error("expected attest decision to carry the docHidden annotation") + if _, ok := cmd.Annotations[docgen.DocHiddenAnnotation]; ok { + t.Error("expected attest decision to no longer carry the docHidden annotation") } - if !isDocHidden(cmd) { - t.Error("expected isDocHidden to be true for attest decision") + if isDocHidden(cmd) { + t.Error("expected isDocHidden to be false for attest decision") + } +} + +func TestLifecycleControlCommandsAreBeta(t *testing.T) { + global = &GlobalOpts{} + cmds := map[string]*cobra.Command{ + "create control": newCreateControlCmd(io.Discard), + "list controls": newListControlsCmd(io.Discard), + "get control": newGetControlCmd(io.Discard), + "archive control": newArchiveControlCmd(io.Discard), + "unarchive control": newUnarchiveControlCmd(io.Discard), + } + for name, cmd := range cmds { + if !isBeta(cmd) { + t.Errorf("expected %q to be marked beta while controls is behind a feature flag", name) + } } } diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index 94259a931..992efd13c 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -42,6 +42,7 @@ func newListCmd(out io.Writer) *cobra.Command { newListTrailsCmd(out), newListPoliciesCmd(out), newListAttestationTypesCmd(out), + newListControlsCmd(out), newListReposCmd(out), newListApiKeysCmd(out), newListServiceAccountsCmd(out), diff --git a/cmd/kosli/listControls.go b/cmd/kosli/listControls.go new file mode 100644 index 000000000..6a1f2bc5c --- /dev/null +++ b/cmd/kosli/listControls.go @@ -0,0 +1,168 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const listControlsShortDesc = `List controls for an org.` + +const listControlsLongDesc = listControlsShortDesc + ` +The results are paginated; use --page and --page-limit to navigate the pages.` + +const listControlsExample = ` +# list the first page of controls for an org: +kosli list controls \ + --api-token yourAPIToken \ + --org yourOrgName + +# list the second page of controls (10 per page) in JSON: +kosli list controls \ + --page 2 \ + --page-limit 10 \ + --output json \ + --api-token yourAPIToken \ + --org yourOrgName + +# list controls whose name or identifier contains "sdlc": +kosli list controls \ + --search sdlc \ + --api-token yourAPIToken \ + --org yourOrgName + +# list controls tagged framework=finos-sdlc (--tag can be repeated): +kosli list controls \ + --tag framework:finos-sdlc \ + --api-token yourAPIToken \ + --org yourOrgName + +# list archived controls instead of active ones: +kosli list controls \ + --archived \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type listControlsOptions struct { + listOptions + search string + tags []string + archived bool +} + +type listControlsResponse struct { + Controls []map[string]interface{} `json:"controls"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + TotalCount int `json:"total_count"` +} + +func newListControlsCmd(out io.Writer) *cobra.Command { + o := new(listControlsOptions) + cmd := &cobra.Command{ + Use: "controls", + Short: listControlsShortDesc, + Long: listControlsLongDesc, + Example: listControlsExample, + Args: cobra.NoArgs, + Annotations: map[string]string{betaCLIAnnotation: ""}, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return o.validate(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out) + }, + } + + addListFlags(cmd, &o.listOptions) + cmd.Flags().StringVar(&o.search, "search", "", controlSearchFlag) + cmd.Flags().StringArrayVar(&o.tags, "tag", []string{}, controlTagFlag) + cmd.Flags().BoolVar(&o.archived, "archived", false, controlArchivedFlag) + + return cmd +} + +func (o *listControlsOptions) run(out io.Writer) error { + base, err := url.JoinPath(global.Host, "api/v2/controls", global.Org) + if err != nil { + return err + } + + params := url.Values{} + params.Set("page", strconv.Itoa(o.pageNumber)) + params.Set("per_page", strconv.Itoa(o.pageLimit)) + if o.search != "" { + params.Set("search", o.search) + } + for _, tag := range o.tags { + params.Add("tag", tag) + } + if o.archived { + params.Set("archived", "true") + } + reqURL := base + "?" + params.Encode() + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: reqURL, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, o.pageNumber, + map[string]output.FormatOutputFunc{ + "table": printControlsListAsTable, + "json": output.PrintJson, + }) +} + +func printControlsListAsTable(raw string, out io.Writer, page int) error { + response := &listControlsResponse{} + if err := json.Unmarshal([]byte(raw), response); err != nil { + return err + } + + if len(response.Controls) == 0 { + msg := "No controls were found" + if page != 1 { + msg = fmt.Sprintf("%s at page number %d", msg, page) + } + logger.Info(msg + ".") + return nil + } + + header := []string{"IDENTIFIER", "NAME", "DESCRIPTION", "CREATED AT"} + rows := []string{} + for _, control := range response.Controls { + description := control["description"] + if description == nil { + description = "" + } + createdAt := "" + if control["created_at"] != nil { + createdAt, _ = formattedTimestamp(control["created_at"], true) + } + rows = append(rows, fmt.Sprintf("%s\t%s\t%s\t%s", control["identifier"], control["name"], description, createdAt)) + } + + rows = append(rows, fmt.Sprintf("\nShowing page %d of %d, total %d controls", response.Page, response.TotalPages, response.TotalCount)) + + tabFormattedPrint(out, header, rows) + + return nil +} diff --git a/cmd/kosli/listControls_test.go b/cmd/kosli/listControls_test.go new file mode 100644 index 000000000..347e803b0 --- /dev/null +++ b/cmd/kosli/listControls_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ListControlsCommandTestSuite struct { + suite.Suite + defaultKosliArguments string + acmeOrgKosliArguments string +} + +func (suite *ListControlsCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "list-control-1", "First control", suite.T()) + CreateControl(global.Org, "list-control-2", "Second control", suite.T()) + + // fixtures for filtering: a tagged control and an archived control with + // unique identifiers/tags so assertions are robust to shared server state. + CreateControl(global.Org, "tagged-control", "Tagged control", suite.T()) + TagControl(global.Org, "tagged-control", map[string]string{"slice2b": "tagtest"}, suite.T()) + CreateControl(global.Org, "archived-control", "Archived control", suite.T()) + ArchiveControl(global.Org, "archived-control", suite.T()) + + global.Org = "acme-org" + global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c" + suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) +} + +func (suite *ListControlsCommandTestSuite) TestListControlsCmd() { + tests := []cmdTestCase{ + { + name: "listing controls works when some exist", + cmd: "list controls" + suite.defaultKosliArguments, + golden: "", + }, + { + name: "listing controls works when there are none", + cmd: "list controls" + suite.acmeOrgKosliArguments, + golden: "No controls were found.\n", + }, + { + name: "listing controls with --output json works when some exist", + cmd: "list controls --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "non-empty"}}, + }, + { + name: "listing controls with --output json works when there are none", + cmd: "list controls --output json" + suite.acmeOrgKosliArguments, + goldenJson: []jsonCheck{{"controls", "[]"}}, + }, + { + wantError: true, + name: "providing an argument causes an error", + cmd: "list controls xxx" + suite.defaultKosliArguments, + golden: "Error: unknown command \"xxx\" for \"kosli list controls\"\n", + }, + { + name: "--page-limit caps the page size and the response echoes the pagination params", + cmd: "list controls --page-limit 1 --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"page", float64(1)}, {"per_page", float64(1)}}, + }, + { + name: "a page beyond the data returns an empty controls list (json)", + cmd: "list controls --page 999 --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "[]"}}, + }, + { + name: "a page beyond the data reports the empty page (table)", + cmd: "list controls --page 999" + suite.defaultKosliArguments, + golden: "No controls were found at page number 999.\n", + }, + { + wantError: true, + name: "--page must be a positive integer", + cmd: "list controls --page 0" + suite.defaultKosliArguments, + goldenRegex: "^Error: page number must be a positive integer", + }, + { + wantError: true, + name: "--page-limit must be a positive integer", + cmd: "list controls --page-limit 0" + suite.defaultKosliArguments, + goldenRegex: "^Error: page limit must be a positive integer", + }, + { + name: "--search matches by identifier", + cmd: "list controls --search list-control-1 --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"controls.[0].identifier", "list-control-1"}}, + }, + { + name: "--tag returns only controls carrying that tag", + cmd: "list controls --tag slice2b:tagtest --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"controls.[0].identifier", "tagged-control"}}, + }, + { + name: "archived controls are excluded by default", + cmd: "list controls --search archived-control --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "[]"}}, + }, + { + name: "--archived returns archived controls", + cmd: "list controls --archived --search archived-control --output json" + suite.defaultKosliArguments, + goldenJson: []jsonCheck{{"controls", "length:1"}, {"controls.[0].identifier", "archived-control"}}, + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestListControlsCommandTestSuite(t *testing.T) { + suite.Run(t, new(ListControlsCommandTestSuite)) +} diff --git a/cmd/kosli/openapiContract_test.go b/cmd/kosli/openapiContract_test.go new file mode 100644 index 000000000..639c51ad1 --- /dev/null +++ b/cmd/kosli/openapiContract_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// OpenAPIContractTestSuite guards against drift between the CLI's hand-written +// request payload structs and the Kosli API's OpenAPI schema. It fetches the +// live schema from the test server (the same image the integration tests run +// against) and, for each registered case, asserts that: +// +// - every json field the CLI struct sends exists as a property in the schema +// component (so the CLI never sends a field the server would reject — the +// control/env inputs are declared extra="forbid"), and +// - every property the schema marks required is present in the CLI struct (so +// the CLI can always satisfy the server's required fields). +// +// Optional schema properties the CLI does not (yet) expose are allowed and only +// logged — that is a deliberate coverage gap, not drift. +// +// To extend coverage, add one line to the registry in TestPayloadsMatchSchema. +type OpenAPIContractTestSuite struct { + suite.Suite + schema openAPISchema +} + +type openAPISchema struct { + Components struct { + Schemas map[string]componentSchema `json:"schemas"` + } `json:"components"` +} + +type componentSchema struct { + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` +} + +// driftCase maps a CLI payload struct to the OpenAPI component it must match. +type driftCase struct { + name string + payload interface{} + component string + // ignore lists json field names to skip on both sides, for deliberate + // CLI/API divergences (none needed yet). + ignore []string +} + +func (suite *OpenAPIContractTestSuite) SetupSuite() { + resp, err := http.Get("http://localhost:8001/api/v2/openapi.json") + require.NoError(suite.T(), err, "should fetch the OpenAPI schema from the test server") + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + require.NoError(suite.T(), err) + require.NoError(suite.T(), json.Unmarshal(body, &suite.schema), "OpenAPI schema should be valid JSON") + require.NotEmpty(suite.T(), suite.schema.Components.Schemas, "OpenAPI schema should contain components") +} + +func (suite *OpenAPIContractTestSuite) TestPayloadsMatchSchema() { + registry := []driftCase{ + {name: "create control", payload: ControlPayload{}, component: "ControlPostInput"}, + {name: "create environment", payload: CreateEnvironmentPayload{}, component: "CreateEnvironmentPutInput"}, + } + + for _, c := range registry { + suite.Run(c.name, func() { + t := suite.T() + component, ok := suite.schema.Components.Schemas[c.component] + require.True(t, ok, "OpenAPI component %q not found — it may have been renamed or removed", c.component) + + structFields := jsonFieldNames(c.payload) + + // 1. Every field the CLI sends must exist in the schema. + for _, field := range structFields { + if contains(c.ignore, field) { + continue + } + _, exists := component.Properties[field] + require.True(t, exists, + "CLI struct %T sends field %q which is not a property of OpenAPI component %q — schema drift", + c.payload, field, c.component) + } + + // 2. Every required schema property must be covered by the CLI struct. + for _, req := range component.Required { + if contains(c.ignore, req) { + continue + } + require.Contains(t, structFields, req, + "OpenAPI component %q requires %q but CLI struct %T does not send it — schema drift", + c.component, req, c.payload) + } + + // Surface (without failing) optional schema properties the CLI does + // not expose yet, so the coverage gap is visible. + for prop := range component.Properties { + if !contains(structFields, prop) && !contains(c.ignore, prop) { + t.Logf("note: OpenAPI component %q has property %q not exposed by CLI struct %T", c.component, prop, c.payload) + } + } + }) + } +} + +// jsonFieldNames returns the wire names from a struct's json tags. +func jsonFieldNames(v interface{}) []string { + t := reflect.TypeOf(v) + names := []string{} + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "" || name == "-" { + continue + } + names = append(names, name) + } + return names +} + +func contains(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} + +func TestOpenAPIContractTestSuite(t *testing.T) { + suite.Run(t, new(OpenAPIContractTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 23ded268b..51d09f23e 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -298,6 +298,11 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationTypeDescriptionFlag = "[optional] The attestation type description." attestationTypeSchemaFlag = "[optional] Path to the attestation type schema in JSON Schema format." attestationTypeJqFlag = "[optional] The attestation type evaluation JQ rules." + controlNameFlag = "[required] The control name." + controlDescriptionFlag = "[optional] The control description." + controlSearchFlag = "[optional] Only list controls whose name or identifier contains this substring (case-insensitive)." + controlTagFlag = "[optional] Filter by tag, given as 'key' or 'key:value'. Can be repeated." + controlArchivedFlag = "[optional] List archived controls instead of active ones." envNameFlag = "The Kosli environment name to assert the artifact against." pathsWatchFlag = "[optional] Watch the filesystem for changes and report snapshots of artifacts running in specific filesystem paths to Kosli." getAttestationFingerprintFlag = "[conditional] The fingerprint of the artifact for the attestation. Cannot be used together with --trail or --attestation-id." @@ -435,6 +440,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { newRenameCmd(out), newJoinCmd(out), newArchiveCmd(out), + newUnarchiveCmd(out), newSnapshotCmd(out), newRequestCmd(out), newLogCmd(out), diff --git a/cmd/kosli/tag.go b/cmd/kosli/tag.go index b2e307ccf..efde61b00 100644 --- a/cmd/kosli/tag.go +++ b/cmd/kosli/tag.go @@ -56,6 +56,12 @@ kosli tag env yourEnvironmentName \ --unset key1=value1 \ --api-token yourApiToken \ --org yourOrgName + +# tag a control +kosli tag control yourControlIdentifier \ + --set key1=value1 \ + --api-token yourApiToken \ + --org yourOrgName ` func newTagCmd(out io.Writer) *cobra.Command { @@ -143,7 +149,7 @@ func (o *tagOptions) run(args []string) error { } func validateResourceType(resourceType string) error { - options := []string{"flow", "flows", "env", "environment", "environments"} + options := []string{"flow", "flows", "env", "environment", "environments", "control", "controls"} match := false for _, opt := range options { if resourceType == opt { diff --git a/cmd/kosli/tag_test.go b/cmd/kosli/tag_test.go index 9e3962236..b9add627a 100644 --- a/cmd/kosli/tag_test.go +++ b/cmd/kosli/tag_test.go @@ -16,12 +16,14 @@ type TagTestSuite struct { flowName string envName string envType string + controlID string } func (suite *TagTestSuite) SetupTest() { suite.flowName = "tag-flow" suite.envName = "tag-env" suite.envType = "K8S" + suite.controlID = "tag-control" global = &GlobalOpts{ ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", Org: "docs-cmd-test-user", @@ -31,6 +33,7 @@ func (suite *TagTestSuite) SetupTest() { CreateFlow(suite.flowName, suite.T()) CreateEnv(global.Org, suite.envName, suite.envType, suite.T()) + CreateControl(global.Org, suite.controlID, "Tag control", suite.T()) } func (suite *TagTestSuite) TestTagCmd() { @@ -65,6 +68,27 @@ func (suite *TagTestSuite) TestTagCmd() { cmd: fmt.Sprintf("tag flow %s --set foo=bar %s", suite.flowName, suite.defaultKosliArguments), golden: "Tag(s) [foo] added for flow 'tag-flow'\n", }, + { + name: "can tag a control", + cmd: fmt.Sprintf("tag control %s --set foo=bar %s", suite.controlID, suite.defaultKosliArguments), + golden: "Tag(s) [foo] added for control 'tag-control'\n", + }, + { + name: "can remove a tag from a control", + cmd: fmt.Sprintf("tag control %s --unset foo %s", suite.controlID, suite.defaultKosliArguments), + golden: "Tag(s) [foo] removed for control 'tag-control'\n", + }, + { + name: "can tag a control using the plural resource type", + cmd: fmt.Sprintf("tag controls %s --set key=value %s", suite.controlID, suite.defaultKosliArguments), + golden: "Tag(s) [key] added for controls 'tag-control'\n", + }, + { + wantError: true, + name: "tagging a non-existing control gives a clear error", + cmd: fmt.Sprintf("tag control no-such-control --set foo=bar %s", suite.defaultKosliArguments), + goldenRegex: "^Error: \"Control 'no-such-control' does not exist in organization", + }, } runTestCmd(suite.T(), tests) } diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index 271446b19..c342a6f09 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -544,6 +544,35 @@ func CreateControl(org, identifier, name string, t *testing.T) { require.NoError(t, err, "control should be created without error") } +func ArchiveControl(org, identifier string, t *testing.T) { + t.Helper() + u, err := url.JoinPath(global.Host, "api/v2/controls", org, identifier, "archive") + require.NoError(t, err, "control archive URL should be constructed without error") + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: u, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + require.NoError(t, err, "control should be archived without error") +} + +func TagControl(org, identifier string, tags map[string]string, t *testing.T) { + t.Helper() + u, err := url.JoinPath(global.Host, "api/v2/tags", org, "control", identifier) + require.NoError(t, err, "control tag URL should be constructed without error") + + reqParams := &requests.RequestParams{ + Method: http.MethodPatch, + URL: u, + Payload: TagResourcePayload{SetTags: tags}, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + require.NoError(t, err, "control should be tagged without error") +} + // CreatePolicy creates a policy on the server func CreatePolicy(org, policyName string, t *testing.T) { t.Helper() diff --git a/cmd/kosli/unarchive.go b/cmd/kosli/unarchive.go new file mode 100644 index 000000000..0acf49aaf --- /dev/null +++ b/cmd/kosli/unarchive.go @@ -0,0 +1,23 @@ +package main + +import ( + "io" + + "github.com/spf13/cobra" +) + +const unarchiveDesc = `All Kosli unarchive commands.` + +func newUnarchiveCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "unarchive", + Short: unarchiveDesc, + Long: unarchiveDesc, + } + + // Add subcommands + cmd.AddCommand( + newUnarchiveControlCmd(out), + ) + return cmd +} diff --git a/cmd/kosli/unarchiveControl.go b/cmd/kosli/unarchiveControl.go new file mode 100644 index 000000000..75ee96538 --- /dev/null +++ b/cmd/kosli/unarchiveControl.go @@ -0,0 +1,61 @@ +package main + +import ( + "io" + "net/http" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const unarchiveControlShortDesc = `Unarchive a Kosli control.` + +const unarchiveControlLongDesc = unarchiveControlShortDesc + ` +Restores a previously archived control to the active state. +` + +const unarchiveControlExample = ` +# unarchive a Kosli control: +kosli unarchive control yourControlIdentifier \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newUnarchiveControlCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "control CONTROL-IDENTIFIER", + Short: unarchiveControlShortDesc, + Long: unarchiveControlLongDesc, + Example: unarchiveControlExample, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{betaCLIAnnotation: ""}, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/controls", global.Org, args[0], "unarchive") + if err != nil { + return err + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("control %s was unarchived", args[0]) + } + return err + }, + } + addDryRunFlag(cmd) + return cmd +} diff --git a/cmd/kosli/unarchiveControl_test.go b/cmd/kosli/unarchiveControl_test.go new file mode 100644 index 000000000..bc718a5f1 --- /dev/null +++ b/cmd/kosli/unarchiveControl_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type UnarchiveControlCommandTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *UnarchiveControlCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "unarchive-me", "Unarchive me", suite.T()) + ArchiveControl(global.Org, "unarchive-me", suite.T()) +} + +func (suite *UnarchiveControlCommandTestSuite) TestUnarchiveControlCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when no identifier argument is provided", + cmd: "unarchive control" + suite.defaultKosliArguments, + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + name: "unarchiving an archived control works", + cmd: "unarchive control unarchive-me" + suite.defaultKosliArguments, + golden: "control unarchive-me was unarchived\n", + }, + { + wantError: true, + name: "unarchiving a non-existing control gives a clear error", + cmd: "unarchive control no-such-control" + suite.defaultKosliArguments, + goldenRegex: "^Error: Control 'no-such-control' does not exist in org", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestUnarchiveControlCommandTestSuite(t *testing.T) { + suite.Run(t, new(UnarchiveControlCommandTestSuite)) +} diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 9ab5a8287..06b027d61 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -97,8 +97,17 @@ type RequestParams struct { Password string Token string DryRun bool + // DisableConflictRetry stops the client retrying on HTTP 409 for this + // request. By default 409 is retried (it signals a transient lock + // conflict), but some endpoints use 409 for a permanent client error + // (e.g. a duplicate identifier) that should be surfaced immediately. + DisableConflictRetry bool } +type contextKey string + +const disableConflictRetryKey contextKey = "disableConflictRetry" + func (p *RequestParams) newHTTPRequest() (*http.Request, map[string]any, error) { if len(p.AdditionalHeaders) == 0 { p.AdditionalHeaders = make(map[string]string) @@ -132,6 +141,10 @@ func (p *RequestParams) newHTTPRequest() (*http.Request, map[string]any, error) return nil, nil, fmt.Errorf("failed to create %s request to %s : %v", p.Method, p.URL, err) } + if p.DisableConflictRetry { + req = req.WithContext(context.WithValue(req.Context(), disableConflictRetryKey, true)) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("User-Agent", "Kosli/"+version.GetVersion()) @@ -340,8 +353,12 @@ func customCheckRetry(ctx context.Context, resp *http.Response, err error) (bool if shouldRetry { return true, nil } - // The server gives 409 if we have a lock conflict. + // The server gives 409 if we have a lock conflict, so retry — unless the + // request opted out (some endpoints use 409 for a permanent client error). if resp != nil && resp.StatusCode == 409 { + if disable, ok := ctx.Value(disableConflictRetryKey).(bool); ok && disable { + return false, nil + } return true, nil } return false, nil diff --git a/internal/requests/requests_test.go b/internal/requests/requests_test.go index 02e0df501..32eb436b2 100644 --- a/internal/requests/requests_test.go +++ b/internal/requests/requests_test.go @@ -56,6 +56,10 @@ func (suite *RequestsTestSuite) SetupSuite() { Get("/locked/"). Reply(409). BodyString("resource temporarily locked") + suite.fakeService.NewHandler(). + Get("/conflict/"). + Reply(409). + BodyString(`{"message": "already exists"}`) suite.fakeService.NewHandler(). Get("/fail/"). Reply(500). @@ -307,6 +311,16 @@ func (suite *RequestsTestSuite) TestDo() { wantError: true, expectedErrorMsg: fmt.Sprintf("Get \"%s\": GET %s giving up after 2 attempt(s)", suite.fakeService.ResolveURL("/locked/"), suite.fakeService.ResolveURL("/locked/")), }, + { + name: "409 is not retried and is surfaced when DisableConflictRetry is set", + params: &RequestParams{ + Method: http.MethodGet, + URL: suite.fakeService.ResolveURL("/conflict/"), + DisableConflictRetry: true, + }, + wantError: true, + expectedErrorMsg: "already exists", + }, { name: "GET request to 500 endpoint", params: &RequestParams{