From 181ed04d238793b1ddbb661f39c344e7efd4ca73 Mon Sep 17 00:00:00 2001 From: Simon Castagna Date: Tue, 13 Oct 2026 10:15:25 +0200 Subject: [PATCH 1/4] feat(list): hidden kosli list rego-policies, list the rego policies of an org Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/list.go | 1 + cmd/kosli/listRegoPolicies.go | 166 ++++++++++++++++ cmd/kosli/listRegoPolicies_test.go | 186 ++++++++++++++++++ cmd/kosli/root.go | 3 + .../testdata/empty-flag-audit-coverage.json | 8 + hack/empty-flag-audit/spec.json | 25 +++ 6 files changed, 389 insertions(+) create mode 100644 cmd/kosli/listRegoPolicies.go create mode 100644 cmd/kosli/listRegoPolicies_test.go diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index 44d665b85..d46605b50 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -45,6 +45,7 @@ func newListCmd(out io.Writer) *cobra.Command { newListReposCmd(out), newListApiKeysCmd(out), newListServiceAccountsCmd(out), + newListRegoPoliciesCmd(out), ) return cmd diff --git a/cmd/kosli/listRegoPolicies.go b/cmd/kosli/listRegoPolicies.go new file mode 100644 index 000000000..77a7bd5bc --- /dev/null +++ b/cmd/kosli/listRegoPolicies.go @@ -0,0 +1,166 @@ +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 listRegoPoliciesShortDesc = `List rego policies for an org.` + +const listRegoPoliciesLongDesc = listRegoPoliciesShortDesc + ` +The results are paginated; use ^--page^ and ^--page-limit^ to navigate the pages. +By default, the policies changed most recently are listed first.` + +const listRegoPoliciesExample = ` +# list the first page of rego policies for an org: +kosli list rego-policies \ + --api-token yourAPIToken \ + --org yourOrgName + +# list the second page of rego policies (50 per page) in JSON: +kosli list rego-policies \ + --page 2 \ + --page-limit 50 \ + --output json \ + --api-token yourAPIToken \ + --org yourOrgName + +# list rego policies whose name or identifier contains "approved": +kosli list rego-policies \ + --search approved \ + --api-token yourAPIToken \ + --org yourOrgName + +# list rego policies in identifier order: +kosli list rego-policies \ + --sort identifier \ + --sort-direction asc \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type listRegoPoliciesOptions struct { + listOptions + search string + sort string + sortDirection string +} + +type listRegoPoliciesResponse struct { + RegoPolicies []map[string]any `json:"rego_policies"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + TotalCount int `json:"total_count"` +} + +func newListRegoPoliciesCmd(out io.Writer) *cobra.Command { + o := new(listRegoPoliciesOptions) + cmd := &cobra.Command{ + Use: "rego-policies", + Short: listRegoPoliciesShortDesc, + Long: listRegoPoliciesLongDesc, + Example: listRegoPoliciesExample, + Args: cobra.NoArgs, + // Hidden while the server gates the endpoint behind is-server-side-evaluation-enabled. + Hidden: true, + 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", "", regoPolicySearchFlag) + cmd.Flags().StringVar(&o.sort, "sort", "", regoPolicySortFlag) + cmd.Flags().StringVar(&o.sortDirection, "sort-direction", "", regoPolicySortDirectionFlag) + + return cmd +} + +func (o *listRegoPoliciesOptions) run(out io.Writer) error { + base, err := url.JoinPath(global.Host, "api/v2/rego-policies", 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) + } + if o.sort != "" { + params.Set("sort_by", o.sort) + } + if o.sortDirection != "" { + params.Set("sort_direction", o.sortDirection) + } + + response, err := kosliClient.Do(&requests.RequestParams{ + Method: http.MethodGet, + URL: base + "?" + params.Encode(), + Token: global.ApiToken, + }) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, o.pageNumber, + map[string]output.FormatOutputFunc{ + "table": printRegoPoliciesListAsTable, + "json": output.PrintJson, + }) +} + +func printRegoPoliciesListAsTable(raw string, out io.Writer, page int) error { + response := &listRegoPoliciesResponse{} + if err := json.Unmarshal([]byte(raw), response); err != nil { + return err + } + + if len(response.RegoPolicies) == 0 { + msg := "No rego policies 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 BY", "LAST UPDATED"} + rows := []string{} + for _, policy := range response.RegoPolicies { + description := policy["description"] + if description == nil { + description = "" + } + updatedAt, err := formattedTimestamp(policy["updated_at"], true) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("%s\t%s\t%s\t%s\t%s", + policy["identifier"], policy["name"], description, policy["created_by"], updatedAt)) + } + + rows = append(rows, fmt.Sprintf("\nShowing page %d of %d, total %d rego policies", + response.Page, response.TotalPages, response.TotalCount)) + + tabFormattedPrint(out, header, rows) + + return nil +} diff --git a/cmd/kosli/listRegoPolicies_test.go b/cmd/kosli/listRegoPolicies_test.go new file mode 100644 index 000000000..140ee169f --- /dev/null +++ b/cmd/kosli/listRegoPolicies_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + regoPoliciesPage = `{"page":1,"per_page":15,"total_pages":1,"total_count":2,"rego_policies":[` + + `{"identifier":"pr-approved","name":"Pull request approved","description":"Approved PRs only",` + + `"created_at":1757000000.5,"created_by":"Jane Doe","updated_at":1757000100.5},` + + `{"identifier":"release-ready","name":"Release ready",` + + `"created_at":1757000200.5,"created_by":"John Roe","updated_at":1757000300.5}]}` + regoPoliciesEmptyPage = `{"page":%d,"per_page":15,"total_pages":0,"total_count":0,"rego_policies":[]}` +) + +// fakeRegoPolicyList records the list request and answers with body and status. +type fakeRegoPolicyList struct { + body string + status int + path string + query url.Values + calls int +} + +func newFakeRegoPolicyList(t *testing.T, body string) (*httptest.Server, *fakeRegoPolicyList) { + t.Helper() + fake := &fakeRegoPolicyList{body: body, status: http.StatusOK} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fake.calls++ + fake.path = r.URL.Path + fake.query = r.URL.Query() + w.WriteHeader(fake.status) + _, _ = fmt.Fprint(w, fake.body) + })) + t.Cleanup(server.Close) + return server, fake +} + +type ListRegoPoliciesCommandTestSuite struct { + suite.Suite +} + +func (suite *ListRegoPoliciesCommandTestSuite) SetupTest() { + // Makes formattedTimestamp print a fixed UTC time. + suite.T().Setenv("KOSLI_TESTS", "true") +} + +func (suite *ListRegoPoliciesCommandTestSuite) listCmd(host, args string) string { + return fmt.Sprintf("list rego-policies %s --host %s --org test-org --api-token test-token --max-api-retries 0", + args, host) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItIsHiddenAndBeta() { + root, err := newRootCmd(io.Discard, io.Discard, nil) + require.NoError(suite.T(), err) + cmd, _, err := root.Find([]string{"list", "rego-policies"}) + require.NoError(suite.T(), err) + + require.Equal(suite.T(), "rego-policies", cmd.Name()) + require.True(suite.T(), cmd.Hidden) + require.Contains(suite.T(), cmd.Annotations, betaCLIAnnotation) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItNeedsTheOrgAndApiToken() { + server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + + _, combined, _, _, err := executeCommandC( + "list rego-policies --org '' --api-token '' --host " + server.URL) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "--org") + require.Equal(suite.T(), 0, fake.calls) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItTakesNoArguments() { + server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + + _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "pr-approved")) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, `unknown command "pr-approved"`) + require.Equal(suite.T(), 0, fake.calls) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItReadsTheFirstPageOf15ByDefault() { + server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + + _, _, _, _, err := executeCommandC(suite.listCmd(server.URL, "")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "/api/v2/rego-policies/test-org", fake.path) + require.Equal(suite.T(), url.Values{"page": {"1"}, "per_page": {"15"}}, fake.query) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItPrintsATable() { + server, _ := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + + _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), + "IDENTIFIER NAME DESCRIPTION CREATED BY LAST UPDATED\n"+ + "pr-approved Pull request approved Approved PRs only Jane Doe Sat, 16 Jan 2016 00:00:00 UTC\n"+ + "release-ready Release ready John Roe Sat, 16 Jan 2016 00:00:00 UTC\n"+ + "\n"+ + "Showing page 1 of 1, total 2 rego policies\n", + combined) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItSaysWhenThereAreNoPolicies() { + server, _ := newFakeRegoPolicyList(suite.T(), fmt.Sprintf(regoPoliciesEmptyPage, 1)) + + _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "No rego policies were found.\n", combined) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItNamesThePageWhenAPageAfterTheFirstIsEmpty() { + server, _ := newFakeRegoPolicyList(suite.T(), fmt.Sprintf(regoPoliciesEmptyPage, 3)) + + _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "--page 3")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "No rego policies were found at page number 3.\n", combined) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItPrintsTheServerAnswerAsJSON() { + server, _ := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + + _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "--output json")) + + require.NoError(suite.T(), err) + require.JSONEq(suite.T(), regoPoliciesPage, combined) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItSendsThePagingSearchAndSort() { + server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + + _, _, _, _, err := executeCommandC(suite.listCmd(server.URL, + "--page 2 --page-limit 50 --search approved --sort identifier --sort-direction asc")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), url.Values{ + "page": {"2"}, + "per_page": {"50"}, + "search": {"approved"}, + "sort_by": {"identifier"}, + "sort_direction": {"asc"}, + }, fake.query) +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItRefusesAPageOrPageLimitThatIsNotPositive() { + for _, args := range []string{"--page 0", "--page-limit 0"} { + server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + + _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, args)) + + require.Error(suite.T(), err, args) + require.Contains(suite.T(), combined, "must be a positive integer", args) + require.Equal(suite.T(), 0, fake.calls, args) + } +} + +func (suite *ListRegoPoliciesCommandTestSuite) TestItReturnsAServerError() { + server, fake := newFakeRegoPolicyList(suite.T(), + `{"message":"User 'jane' does not have access to organization 'test-org'"}`) + fake.status = http.StatusForbidden + + _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "")) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "does not have access to organization 'test-org'") +} + +func TestListRegoPoliciesCommandTestSuite(t *testing.T) { + suite.Run(t, new(ListRegoPoliciesCommandTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 700d2fe80..6eb95a9bf 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -162,6 +162,9 @@ Paths the list already matches stay excluded whatever is later added there, so k regoPolicyNameFlag = "[conditional] The display name of the rego policy. Only required when the identifier is new; without it the current name is kept." regoPolicyDescriptionFlag = "[optional] The description of the rego policy. Without it the current description is kept." regoPolicyCommentFlag = "[optional] Why this version is published. It is kept with the version." + regoPolicySearchFlag = "[optional] Only list rego policies whose name or identifier contains this substring (case-insensitive)." + regoPolicySortFlag = "[optional] The field to sort rego policies by. Valid values are: [updated_at, identifier]. (defaults to updated_at)" + regoPolicySortDirectionFlag = "[optional] The direction to sort rego policies in. Valid values are: [asc, desc]. (defaults to desc)" outputFlagWithMarkdown = "[defaulted] The format of the output. Valid formats are: [table, json, markdown]." searchByNameFlag = "[optional] Only list flows whose name contains this substring. The Kosli API supports alphanumeric characters and '-'." ignoreCaseFlag = "[optional] Perform case-insensitive matching for --name. By default matching is case sensitive." diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index 839257c27..7582498ba 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -740,6 +740,14 @@ "list policies": { "output": "string" }, + "list rego-policies": { + "output": "string", + "page": "int", + "page-limit": "int", + "search": "string", + "sort": "string", + "sort-direction": "string" + }, "list repos": { "name": "string", "output": "string", diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index af6358cb4..f30ce97cc 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -1551,6 +1551,31 @@ "setup": [], "verify": [] }, + "list rego-policies": { + "args": [], + "flags": {}, + "baseline_ok": false, + "error": "the server the audit runs against does not offer the rego policy endpoints", + "needs": "server-side evaluation", + "flags_to_test": [ + "output", + "page", + "page-limit", + "search", + "sort", + "sort-direction" + ], + "flag_values": { + "output": "json", + "page": "1", + "page-limit": "5", + "search": "probe-search", + "sort": "identifier", + "sort-direction": "asc" + }, + "setup": [], + "verify": [] + }, "create service-account": { "args": [ "{account}" From 471e9326e1269a660218c219fdf0d9359de58669 Mon Sep 17 00:00:00 2001 From: Simon Castagna Date: Tue, 13 Oct 2026 10:38:30 +0200 Subject: [PATCH 2/4] refactor(regopolicies): export the endpoint URLs, so every rego policy command escapes the identifier the same way Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/listRegoPolicies.go | 3 ++- internal/regopolicies/client.go | 26 +++++++++++---------- internal/regopolicies/urls_test.go | 36 ++++++++++++++++++++++++++++++ internal/regopolicies/wait.go | 2 +- 4 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 internal/regopolicies/urls_test.go diff --git a/cmd/kosli/listRegoPolicies.go b/cmd/kosli/listRegoPolicies.go index 77a7bd5bc..9a4853b37 100644 --- a/cmd/kosli/listRegoPolicies.go +++ b/cmd/kosli/listRegoPolicies.go @@ -9,6 +9,7 @@ import ( "strconv" "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/regopolicies" "github.com/kosli-dev/cli/internal/requests" "github.com/spf13/cobra" ) @@ -93,7 +94,7 @@ func newListRegoPoliciesCmd(out io.Writer) *cobra.Command { } func (o *listRegoPoliciesOptions) run(out io.Writer) error { - base, err := url.JoinPath(global.Host, "api/v2/rego-policies", global.Org) + base, err := regopolicies.ListURL(global.Host, global.Org) if err != nil { return err } diff --git a/internal/regopolicies/client.go b/internal/regopolicies/client.go index ad9103efa..887ba695f 100644 --- a/internal/regopolicies/client.go +++ b/internal/regopolicies/client.go @@ -79,11 +79,7 @@ func NewClient(httpClient *requests.Client, host, token string, dryRun bool) *Cl // Publish stores the next version of a policy and returns it pending. A dry run // sends nothing and returns no version. func (c *Client) Publish(org string, request PublishRequest) (*Version, error) { - route, err := policyRoute(org, request.Identifier) - if err != nil { - return nil, err - } - endpoint, err := url.JoinPath(c.host, route) + endpoint, err := PolicyURL(c.host, org, request.Identifier) if err != nil { return nil, err } @@ -116,7 +112,7 @@ func (c *Client) Publish(org string, request PublishRequest) (*Version, error) { // GetVersion reads one version of a policy. A pending one is a normal answer, // not an error. func (c *Client) GetVersion(org, identifier string, version int) (*Version, error) { - endpoint, err := versionURL(c.host, org, identifier, version) + endpoint, err := VersionURL(c.host, org, identifier, version) if err != nil { return nil, err } @@ -137,22 +133,28 @@ func (c *Client) GetVersion(org, identifier string, version int) (*Version, erro return decodeVersion(response.Body) } -// policyRoute escapes the identifier into one path segment. It refuses "." and +// ListURL is the endpoint that lists the rego policies of an org. +func ListURL(host, org string) (string, error) { + return url.JoinPath(host, "/api/v2/rego-policies/"+url.PathEscape(org)) +} + +// PolicyURL escapes the identifier into one path segment. It refuses "." and // "..", which a URL path resolves however they are escaped. -func policyRoute(org, identifier string) (string, error) { +func PolicyURL(host, org, identifier string) (string, error) { switch identifier { case "", ".", "..": return "", fmt.Errorf("%q is not a valid rego policy identifier", identifier) } - return "/api/v2/rego-policies/" + url.PathEscape(org) + "/" + url.PathEscape(identifier), nil + return url.JoinPath(host, "/api/v2/rego-policies/"+url.PathEscape(org)+"/"+url.PathEscape(identifier)) } -func versionURL(host, org, identifier string, version int) (string, error) { - route, err := policyRoute(org, identifier) +// VersionURL is the endpoint of one version of a policy. +func VersionURL(host, org, identifier string, version int) (string, error) { + endpoint, err := PolicyURL(host, org, identifier) if err != nil { return "", err } - return url.JoinPath(host, route, "versions", strconv.Itoa(version)) + return url.JoinPath(endpoint, "versions", strconv.Itoa(version)) } // The server refuses unknown fields, so the wire shape is stated here exactly. diff --git a/internal/regopolicies/urls_test.go b/internal/regopolicies/urls_test.go new file mode 100644 index 000000000..5a2aa698a --- /dev/null +++ b/internal/regopolicies/urls_test.go @@ -0,0 +1,36 @@ +package regopolicies + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPolicyURLKeepsTheIdentifierInOneSegment(t *testing.T) { + endpoint, err := PolicyURL("https://app.kosli.com", "my-org", "../other/x") + + require.NoError(t, err) + require.Equal(t, "https://app.kosli.com/api/v2/rego-policies/my-org/..%2Fother%2Fx", endpoint) +} + +func TestPolicyURLRefusesAnIdentifierThatIsNotASegment(t *testing.T) { + for _, identifier := range []string{"", ".", ".."} { + _, err := PolicyURL("https://app.kosli.com", "my-org", identifier) + + require.Error(t, err, identifier) + } +} + +func TestVersionURLNamesTheVersion(t *testing.T) { + endpoint, err := VersionURL("https://app.kosli.com", "my-org", "pr-approved", 3) + + require.NoError(t, err) + require.Equal(t, "https://app.kosli.com/api/v2/rego-policies/my-org/pr-approved/versions/3", endpoint) +} + +func TestListURLNamesTheOrg(t *testing.T) { + endpoint, err := ListURL("https://app.kosli.com", "my org") + + require.NoError(t, err) + require.Equal(t, "https://app.kosli.com/api/v2/rego-policies/my%20org", endpoint) +} diff --git a/internal/regopolicies/wait.go b/internal/regopolicies/wait.go index cb46ecbba..aab8a3915 100644 --- a/internal/regopolicies/wait.go +++ b/internal/regopolicies/wait.go @@ -36,7 +36,7 @@ func (e *StillPendingError) Error() string { // WaitForTerminal polls until the version is validated, the caller gives up, or // the budget expires. It returns a version only when that version is terminal. func (c *Client) WaitForTerminal(ctx context.Context, org, identifier string, version int, options WaitOptions) (*Version, error) { - endpoint, err := versionURL(c.host, org, identifier, version) + endpoint, err := VersionURL(c.host, org, identifier, version) if err != nil { return nil, err } From 042e192e3d618f234d970f6772db0e1943ad1d15 Mon Sep 17 00:00:00 2001 From: Simon Castagna Date: Tue, 13 Oct 2026 10:38:31 +0200 Subject: [PATCH 3/4] feat(get): hidden kosli get rego-policy, show a rego policy or one of its versions Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/get.go | 1 + cmd/kosli/getRegoPolicy.go | 213 +++++++++++++++++ cmd/kosli/getRegoPolicy_test.go | 225 ++++++++++++++++++ cmd/kosli/root.go | 1 + .../testdata/empty-flag-audit-coverage.json | 4 + hack/empty-flag-audit/spec.json | 19 ++ 6 files changed, 463 insertions(+) create mode 100644 cmd/kosli/getRegoPolicy.go create mode 100644 cmd/kosli/getRegoPolicy_test.go diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index cdfd7d5a3..bc3cdd8a0 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -31,6 +31,7 @@ func newGetCmd(out io.Writer) *cobra.Command { newGetRepoCmd(out), newGetServiceAccountCmd(out), newGetDefaultOrgCmd(out), + newGetRegoPolicyCmd(out), ) return cmd } diff --git a/cmd/kosli/getRegoPolicy.go b/cmd/kosli/getRegoPolicy.go new file mode 100644 index 000000000..15415e7c8 --- /dev/null +++ b/cmd/kosli/getRegoPolicy.go @@ -0,0 +1,213 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/regopolicies" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const getRegoPolicyShortDesc = `Get a rego policy, or one version of it.` + +const getRegoPolicyLongDesc = getRegoPolicyShortDesc + ` +Without ^--version^, the policy is shown with its newest version and its current +version. The current version is the highest-numbered valid version, which is the +one an evaluation runs, so it can be older than the newest version. + +With ^--version^, that version is shown with its status, its digest, who +published it and when, and the reason when it is rejected or failed. The command +exits 0 whatever the status of the version.` + +const getRegoPolicyExample = ` +# get a rego policy and its current version: +kosli get rego-policy pr-approved \ + --api-token yourAPIToken \ + --org yourOrgName + +# get version 3 of a rego policy: +kosli get rego-policy pr-approved \ + --version 3 \ + --api-token yourAPIToken \ + --org yourOrgName + +# get version 3 of a rego policy as JSON: +kosli get rego-policy pr-approved \ + --version 3 \ + --output json \ + --api-token yourAPIToken \ + --org yourOrgName +` + +type getRegoPolicyOptions struct { + output string + version int +} + +func newGetRegoPolicyCmd(out io.Writer) *cobra.Command { + o := new(getRegoPolicyOptions) + cmd := &cobra.Command{ + Use: "rego-policy IDENTIFIER", + Short: getRegoPolicyShortDesc, + Long: getRegoPolicyLongDesc, + Example: getRegoPolicyExample, + Args: cobra.ExactArgs(1), + // Hidden while the server gates the endpoint behind is-server-side-evaluation-enabled. + Hidden: true, + 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()) + } + if cmd.Flags().Changed("version") && o.version < 1 { + return ErrorBeforePrintingUsage(cmd, "--version must be a positive integer") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args[0]) + }, + } + + cmd.Flags().IntVar(&o.version, "version", 0, regoPolicyVersionFlag) + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + + return cmd +} + +func (o *getRegoPolicyOptions) run(out io.Writer, identifier string) error { + endpoint, err := regopolicies.PolicyURL(global.Host, global.Org, identifier) + printTable := printRegoPolicyAsTable + if o.version > 0 { + endpoint, err = regopolicies.VersionURL(global.Host, global.Org, identifier, o.version) + printTable = printRegoPolicyVersionAsTable + } + if err != nil { + return err + } + + response, err := kosliClient.Do(&requests.RequestParams{ + Method: http.MethodGet, + URL: endpoint, + Token: global.ApiToken, + }) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printTable, + "json": output.PrintJson, + }) +} + +func printRegoPolicyAsTable(raw string, out io.Writer, page int) error { + var policy map[string]any + if err := json.Unmarshal([]byte(raw), &policy); err != nil { + return err + } + + rows := []string{ + fmt.Sprintf("Identifier:\t%s", policy["identifier"]), + fmt.Sprintf("Name:\t%s", policy["name"]), + } + if description, ok := policy["description"]; ok && description != nil { + rows = append(rows, fmt.Sprintf("Description:\t%s", description)) + } + rows = append(rows, fmt.Sprintf("Created by:\t%s", policy["created_by"])) + for _, field := range []struct{ label, key string }{ + {"Created at", "created_at"}, + {"Last updated", "updated_at"}, + } { + at, err := formattedTimestamp(policy[field.key], true) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("%s:\t%s", field.label, at)) + } + if newest, ok := policy["newest_version"].(map[string]any); ok { + rows = append(rows, fmt.Sprintf("Newest version:\t%.0f (%s)", newest["version"], newest["status"])) + } + + current, ok := policy["current_version"].(map[string]any) + if !ok { + rows = append(rows, "Current version:\tnone, as no version is valid yet") + tabFormattedPrint(out, []string{}, rows) + return nil + } + rows = append(rows, + fmt.Sprintf("Current version:\t%.0f", current["version"]), + fmt.Sprintf(" Digest:\t%s", current["digest"]), + fmt.Sprintf(" Input schema:\t%s", current["input_schema"]), + ) + if schema, ok := current["params_schema"]; ok && schema != nil { + compact, err := json.Marshal(schema) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf(" Params schema:\t%s", compact)) + } + + tabFormattedPrint(out, []string{}, rows) + return nil +} + +func printRegoPolicyVersionAsTable(raw string, out io.Writer, page int) error { + var version map[string]any + if err := json.Unmarshal([]byte(raw), &version); err != nil { + return err + } + + rows := []string{ + fmt.Sprintf("Identifier:\t%s", version["identifier"]), + fmt.Sprintf("Version:\t%.0f", version["version"]), + fmt.Sprintf("Status:\t%s", version["status"]), + } + if reason, ok := version["error"].(map[string]any); ok { + rows = append(rows, fmt.Sprintf("Error:\t%s: %s", reason["kind"], reason["message"])) + } + rows = append(rows, + fmt.Sprintf("Digest:\t%s", version["digest"]), + fmt.Sprintf("Input schema:\t%s", version["input_schema"]), + ) + if comment, ok := version["comment"].(string); ok && comment != "" { + rows = append(rows, fmt.Sprintf("Comment:\t%s", comment)) + } + rows = append(rows, fmt.Sprintf("Published by:\t%s", version["created_by"])) + publishedAt, err := formattedTimestamp(version["created_at"], true) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("Published at:\t%s", publishedAt)) + if validatedAt, ok := version["validated_at"]; ok && validatedAt != nil { + at, err := formattedTimestamp(validatedAt, true) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("Validated at:\t%s", at)) + } + for _, field := range []struct{ label, key string }{ + {"OPA version", "opa_version"}, + {"Entrypoint", "entrypoint"}, + } { + if value, ok := version[field.key]; ok && value != nil { + rows = append(rows, fmt.Sprintf("%s:\t%s", field.label, value)) + } + } + if schema, ok := version["params_schema"]; ok && schema != nil { + compact, err := json.Marshal(schema) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("Params schema:\t%s", compact)) + } + + tabFormattedPrint(out, []string{}, rows) + return nil +} diff --git a/cmd/kosli/getRegoPolicy_test.go b/cmd/kosli/getRegoPolicy_test.go new file mode 100644 index 000000000..d6c39bb0b --- /dev/null +++ b/cmd/kosli/getRegoPolicy_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + regoPolicyWithCurrentVersion = `{"identifier":"pr-approved","name":"Pull request approved",` + + `"description":"Approved PRs only","newest_version":{"version":3,"status":"rejected"},` + + `"current_version":{"version":2,"digest":"sha256:abc123","input_schema":"trail",` + + `"params_schema":{"type":"object"}},` + + `"created_at":1757000000.5,"created_by":"Jane Doe","updated_at":1757000100.5}` + regoPolicyWithNoValidVersion = `{"identifier":"pr-approved","name":"Pull request approved",` + + `"newest_version":{"version":1,"status":"pending"},` + + `"created_at":1757000000.5,"created_by":"Jane Doe","updated_at":1757000000.5}` +) + +// fakeRegoPolicyRead records the read and answers with body and status. +type fakeRegoPolicyRead struct { + body string + status int + escapedPath string + calls int +} + +func newFakeRegoPolicyRead(t *testing.T, body string) (*httptest.Server, *fakeRegoPolicyRead) { + t.Helper() + fake := &fakeRegoPolicyRead{body: body, status: http.StatusOK} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fake.calls++ + fake.escapedPath = r.URL.EscapedPath() + w.WriteHeader(fake.status) + _, _ = fmt.Fprint(w, fake.body) + })) + t.Cleanup(server.Close) + return server, fake +} + +type GetRegoPolicyCommandTestSuite struct { + suite.Suite +} + +func (suite *GetRegoPolicyCommandTestSuite) SetupTest() { + // Makes formattedTimestamp print a fixed UTC time. + suite.T().Setenv("KOSLI_TESTS", "true") +} + +func (suite *GetRegoPolicyCommandTestSuite) getCmd(host, args string) string { + return fmt.Sprintf("get rego-policy %s --host %s --org test-org --api-token test-token --max-api-retries 0", + args, host) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItIsHiddenAndBeta() { + root, err := newRootCmd(io.Discard, io.Discard, nil) + require.NoError(suite.T(), err) + cmd, _, err := root.Find([]string{"get", "rego-policy"}) + require.NoError(suite.T(), err) + + require.Equal(suite.T(), "rego-policy", cmd.Name()) + require.True(suite.T(), cmd.Hidden) + require.Contains(suite.T(), cmd.Annotations, betaCLIAnnotation) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItTakesExactlyOneIdentifier() { + for _, args := range []string{"", "pr-approved other"} { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, args)) + + require.Error(suite.T(), err, args) + require.Contains(suite.T(), combined, "accepts 1 arg(s)", args) + require.Equal(suite.T(), 0, fake.calls, args) + } +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsThePolicyAndItsCurrentVersion() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "/api/v2/rego-policies/test-org/pr-approved", fake.escapedPath) + require.Equal(suite.T(), + "Identifier: pr-approved\n"+ + "Name: Pull request approved\n"+ + "Description: Approved PRs only\n"+ + "Created by: Jane Doe\n"+ + "Created at: Sat, 16 Jan 2016 00:00:00 UTC\n"+ + "Last updated: Sat, 16 Jan 2016 00:00:00 UTC\n"+ + "Newest version: 3 (rejected)\n"+ + "Current version: 2\n"+ + " Digest: sha256:abc123\n"+ + " Input schema: trail\n"+ + " Params schema: {\"type\":\"object\"}\n", + combined) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItSaysWhenNoVersionIsValidYet() { + server, _ := newFakeRegoPolicyRead(suite.T(), regoPolicyWithNoValidVersion) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved")) + + require.NoError(suite.T(), err) + require.NotContains(suite.T(), combined, "Description:") + require.Contains(suite.T(), combined, "Newest version: 1 (pending)\n") + require.Contains(suite.T(), combined, "Current version: none, as no version is valid yet\n") + require.NotContains(suite.T(), combined, "Digest:") +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsThePolicyAsJSON() { + server, _ := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --output json")) + + require.NoError(suite.T(), err) + require.JSONEq(suite.T(), regoPolicyWithCurrentVersion, combined) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsAValidVersion() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyValid) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 1")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "/api/v2/rego-policies/test-org/pr-approved/versions/1", fake.escapedPath) + require.Equal(suite.T(), + "Identifier: pr-approved\n"+ + "Version: 1\n"+ + "Status: valid\n"+ + "Digest: sha256:abc123\n"+ + "Input schema: trail\n"+ + "Published by: Jane Doe\n"+ + "Published at: Sat, 16 Jan 2016 00:00:00 UTC\n"+ + "Validated at: Sat, 16 Jan 2016 00:00:00 UTC\n"+ + "OPA version: 1.21.1\n"+ + "Entrypoint: policy\n", + combined) +} + +// get only reads, so a version that is not valid is a successful answer. +func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsWhyAVersionIsNotValidAndExitsZero() { + server, _ := newFakeRegoPolicyRead(suite.T(), regoPolicyRejected) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 1")) + + require.NoError(suite.T(), err) + require.Contains(suite.T(), combined, "Status: rejected\n") + require.Contains(suite.T(), combined, + "Error: compile: policy.rego:4: rego_parse_error: unexpected eof token\n") + require.NotContains(suite.T(), combined, "Entrypoint:") +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsTheCommentAndParamsSchemaOfAVersion() { + body := `{"identifier":"pr-approved","version":2,"status":"valid","digest":"sha256:abc123",` + + `"input_schema":"trails","comment":"Also require two approvers",` + + `"created_at":1757000000.5,"created_by":"Jane Doe","validated_at":1757000002.25,` + + `"opa_version":"1.21.1","entrypoint":"policy","params_schema":{"type":"object"}}` + server, _ := newFakeRegoPolicyRead(suite.T(), body) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 2")) + + require.NoError(suite.T(), err) + require.Contains(suite.T(), combined, "Comment: Also require two approvers\n") + require.Contains(suite.T(), combined, "Params schema: {\"type\":\"object\"}\n") +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsAVersionAsJSON() { + server, _ := newFakeRegoPolicyRead(suite.T(), regoPolicyRejected) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 1 --output json")) + + require.NoError(suite.T(), err) + require.JSONEq(suite.T(), regoPolicyRejected, combined) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItRefusesAVersionThatIsNotPositive() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyValid) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 0")) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "--version must be a positive integer") + require.Equal(suite.T(), 0, fake.calls) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItKeepsTheIdentifierInOneSegment() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + + _, _, _, _, err := executeCommandC(suite.getCmd(server.URL, "a/b")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "/api/v2/rego-policies/test-org/a%2Fb", fake.escapedPath) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItRefusesAnIdentifierThatIsNotASegment() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "..")) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, `".." is not a valid rego policy identifier`) + require.Equal(suite.T(), 0, fake.calls) +} + +func (suite *GetRegoPolicyCommandTestSuite) TestItReturnsANotFoundInTheServersWords() { + server, fake := newFakeRegoPolicyRead(suite.T(), + `{"message":"Rego policy 'pr-approved' has no version 9 in organization 'test-org'"}`) + fake.status = http.StatusNotFound + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 9")) + + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "Rego policy 'pr-approved' has no version 9 in organization 'test-org'") +} + +func TestGetRegoPolicyCommandTestSuite(t *testing.T) { + suite.Run(t, new(GetRegoPolicyCommandTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 6eb95a9bf..10aaeffb4 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -162,6 +162,7 @@ Paths the list already matches stay excluded whatever is later added there, so k regoPolicyNameFlag = "[conditional] The display name of the rego policy. Only required when the identifier is new; without it the current name is kept." regoPolicyDescriptionFlag = "[optional] The description of the rego policy. Without it the current description is kept." regoPolicyCommentFlag = "[optional] Why this version is published. It is kept with the version." + regoPolicyVersionFlag = "[optional] The version of the rego policy to get. Without it, the policy and its current version are shown." regoPolicySearchFlag = "[optional] Only list rego policies whose name or identifier contains this substring (case-insensitive)." regoPolicySortFlag = "[optional] The field to sort rego policies by. Valid values are: [updated_at, identifier]. (defaults to updated_at)" regoPolicySortDirectionFlag = "[optional] The direction to sort rego policies in. Valid values are: [asc, desc]. (defaults to desc)" diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index 7582498ba..ed7bd9656 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -676,6 +676,10 @@ "get policy": { "output": "string" }, + "get rego-policy": { + "output": "string", + "version": "int" + }, "get repo": { "output": "string", "provider": "string", diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index f30ce97cc..c9d548a55 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -1551,6 +1551,25 @@ "setup": [], "verify": [] }, + "get rego-policy": { + "args": [ + "{policy}" + ], + "flags": {}, + "baseline_ok": false, + "error": "the server the audit runs against does not offer the rego policy endpoints", + "needs": "server-side evaluation", + "flags_to_test": [ + "output", + "version" + ], + "flag_values": { + "output": "json", + "version": "1" + }, + "setup": [], + "verify": [] + }, "list rego-policies": { "args": [], "flags": {}, From 2036f0be74e65f846cf1321b2e9d157bdd317989 Mon Sep 17 00:00:00 2001 From: Simon Castagna Date: Tue, 13 Oct 2026 11:01:03 +0200 Subject: [PATCH 4/4] fix(get): rename get rego-policy --version to --policy-version, and address review findings - --version was read from KOSLI_VERSION, which often names the CLI version to install - print a version from the typed regopolicies.Version, so a null field shows as empty - build one URL in run() - test a missing --org and --api-token in both suites, and share one read fake Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/getRegoPolicy.go | 68 ++++++++--------- cmd/kosli/getRegoPolicy_test.go | 74 +++++++++++-------- cmd/kosli/listRegoPolicies_test.go | 57 +++++--------- cmd/kosli/regoPolicyReadFake_test.go | 44 +++++++++++ .../testdata/empty-flag-audit-coverage.json | 2 +- hack/empty-flag-audit/spec.json | 4 +- 6 files changed, 146 insertions(+), 103 deletions(-) create mode 100644 cmd/kosli/regoPolicyReadFake_test.go diff --git a/cmd/kosli/getRegoPolicy.go b/cmd/kosli/getRegoPolicy.go index 15415e7c8..259b024de 100644 --- a/cmd/kosli/getRegoPolicy.go +++ b/cmd/kosli/getRegoPolicy.go @@ -15,11 +15,11 @@ import ( const getRegoPolicyShortDesc = `Get a rego policy, or one version of it.` const getRegoPolicyLongDesc = getRegoPolicyShortDesc + ` -Without ^--version^, the policy is shown with its newest version and its current +Without ^--policy-version^, the policy is shown with its newest version and its current version. The current version is the highest-numbered valid version, which is the one an evaluation runs, so it can be older than the newest version. -With ^--version^, that version is shown with its status, its digest, who +With ^--policy-version^, that version is shown with its status, its digest, who published it and when, and the reason when it is rejected or failed. The command exits 0 whatever the status of the version.` @@ -31,13 +31,13 @@ kosli get rego-policy pr-approved \ # get version 3 of a rego policy: kosli get rego-policy pr-approved \ - --version 3 \ + --policy-version 3 \ --api-token yourAPIToken \ --org yourOrgName # get version 3 of a rego policy as JSON: kosli get rego-policy pr-approved \ - --version 3 \ + --policy-version 3 \ --output json \ --api-token yourAPIToken \ --org yourOrgName @@ -64,8 +64,8 @@ func newGetRegoPolicyCmd(out io.Writer) *cobra.Command { if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } - if cmd.Flags().Changed("version") && o.version < 1 { - return ErrorBeforePrintingUsage(cmd, "--version must be a positive integer") + if cmd.Flags().Changed("policy-version") && o.version < 1 { + return ErrorBeforePrintingUsage(cmd, "--policy-version must be a positive integer") } return nil }, @@ -74,18 +74,22 @@ func newGetRegoPolicyCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().IntVar(&o.version, "version", 0, regoPolicyVersionFlag) + cmd.Flags().IntVar(&o.version, "policy-version", 0, regoPolicyVersionFlag) cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) return cmd } func (o *getRegoPolicyOptions) run(out io.Writer, identifier string) error { - endpoint, err := regopolicies.PolicyURL(global.Host, global.Org, identifier) - printTable := printRegoPolicyAsTable + var endpoint string + var err error + var printTable output.FormatOutputFunc if o.version > 0 { endpoint, err = regopolicies.VersionURL(global.Host, global.Org, identifier, o.version) printTable = printRegoPolicyVersionAsTable + } else { + endpoint, err = regopolicies.PolicyURL(global.Host, global.Org, identifier) + printTable = printRegoPolicyAsTable } if err != nil { return err @@ -159,49 +163,47 @@ func printRegoPolicyAsTable(raw string, out io.Writer, page int) error { } func printRegoPolicyVersionAsTable(raw string, out io.Writer, page int) error { - var version map[string]any + var version regopolicies.Version if err := json.Unmarshal([]byte(raw), &version); err != nil { return err } rows := []string{ - fmt.Sprintf("Identifier:\t%s", version["identifier"]), - fmt.Sprintf("Version:\t%.0f", version["version"]), - fmt.Sprintf("Status:\t%s", version["status"]), + fmt.Sprintf("Identifier:\t%s", version.Identifier), + fmt.Sprintf("Version:\t%d", version.Version), + fmt.Sprintf("Status:\t%s", version.Status), } - if reason, ok := version["error"].(map[string]any); ok { - rows = append(rows, fmt.Sprintf("Error:\t%s: %s", reason["kind"], reason["message"])) + if version.Error != nil { + rows = append(rows, fmt.Sprintf("Error:\t%s: %s", version.Error.Kind, version.Error.Message)) } rows = append(rows, - fmt.Sprintf("Digest:\t%s", version["digest"]), - fmt.Sprintf("Input schema:\t%s", version["input_schema"]), + fmt.Sprintf("Digest:\t%s", version.Digest), + fmt.Sprintf("Input schema:\t%s", version.InputSchema), ) - if comment, ok := version["comment"].(string); ok && comment != "" { - rows = append(rows, fmt.Sprintf("Comment:\t%s", comment)) + if version.Comment != "" { + rows = append(rows, fmt.Sprintf("Comment:\t%s", version.Comment)) } - rows = append(rows, fmt.Sprintf("Published by:\t%s", version["created_by"])) - publishedAt, err := formattedTimestamp(version["created_at"], true) + rows = append(rows, fmt.Sprintf("Published by:\t%s", version.CreatedBy)) + publishedAt, err := formattedTimestamp(version.CreatedAt, true) if err != nil { return err } rows = append(rows, fmt.Sprintf("Published at:\t%s", publishedAt)) - if validatedAt, ok := version["validated_at"]; ok && validatedAt != nil { - at, err := formattedTimestamp(validatedAt, true) + if version.ValidatedAt != nil { + validatedAt, err := formattedTimestamp(*version.ValidatedAt, true) if err != nil { return err } - rows = append(rows, fmt.Sprintf("Validated at:\t%s", at)) + rows = append(rows, fmt.Sprintf("Validated at:\t%s", validatedAt)) } - for _, field := range []struct{ label, key string }{ - {"OPA version", "opa_version"}, - {"Entrypoint", "entrypoint"}, - } { - if value, ok := version[field.key]; ok && value != nil { - rows = append(rows, fmt.Sprintf("%s:\t%s", field.label, value)) - } + if version.OPAVersion != "" { + rows = append(rows, fmt.Sprintf("OPA version:\t%s", version.OPAVersion)) } - if schema, ok := version["params_schema"]; ok && schema != nil { - compact, err := json.Marshal(schema) + if version.Entrypoint != "" { + rows = append(rows, fmt.Sprintf("Entrypoint:\t%s", version.Entrypoint)) + } + if version.ParamsSchema != nil { + compact, err := json.Marshal(version.ParamsSchema) if err != nil { return err } diff --git a/cmd/kosli/getRegoPolicy_test.go b/cmd/kosli/getRegoPolicy_test.go index d6c39bb0b..1af1f083e 100644 --- a/cmd/kosli/getRegoPolicy_test.go +++ b/cmd/kosli/getRegoPolicy_test.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -22,27 +21,6 @@ const ( `"created_at":1757000000.5,"created_by":"Jane Doe","updated_at":1757000000.5}` ) -// fakeRegoPolicyRead records the read and answers with body and status. -type fakeRegoPolicyRead struct { - body string - status int - escapedPath string - calls int -} - -func newFakeRegoPolicyRead(t *testing.T, body string) (*httptest.Server, *fakeRegoPolicyRead) { - t.Helper() - fake := &fakeRegoPolicyRead{body: body, status: http.StatusOK} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fake.calls++ - fake.escapedPath = r.URL.EscapedPath() - w.WriteHeader(fake.status) - _, _ = fmt.Fprint(w, fake.body) - })) - t.Cleanup(server.Close) - return server, fake -} - type GetRegoPolicyCommandTestSuite struct { suite.Suite } @@ -68,6 +46,22 @@ func (suite *GetRegoPolicyCommandTestSuite) TestItIsHiddenAndBeta() { require.Contains(suite.T(), cmd.Annotations, betaCLIAnnotation) } +func (suite *GetRegoPolicyCommandTestSuite) TestItNeedsTheOrgAndApiToken() { + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + noConfig := withoutOrgAndApiToken(suite.T()) + + _, combined, _, _, err := executeCommandC(fmt.Sprintf( + "get rego-policy pr-approved --api-token test-token --host %s %s", server.URL, noConfig)) + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "Error: --org is not set\n") + + _, combined, _, _, err = executeCommandC(fmt.Sprintf( + "get rego-policy pr-approved --org test-org --host %s %s", server.URL, noConfig)) + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "Error: --api-token is not set\n") + require.Equal(suite.T(), 0, fake.calls) +} + func (suite *GetRegoPolicyCommandTestSuite) TestItTakesExactlyOneIdentifier() { for _, args := range []string{"", "pr-approved other"} { server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) @@ -126,7 +120,7 @@ func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsThePolicyAsJSON() { func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsAValidVersion() { server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyValid) - _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 1")) + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --policy-version 1")) require.NoError(suite.T(), err) require.Equal(suite.T(), "/api/v2/rego-policies/test-org/pr-approved/versions/1", fake.escapedPath) @@ -148,7 +142,7 @@ func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsAValidVersion() { func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsWhyAVersionIsNotValidAndExitsZero() { server, _ := newFakeRegoPolicyRead(suite.T(), regoPolicyRejected) - _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 1")) + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --policy-version 1")) require.NoError(suite.T(), err) require.Contains(suite.T(), combined, "Status: rejected\n") @@ -164,17 +158,28 @@ func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsTheCommentAndParamsSchem `"opa_version":"1.21.1","entrypoint":"policy","params_schema":{"type":"object"}}` server, _ := newFakeRegoPolicyRead(suite.T(), body) - _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 2")) + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --policy-version 2")) require.NoError(suite.T(), err) require.Contains(suite.T(), combined, "Comment: Also require two approvers\n") require.Contains(suite.T(), combined, "Params schema: {\"type\":\"object\"}\n") } +func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsAVersionWithANullFieldWithoutFormatNoise() { + body := `{"identifier":"pr-approved","version":1,"status":"pending","digest":null,` + + `"input_schema":"trail","comment":"","created_at":1757000000.5,"created_by":null}` + server, _ := newFakeRegoPolicyRead(suite.T(), body) + + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --policy-version 1")) + + require.NoError(suite.T(), err) + require.NotContains(suite.T(), combined, "%!") +} + func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsAVersionAsJSON() { server, _ := newFakeRegoPolicyRead(suite.T(), regoPolicyRejected) - _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 1 --output json")) + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --policy-version 1 --output json")) require.NoError(suite.T(), err) require.JSONEq(suite.T(), regoPolicyRejected, combined) @@ -183,13 +188,24 @@ func (suite *GetRegoPolicyCommandTestSuite) TestItPrintsAVersionAsJSON() { func (suite *GetRegoPolicyCommandTestSuite) TestItRefusesAVersionThatIsNotPositive() { server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyValid) - _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 0")) + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --policy-version 0")) require.Error(suite.T(), err) - require.Contains(suite.T(), combined, "--version must be a positive integer") + require.Contains(suite.T(), combined, "--policy-version must be a positive integer") require.Equal(suite.T(), 0, fake.calls) } +// KOSLI_VERSION often names the CLI version to install, so it must not pick a policy version. +func (suite *GetRegoPolicyCommandTestSuite) TestKosliVersionInTheEnvironmentDoesNotPickAVersion() { + suite.T().Setenv("KOSLI_VERSION", "2") + server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) + + _, _, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved")) + + require.NoError(suite.T(), err) + require.Equal(suite.T(), "/api/v2/rego-policies/test-org/pr-approved", fake.escapedPath) +} + func (suite *GetRegoPolicyCommandTestSuite) TestItKeepsTheIdentifierInOneSegment() { server, fake := newFakeRegoPolicyRead(suite.T(), regoPolicyWithCurrentVersion) @@ -214,7 +230,7 @@ func (suite *GetRegoPolicyCommandTestSuite) TestItReturnsANotFoundInTheServersWo `{"message":"Rego policy 'pr-approved' has no version 9 in organization 'test-org'"}`) fake.status = http.StatusNotFound - _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --version 9")) + _, combined, _, _, err := executeCommandC(suite.getCmd(server.URL, "pr-approved --policy-version 9")) require.Error(suite.T(), err) require.Contains(suite.T(), combined, "Rego policy 'pr-approved' has no version 9 in organization 'test-org'") diff --git a/cmd/kosli/listRegoPolicies_test.go b/cmd/kosli/listRegoPolicies_test.go index 140ee169f..342d669c8 100644 --- a/cmd/kosli/listRegoPolicies_test.go +++ b/cmd/kosli/listRegoPolicies_test.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "net/http" - "net/http/httptest" "net/url" "testing" @@ -21,29 +20,6 @@ const ( regoPoliciesEmptyPage = `{"page":%d,"per_page":15,"total_pages":0,"total_count":0,"rego_policies":[]}` ) -// fakeRegoPolicyList records the list request and answers with body and status. -type fakeRegoPolicyList struct { - body string - status int - path string - query url.Values - calls int -} - -func newFakeRegoPolicyList(t *testing.T, body string) (*httptest.Server, *fakeRegoPolicyList) { - t.Helper() - fake := &fakeRegoPolicyList{body: body, status: http.StatusOK} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fake.calls++ - fake.path = r.URL.Path - fake.query = r.URL.Query() - w.WriteHeader(fake.status) - _, _ = fmt.Fprint(w, fake.body) - })) - t.Cleanup(server.Close) - return server, fake -} - type ListRegoPoliciesCommandTestSuite struct { suite.Suite } @@ -70,18 +46,23 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItIsHiddenAndBeta() { } func (suite *ListRegoPoliciesCommandTestSuite) TestItNeedsTheOrgAndApiToken() { - server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + server, fake := newFakeRegoPolicyRead(suite.T(), regoPoliciesPage) + noConfig := withoutOrgAndApiToken(suite.T()) - _, combined, _, _, err := executeCommandC( - "list rego-policies --org '' --api-token '' --host " + server.URL) + _, combined, _, _, err := executeCommandC(fmt.Sprintf( + "list rego-policies --api-token test-token --host %s %s", server.URL, noConfig)) + require.Error(suite.T(), err) + require.Contains(suite.T(), combined, "Error: --org is not set\n") + _, combined, _, _, err = executeCommandC(fmt.Sprintf( + "list rego-policies --org test-org --host %s %s", server.URL, noConfig)) require.Error(suite.T(), err) - require.Contains(suite.T(), combined, "--org") + require.Contains(suite.T(), combined, "Error: --api-token is not set\n") require.Equal(suite.T(), 0, fake.calls) } func (suite *ListRegoPoliciesCommandTestSuite) TestItTakesNoArguments() { - server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + server, fake := newFakeRegoPolicyRead(suite.T(), regoPoliciesPage) _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "pr-approved")) @@ -91,17 +72,17 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItTakesNoArguments() { } func (suite *ListRegoPoliciesCommandTestSuite) TestItReadsTheFirstPageOf15ByDefault() { - server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + server, fake := newFakeRegoPolicyRead(suite.T(), regoPoliciesPage) _, _, _, _, err := executeCommandC(suite.listCmd(server.URL, "")) require.NoError(suite.T(), err) - require.Equal(suite.T(), "/api/v2/rego-policies/test-org", fake.path) + require.Equal(suite.T(), "/api/v2/rego-policies/test-org", fake.escapedPath) require.Equal(suite.T(), url.Values{"page": {"1"}, "per_page": {"15"}}, fake.query) } func (suite *ListRegoPoliciesCommandTestSuite) TestItPrintsATable() { - server, _ := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + server, _ := newFakeRegoPolicyRead(suite.T(), regoPoliciesPage) _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "")) @@ -116,7 +97,7 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItPrintsATable() { } func (suite *ListRegoPoliciesCommandTestSuite) TestItSaysWhenThereAreNoPolicies() { - server, _ := newFakeRegoPolicyList(suite.T(), fmt.Sprintf(regoPoliciesEmptyPage, 1)) + server, _ := newFakeRegoPolicyRead(suite.T(), fmt.Sprintf(regoPoliciesEmptyPage, 1)) _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "")) @@ -125,7 +106,7 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItSaysWhenThereAreNoPolicies( } func (suite *ListRegoPoliciesCommandTestSuite) TestItNamesThePageWhenAPageAfterTheFirstIsEmpty() { - server, _ := newFakeRegoPolicyList(suite.T(), fmt.Sprintf(regoPoliciesEmptyPage, 3)) + server, _ := newFakeRegoPolicyRead(suite.T(), fmt.Sprintf(regoPoliciesEmptyPage, 3)) _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "--page 3")) @@ -134,7 +115,7 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItNamesThePageWhenAPageAfterT } func (suite *ListRegoPoliciesCommandTestSuite) TestItPrintsTheServerAnswerAsJSON() { - server, _ := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + server, _ := newFakeRegoPolicyRead(suite.T(), regoPoliciesPage) _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, "--output json")) @@ -143,7 +124,7 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItPrintsTheServerAnswerAsJSON } func (suite *ListRegoPoliciesCommandTestSuite) TestItSendsThePagingSearchAndSort() { - server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + server, fake := newFakeRegoPolicyRead(suite.T(), regoPoliciesPage) _, _, _, _, err := executeCommandC(suite.listCmd(server.URL, "--page 2 --page-limit 50 --search approved --sort identifier --sort-direction asc")) @@ -160,7 +141,7 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItSendsThePagingSearchAndSort func (suite *ListRegoPoliciesCommandTestSuite) TestItRefusesAPageOrPageLimitThatIsNotPositive() { for _, args := range []string{"--page 0", "--page-limit 0"} { - server, fake := newFakeRegoPolicyList(suite.T(), regoPoliciesPage) + server, fake := newFakeRegoPolicyRead(suite.T(), regoPoliciesPage) _, combined, _, _, err := executeCommandC(suite.listCmd(server.URL, args)) @@ -171,7 +152,7 @@ func (suite *ListRegoPoliciesCommandTestSuite) TestItRefusesAPageOrPageLimitThat } func (suite *ListRegoPoliciesCommandTestSuite) TestItReturnsAServerError() { - server, fake := newFakeRegoPolicyList(suite.T(), + server, fake := newFakeRegoPolicyRead(suite.T(), `{"message":"User 'jane' does not have access to organization 'test-org'"}`) fake.status = http.StatusForbidden diff --git a/cmd/kosli/regoPolicyReadFake_test.go b/cmd/kosli/regoPolicyReadFake_test.go new file mode 100644 index 000000000..56040b72b --- /dev/null +++ b/cmd/kosli/regoPolicyReadFake_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" +) + +// fakeRegoPolicyRead records each read and answers with body and status. +type fakeRegoPolicyRead struct { + body string + status int + escapedPath string + query url.Values + calls int +} + +func newFakeRegoPolicyRead(t *testing.T, body string) (*httptest.Server, *fakeRegoPolicyRead) { + t.Helper() + fake := &fakeRegoPolicyRead{body: body, status: http.StatusOK} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fake.calls++ + fake.escapedPath = r.URL.EscapedPath() + fake.query = r.URL.Query() + w.WriteHeader(fake.status) + _, _ = fmt.Fprint(w, fake.body) + })) + t.Cleanup(server.Close) + return server, fake +} + +// withoutOrgAndApiToken leaves no org or API token in the environment or a config file. +func withoutOrgAndApiToken(t *testing.T) string { + t.Helper() + for _, key := range []string{"KOSLI_ORG", "KOSLI_API_TOKEN"} { + t.Setenv(key, "") + _ = os.Unsetenv(key) + } + return "--config-file " + filepath.Join(t.TempDir(), "absent.yml") +} diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index ed7bd9656..25a0718f0 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -678,7 +678,7 @@ }, "get rego-policy": { "output": "string", - "version": "int" + "policy-version": "int" }, "get repo": { "output": "string", diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index c9d548a55..53587a7d9 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -1561,11 +1561,11 @@ "needs": "server-side evaluation", "flags_to_test": [ "output", - "version" + "policy-version" ], "flag_values": { "output": "json", - "version": "1" + "policy-version": "1" }, "setup": [], "verify": []