From b3c0a79408d57c6dc37c7e22ad1f9fad9e966de9 Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Thu, 2 Jul 2026 11:53:57 -0400 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: 92c8799bca2f824a8a7c26187f01162c7bc3f09b --- cmd/api_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ cmd/schema.go | 8 +++++++- cmd/selfheal.go | 12 +++++++++++- cmd/selfheal_test.go | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/cmd/api_test.go b/cmd/api_test.go index 60c8b2a..caa2efd 100644 --- a/cmd/api_test.go +++ b/cmd/api_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/customerio/cli/internal/client" @@ -845,3 +846,45 @@ func TestAPI_JSONFromFile_EmptyFilename(t *testing.T) { t.Errorf("expected 'missing filename' error, got: %s", err.Error()) } } + +// TestSchema_SendsAccessTokenOnSpecFetch guards the plan-gating path: when the +// caller authenticates with a pre-exchanged JWT (CIO_ACCESS_TOKEN) and no +// service-account token — as the in-product agent does — the OpenAPI spec fetch +// must still send it as Bearer auth, so the server can return the plan-filtered +// spec (e.g. a Builder workspace without campaigns) instead of the full one. +func TestSchema_SendsAccessTokenOnSpecFetch(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CIO_TOKEN", "") + t.Setenv("CIO_ACCESS_TOKEN", "jwt-agent-token") + + var mu sync.Mutex + var journeysAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/openapi.json": + mu.Lock() + journeysAuth = r.Header.Get("Authorization") + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(schemaSpec())) + case "/cdp/api/openapi.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(schemaCDPSpec())) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + t.Setenv("CIO_API_URL", server.URL) + + if _, _, err := executeCommand("schema"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + mu.Lock() + got := journeysAuth + mu.Unlock() + if got != "Bearer jwt-agent-token" { + t.Fatalf("spec fetch must send CIO_ACCESS_TOKEN as Bearer auth so the server can plan-filter the spec; got %q", got) + } +} diff --git a/cmd/schema.go b/cmd/schema.go index ad51cca..786e169 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -39,12 +39,18 @@ func runSchema(cmd *cobra.Command, args []string) error { var cacheKey string if c := clientFromCmd(cmd); c != nil { baseURL = c.BaseURL() - if c.ServiceAccountToken() != "" { + switch { + case c.ServiceAccountToken() != "": jwt, err := c.EnsureAccessToken(cmd.Context()) if err == nil { accessToken = jwt cacheKey = c.ServiceAccountToken() } + case c.AccessToken() != "": + // Pre-exchanged JWT (e.g. CIO_ACCESS_TOKEN, as the in-product agent uses) + // — send it so the server returns the plan-filtered spec, not the full one. + accessToken = c.AccessToken() + cacheKey = accessToken } } diff --git a/cmd/selfheal.go b/cmd/selfheal.go index 8546158..b00f7a0 100644 --- a/cmd/selfheal.go +++ b/cmd/selfheal.go @@ -57,7 +57,17 @@ func maybePromoteSandboxToken(ctx context.Context, c *client.Client, saToken str var promoted struct { Token string `json:"token"` } - if err := json.Unmarshal(resp, &promoted); err != nil || !client.IsServiceAccountToken(promoted.Token) { + // A successful promotion must return a *live* token. A malformed response or + // a still-sandbox token is treated like a failed probe: record the throttle + // timestamp so we don't re-probe on every command. Without this guard, a + // non-live token would be persisted with SandboxPromoteCheckedAt zeroed + // below, and since the stored token is still a sandbox token the throttle + // (which keys off that timestamp) would never engage — re-probing the + // endpoint on every invocation. IsServiceAccountToken matches sa_sandbox_ + // too, so it alone is not enough. + if err := json.Unmarshal(resp, &promoted); err != nil || + !client.IsServiceAccountToken(promoted.Token) || + client.IsSandboxServiceAccountToken(promoted.Token) { creds.SandboxPromoteCheckedAt = time.Now() _ = client.WriteCredentials(creds) return diff --git a/cmd/selfheal_test.go b/cmd/selfheal_test.go index c178bea..a199246 100644 --- a/cmd/selfheal_test.go +++ b/cmd/selfheal_test.go @@ -127,3 +127,39 @@ func TestMaybePromoteSandboxToken_SwallowsStillSandbox(t *testing.T) { t.Fatal("throttle timestamp should be recorded after a failed attempt") } } + +// A 200 whose body is still a sandbox token must not be treated as a successful +// promotion: keep the stored token and record the throttle. Otherwise the token +// would be swapped to another sandbox token with SandboxPromoteCheckedAt zeroed, +// and since the stored token is still a sandbox token the throttle never engages +// — re-probing the promote endpoint on every command. +func TestMaybePromoteSandboxToken_RejectsNonLiveTokenBody(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"sa_sandbox_still_sandbox"}`)) + })) + defer srv.Close() + + saToken := "sa_sandbox_z" + if err := client.WriteCredentials(&client.Credentials{ + ServiceAccountToken: saToken, + AccountID: "9", + }); err != nil { + t.Fatal(err) + } + + maybePromoteSandboxToken(context.Background(), newClientFor(srv.URL, saToken), saToken, false) + + creds, err := client.ReadCredentials() + if err != nil { + t.Fatal(err) + } + if creds.ServiceAccountToken != saToken { + t.Fatalf("token must be unchanged for a non-live response body, got %q", creds.ServiceAccountToken) + } + if creds.SandboxPromoteCheckedAt.IsZero() { + t.Fatal("throttle timestamp should be recorded so the endpoint is not re-probed every command") + } +}