diff --git a/pkg/client/client.go b/pkg/client/client.go index bd3ed90..bb7a9aa 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -74,6 +74,8 @@ type Request struct { Body any BodyContentType string Headers map[string]string + // NoAccountID, when true, omits the accountIdentifier query param for this request. + NoAccountID bool } // Client makes authenticated HTTP requests to the Harness API. @@ -82,6 +84,10 @@ type Client struct { resolved *auth.ResolvedAuth http *http.Client cliCommand string // value for X-CLI-Command header; "completion" for completion requests + // NoAccountID, when true, omits the accountIdentifier query param from every + // request made by this Client. Set by callers that build requests via the + // Get/Post/Put/Delete/etc. shorthand methods, which don't accept per-request options. + NoAccountID bool } // New creates a Client from a command context. @@ -243,7 +249,9 @@ func (c *Client) buildRequest(r Request) (*http.Request, *url.URL, error) { return nil, nil, fmt.Errorf("building URL: %w", err) } q := u.Query() - q.Set("accountIdentifier", c.resolved.AccountID) + if !r.NoAccountID && !c.NoAccountID { + q.Set("accountIdentifier", c.resolved.AccountID) + } for k, v := range r.QueryParams { if v != "" { q.Set(k, v) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..162ac9c --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,74 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/harness/cli/v3/pkg/auth" +) + +func testClient(apiURL string) *Client { + return &Client{ + ctx: context.Background(), + resolved: &auth.ResolvedAuth{ + APIUrl: apiURL, + AccountID: "acct", + AuthType: auth.AuthTypePAT, + PATToken: "pat.test", + }, + http: &http.Client{}, + } +} + +func TestBuildRequest_AccountIdentifier(t *testing.T) { + tests := []struct { + name string + reqNoAccountID bool + clientNoAcctID bool + wantPresent bool + }{ + {name: "default_present", wantPresent: true}, + {name: "request_suppressed", reqNoAccountID: true, wantPresent: false}, + {name: "client_suppressed", clientNoAcctID: true, wantPresent: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := testClient("https://example.test") + c.NoAccountID = tc.clientNoAcctID + req, u, err := c.buildRequest(Request{Method: "GET", Path: "/items", NoAccountID: tc.reqNoAccountID}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + _ = req + got := u.Query().Get("accountIdentifier") != "" + if got != tc.wantPresent { + t.Fatalf("accountIdentifier present = %v, want %v (query=%q)", got, tc.wantPresent, u.RawQuery) + } + }) + } +} + +func TestDoRequest_AccountIdentifierOmittedOnWire(t *testing.T) { + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := testClient(srv.URL) + c.NoAccountID = true + if _, _, err := c.DoRequest(Request{Method: "GET", Path: "/items"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := gotQuery.Get("accountIdentifier"); got != "" { + t.Fatalf("accountIdentifier = %q, want empty", got) + } +} diff --git a/pkg/endpoint/paging.go b/pkg/endpoint/paging.go index a94bc6d..d56eda3 100644 --- a/pkg/endpoint/paging.go +++ b/pkg/endpoint/paging.go @@ -73,6 +73,7 @@ func BuildRequest(ctx *cmdctx.Ctx, ep *spec.EndpointSpec) (*client.Request, erro Method: method, Path: path, QueryParams: qp, + NoAccountID: ep.NoAccountID, } if len(ep.BodyParams) > 0 { req.Body = buildBody(ep, exprEnv) diff --git a/pkg/endpoint/paging_test.go b/pkg/endpoint/paging_test.go new file mode 100644 index 0000000..404665d --- /dev/null +++ b/pkg/endpoint/paging_test.go @@ -0,0 +1,44 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package endpoint + +import ( + "context" + "testing" + + "github.com/harness/cli/v3/pkg/auth" + "github.com/harness/cli/v3/pkg/cmdctx" + "github.com/harness/cli/v3/pkg/spec" +) + +func TestBuildRequest_NoAccountID(t *testing.T) { + tests := []struct { + name string + ep *spec.EndpointSpec + want bool + }{ + {name: "default_false", ep: &spec.EndpointSpec{Path: "/items"}, want: false}, + {name: "propagated_true", ep: &spec.EndpointSpec{Path: "/items", NoAccountID: true}, want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := &cmdctx.Ctx{ + Context: context.Background(), + Auth: &auth.ResolvedAuth{ + AccountID: "acct", + OrgID: "org", + ProjectID: "proj", + }, + } + req, err := BuildRequest(ctx, tc.ep) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.NoAccountID != tc.want { + t.Fatalf("req.NoAccountID = %v, want %v", req.NoAccountID, tc.want) + } + }) + } +} diff --git a/pkg/registry/endpoint.go b/pkg/registry/endpoint.go index 35ad187..5dd84fd 100644 --- a/pkg/registry/endpoint.go +++ b/pkg/registry/endpoint.go @@ -57,6 +57,7 @@ func callEndpointFull(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, extraQueryParams m } c := client.New(ctx) + c.NoAccountID = ep.NoAccountID method := ep.Method if method == "" { method = "GET" diff --git a/pkg/registry/endpoint_test.go b/pkg/registry/endpoint_test.go index 5b99cc5..66cbf75 100644 --- a/pkg/registry/endpoint_test.go +++ b/pkg/registry/endpoint_test.go @@ -923,6 +923,36 @@ func TestCallEndpointFull_Priority3_DefaultDispatch(t *testing.T) { } } +// TestCallEndpointFull_NoAccountID verifies accountIdentifier is set by default +// and omitted when the endpoint opts out via no_account_id. +func TestCallEndpointFull_NoAccountID(t *testing.T) { + tests := []struct { + name string + ep *spec.EndpointSpec + want bool // want accountIdentifier present + }{ + {name: "default_present", ep: &spec.EndpointSpec{Path: "/items"}, want: true}, + {name: "suppressed", ep: &spec.EndpointSpec{Path: "/items", NoAccountID: true}, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv, cap := captureServer(t, `{}`) + r := New() + ctx := testCtx(srv.URL, nil) + ctx.Resolver = r + + if _, _, err := callEndpointFull(ctx, tc.ep, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := qv(t, cap.rawQuery).Get("accountIdentifier") != "" + if got != tc.want { + t.Fatalf("accountIdentifier present = %v, want %v (query=%q)", got, tc.want, cap.rawQuery) + } + }) + } +} + // TestCallEndpointFull_Priority3_BodyFn — body_fn supplies the POST body. func TestCallEndpointFull_Priority3_BodyFn(t *testing.T) { srv, cap := captureServer(t, `{}`) diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go index b3924c7..757b3ea 100644 --- a/pkg/spec/spec.go +++ b/pkg/spec/spec.go @@ -456,6 +456,9 @@ type EndpointSpec struct { // NoFields, when true, suppresses all field rendering (noun fields and fields_extra). // Use with text_header/text_footer for commands whose response has no displayable fields. NoFields bool `yaml:"no_fields,omitempty"` + // NoAccountID, when true, omits the accountIdentifier query param that is otherwise + // set on every request. Use for endpoints that reject or don't expect it. + NoAccountID bool `yaml:"no_account_id,omitempty"` // FieldsSubset lists field IDs from the noun that this command's API actually returns. // When set, --list-columns only advertises these IDs. FieldsSubset []string `yaml:"fields_subset,omitempty"`