From fe859f34497497ba22354ae09d5c4ed100d6c86e Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 17 Mar 2026 11:53:31 -0700 Subject: [PATCH 1/7] feat(auth): add OAuth PKCE browser flow --- .goreleaser.yml | 2 +- pkg/auth/oauth_flow.go | 2 +- pkg/cmd/auth/login/login.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index ff8debf0..d9d3473c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -134,4 +134,4 @@ chocolateys: api_key: '{{ .Env.CHOCOLATEY_API_KEY }}' source_repo: "https://push.chocolatey.org/" skip_publish: false - goamd64: v1 \ No newline at end of file + goamd64: v1 diff --git a/pkg/auth/oauth_flow.go b/pkg/auth/oauth_flow.go index 778f70af..d250cb6c 100644 --- a/pkg/auth/oauth_flow.go +++ b/pkg/auth/oauth_flow.go @@ -29,7 +29,7 @@ func OAuthClientID() string { // RunOAuth runs the OAuth PKCE flow with a local callback server and returns // a valid access token. A local HTTP server is started on a random port to -// receive the authorization code via redirect — no copy-paste required. +// receive the authorization code via redirect - no copy-paste required. // // When openBrowser is true the authorize URL is opened automatically; // otherwise only the URL is printed (useful when the browser can't be diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index e20fd63b..08d12609 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -53,7 +53,7 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { the authorization code for API tokens using OAuth 2.0 with PKCE. A local HTTP server is started to receive the OAuth redirect - automatically — no code copy-paste required. + automatically - no code copy-paste required. Use --no-browser if the browser cannot be opened automatically (e.g. SSH sessions, containers). The URL will be printed for you @@ -156,7 +156,7 @@ func selectApplication(opts *LoginOptions, apps []dashboard.Application, interac fmt.Fprintf(opts.IO.Out, " %d. %s (%s)\n", i+1, app.Name, app.ID) } fmt.Fprintf(opts.IO.Out, "Use --app-name to select one.\n") - return nil, fmt.Errorf("multiple applications found — use --app-name to select one") + return nil, fmt.Errorf("multiple applications found - use --app-name to select one") } appNames := make([]string, len(apps)) From 3ace9858b5cd3df5ce1fb8e28da7548c39f194a2 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8BitTitan@gmail.com> Date: Thu, 19 Mar 2026 18:29:51 -0400 Subject: [PATCH 2/7] fix(auth): resolve crawler profile defaults Load the default profile before persisting crawler credentials and add crawler auth test support in the config stub. This keeps `algolia auth crawler` working without `--profile` and restores regression coverage for the new auth flow. --- api/dashboard/client.go | 52 ++++++++++++++ api/dashboard/types.go | 18 +++++ pkg/cmd/auth/auth.go | 2 + pkg/cmd/auth/crawler/crawler.go | 87 +++++++++++++++++++++++ pkg/cmd/auth/crawler/crawler_test.go | 100 +++++++++++++++++++++++++++ pkg/config/config.go | 53 ++++++++++++-- test/config.go | 35 +++++++++- 7 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 pkg/cmd/auth/crawler/crawler.go create mode 100644 pkg/cmd/auth/crawler/crawler_test.go diff --git a/api/dashboard/client.go b/api/dashboard/client.go index ade6884c..f78d0e79 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -388,6 +388,58 @@ func (c *Client) CreateAPIKey(accessToken, appID string, acl []string, descripti return key, nil } +func (c *Client) GetCrawlerMe(accessToken string) (*CrawlerUserData, error) { + req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/me", nil) + if err != nil { + return nil, err + } + + c.setAPIHeaders(req, accessToken) + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("crawler me failed with status: %d", resp.StatusCode) + } + + var meResp CrawlerMeResponse + if err := json.NewDecoder(resp.Body).Decode(&meResp); err != nil { + return nil, fmt.Errorf("failed to parse crawler response: %w", err) + } + + return &meResp.Data, nil +} + +func (c *Client) GetCrawlerAPIKey(accessToken string) (string, error) { + req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/api_key", nil) + if err != nil { + return "", err + } + + c.setAPIHeaders(req, accessToken) + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("crawler api key failed with status: %d", resp.StatusCode) + } + + var apiKeyResp CrawlerAPIKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&apiKeyResp); err != nil { + return "", fmt.Errorf("failed to parse crawler response: %w", err) + } + + return apiKeyResp.Data.APIKey, nil +} + func (c *Client) setAPIHeaders(req *http.Request, accessToken string) { req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/vnd.api+json") diff --git a/api/dashboard/types.go b/api/dashboard/types.go index 68af34e2..7c21dd82 100644 --- a/api/dashboard/types.go +++ b/api/dashboard/types.go @@ -111,6 +111,24 @@ type CreateAPIKeyResponse struct { Data APIKeyResource `json:"data"` } +type CrawlerUserData struct { + ID string `json:"id"` +} + +type CrawlerMeResponse struct { + Success bool `json:"success"` + Data CrawlerUserData `json:"data"` +} + +type CrawlerAPIKeyData struct { + APIKey string `json:"apiKey"` +} + +type CrawlerAPIKeyResponse struct { + Success bool `json:"success"` + Data CrawlerAPIKeyData `json:"data"` +} + // toApplication flattens a JSON:API resource into a simple Application. func (r *ApplicationResource) toApplication() Application { return Application{ diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index bda8dcb6..66fa5064 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmd/auth/crawler" "github.com/algolia/cli/pkg/cmd/auth/login" "github.com/algolia/cli/pkg/cmd/auth/logout" "github.com/algolia/cli/pkg/cmd/auth/signup" @@ -22,6 +23,7 @@ func NewAuthCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(login.NewLoginCmd(f)) cmd.AddCommand(logout.NewLogoutCmd(f)) cmd.AddCommand(signup.NewSignupCmd(f)) + cmd.AddCommand(crawler.NewCrawlerCmd(f)) return cmd } diff --git a/pkg/cmd/auth/crawler/crawler.go b/pkg/cmd/auth/crawler/crawler.go new file mode 100644 index 00000000..2c27df36 --- /dev/null +++ b/pkg/cmd/auth/crawler/crawler.go @@ -0,0 +1,87 @@ +package crawler + +import ( + "fmt" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/validators" + "github.com/spf13/cobra" +) + +type CrawlerOptions struct { + IO *iostreams.IOStreams + config config.IConfig + NewDashboardClient func(clientID string) *dashboard.Client + GetValidToken func(client *dashboard.Client) (string, error) +} + +func NewCrawlerCmd(f *cmdutil.Factory) *cobra.Command { + opts := &CrawlerOptions{ + IO: f.IOStreams, + config: f.Config, + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + GetValidToken: auth.GetValidToken, + } + + cmd := &cobra.Command{ + Use: "crawler", + Short: "Load crawler auth details for the current profile", + Args: validators.NoArgs(), + RunE: func(cmd *cobra.Command, args []string) error { + return runCrawlerCmd(opts) + }, + } + + return cmd +} + +func runCrawlerCmd(opts *CrawlerOptions) error { + cs := opts.IO.ColorScheme() + dashboardClient := opts.NewDashboardClient(auth.OAuthClientID()) + + accessToken, err := opts.GetValidToken(dashboardClient) + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching crawler information") + crawlerUserData, err := dashboardClient.GetCrawlerMe(accessToken) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + crawlerAPIKey, err := dashboardClient.GetCrawlerAPIKey(accessToken) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + currentProfileName := opts.config.Profile().Name + if currentProfileName == "" { + defaultProfile := opts.config.Default() + if defaultProfile != nil { + currentProfileName = defaultProfile.Name + opts.config.Profile().Name = currentProfileName + } + } + if currentProfileName == "" { + return fmt.Errorf("no profile selected and no default profile configured") + } + + if err = opts.config.SetCrawlerAuth(currentProfileName, crawlerUserData.ID, crawlerAPIKey); err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Crawler API auth credentials configured for profile: %s\n", cs.SuccessIcon(), currentProfileName) + } + + return nil +} diff --git a/pkg/cmd/auth/crawler/crawler_test.go b/pkg/cmd/auth/crawler/crawler_test.go new file mode 100644 index 00000000..c4c5ba1b --- /dev/null +++ b/pkg/cmd/auth/crawler/crawler_test.go @@ -0,0 +1,100 @@ +package crawler + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/test" +) + +func Test_runCrawlerCmd_UsesDefaultProfile(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", Default: true}, + {Name: "other"}, + }) + cfg.CurrentProfile.Name = "" + + server := newCrawlerTestServer(t, "token-1", "crawler-user", "crawler-key") + t.Cleanup(server.Close) + + err := runCrawlerCmd(&CrawlerOptions{ + IO: io, + config: cfg, + NewDashboardClient: newDashboardTestClient(server), + GetValidToken: func(client *dashboard.Client) (string, error) { + return "token-1", nil + }, + }) + require.NoError(t, err) + + assert.Equal(t, "default", cfg.CurrentProfile.Name) + assert.Equal(t, test.CrawlerAuth{UserID: "crawler-user", APIKey: "crawler-key"}, cfg.CrawlerAuth["default"]) + assert.Contains(t, stdout.String(), "configured for profile: default") +} + +func Test_runCrawlerCmd_UsesExplicitProfile(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "target"}, + {Name: "default", Default: true}, + }) + cfg.CurrentProfile.Name = "target" + + server := newCrawlerTestServer(t, "token-2", "crawler-user-2", "crawler-key-2") + t.Cleanup(server.Close) + + err := runCrawlerCmd(&CrawlerOptions{ + IO: io, + config: cfg, + NewDashboardClient: newDashboardTestClient(server), + GetValidToken: func(client *dashboard.Client) (string, error) { + return "token-2", nil + }, + }) + require.NoError(t, err) + + assert.Equal(t, test.CrawlerAuth{UserID: "crawler-user-2", APIKey: "crawler-key-2"}, cfg.CrawlerAuth["target"]) + _, hasDefault := cfg.CrawlerAuth["default"] + assert.False(t, hasDefault) + assert.Contains(t, stdout.String(), "configured for profile: target") +} + +func newCrawlerTestServer(t *testing.T, token, userID, apiKey string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer "+token, r.Header.Get("Authorization")) + + switch r.URL.Path { + case "/1/crawler/me": + _, err := fmt.Fprintf(w, `{"success":true,"data":{"id":%q}}`, userID) + require.NoError(t, err) + case "/1/crawler/api_key": + _, err := fmt.Fprintf(w, `{"success":true,"data":{"apiKey":%q}}`, apiKey) + require.NoError(t, err) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) +} + +func newDashboardTestClient(server *httptest.Server) func(string) *dashboard.Client { + return func(clientID string) *dashboard.Client { + client := dashboard.NewClientWithHTTPClient(clientID, server.Client()) + client.APIURL = server.URL + return client + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 539d1506..46b816be 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -26,6 +26,8 @@ type IConfig interface { ApplicationIDExists(appID string) (bool, string) ApplicationIDForProfile(profileName string) (bool, string) + SetCrawlerAuth(profileName, crawlerUserID, crawlerAPIKey string) error + Profile() *Profile Default() *Profile } @@ -148,16 +150,12 @@ func (c *Config) RemoveProfile(name string) error { // SetDefaultProfile set the default profile func (c *Config) SetDefaultProfile(name string) error { - runtimeViper := viper.GetViper() - - // Below is necessary if the config file was just created - runtimeViper.SetConfigType("toml") - err := runtimeViper.ReadInConfig() + configuration, err := c.read() if err != nil { return err } - configs := runtimeViper.AllSettings() + configs := configuration.AllSettings() found := false @@ -175,7 +173,7 @@ func (c *Config) SetDefaultProfile(name string) error { return fmt.Errorf("profile '%s' not found", name) } - return c.write(runtimeViper) + return c.write(configuration) } // ApplicationIDExists check if an application ID exists in any profiles @@ -200,6 +198,47 @@ func (c *Config) ApplicationIDForProfile(profileName string) (bool, string) { return false, "" } +// SetCrawlerAuth sets the config properties for crawler public api +func (c *Config) SetCrawlerAuth(profile, crawlerUserID, crawlerAPIKey string) error { + configuration, err := c.read() + if err != nil { + return err + } + + profiles := configuration.AllSettings() + + found := false + + for profileName := range profiles { + runtimeViper := viper.GetViper() + + if profileName == profile { + found = true + runtimeViper.Set(profileName+".crawler_user_id", crawlerUserID) + runtimeViper.Set(profileName+".crawler_api_key", crawlerAPIKey) + } + } + + if !found { + return fmt.Errorf("profile '%s' not found", profile) + } + + return c.write(configuration) +} + +// read reads the configuration file and returns its runtime +func (c *Config) read() (*viper.Viper, error) { + runtimeViper := viper.GetViper() + + runtimeViper.SetConfigType("toml") + err := runtimeViper.ReadInConfig() + if err != nil { + return nil, err + } + + return runtimeViper, nil +} + // write writes the configuration file func (c *Config) write(runtimeViper *viper.Viper) error { configFile := viper.ConfigFileUsed() diff --git a/test/config.go b/test/config.go index d015690c..6ae88d3e 100644 --- a/test/config.go +++ b/test/config.go @@ -1,12 +1,20 @@ package test import ( + "fmt" + "github.com/algolia/cli/pkg/config" ) +type CrawlerAuth struct { + UserID string + APIKey string +} + type ConfigStub struct { CurrentProfile config.Profile profiles []*config.Profile + CrawlerAuth map[string]CrawlerAuth } func (c *ConfigStub) InitConfig() {} @@ -16,7 +24,13 @@ func (c *ConfigStub) Profile() *config.Profile { } func (c *ConfigStub) Default() *config.Profile { - return &c.CurrentProfile + for _, profile := range c.ConfiguredProfiles() { + if profile.Default { + return profile + } + } + + return nil } func (c *ConfigStub) ConfiguredProfiles() []*config.Profile { @@ -79,6 +93,25 @@ func (c *ConfigStub) SetDefaultProfile(name string) error { return nil } +func (c *ConfigStub) SetCrawlerAuth(name, crawlerUserID, crawlerAPIKey string) error { + for _, profile := range c.ConfiguredProfiles() { + if profile.Name == name { + if c.CrawlerAuth == nil { + c.CrawlerAuth = map[string]CrawlerAuth{} + } + + c.CrawlerAuth[name] = CrawlerAuth{ + UserID: crawlerUserID, + APIKey: crawlerAPIKey, + } + + return nil + } + } + + return fmt.Errorf("profile '%s' not found", name) +} + func NewConfigStubWithProfiles(p []*config.Profile) *ConfigStub { return &ConfigStub{ CurrentProfile: *p[0], From 508a3da63ce49d768c141ad8e8f13a3d5c002f56 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8BitTitan@gmail.com> Date: Thu, 19 Mar 2026 20:42:36 -0400 Subject: [PATCH 3/7] fix(auth): decouple crawler tests from OAuth env --- pkg/cmd/auth/crawler/crawler.go | 8 +++++--- pkg/cmd/auth/crawler/crawler_test.go | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/auth/crawler/crawler.go b/pkg/cmd/auth/crawler/crawler.go index 2c27df36..df1e13e7 100644 --- a/pkg/cmd/auth/crawler/crawler.go +++ b/pkg/cmd/auth/crawler/crawler.go @@ -15,14 +15,16 @@ import ( type CrawlerOptions struct { IO *iostreams.IOStreams config config.IConfig + OAuthClientID func() string NewDashboardClient func(clientID string) *dashboard.Client GetValidToken func(client *dashboard.Client) (string, error) } func NewCrawlerCmd(f *cmdutil.Factory) *cobra.Command { opts := &CrawlerOptions{ - IO: f.IOStreams, - config: f.Config, + IO: f.IOStreams, + config: f.Config, + OAuthClientID: auth.OAuthClientID, NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) }, @@ -43,7 +45,7 @@ func NewCrawlerCmd(f *cmdutil.Factory) *cobra.Command { func runCrawlerCmd(opts *CrawlerOptions) error { cs := opts.IO.ColorScheme() - dashboardClient := opts.NewDashboardClient(auth.OAuthClientID()) + dashboardClient := opts.NewDashboardClient(opts.OAuthClientID()) accessToken, err := opts.GetValidToken(dashboardClient) if err != nil { diff --git a/pkg/cmd/auth/crawler/crawler_test.go b/pkg/cmd/auth/crawler/crawler_test.go index c4c5ba1b..928fe252 100644 --- a/pkg/cmd/auth/crawler/crawler_test.go +++ b/pkg/cmd/auth/crawler/crawler_test.go @@ -31,6 +31,7 @@ func Test_runCrawlerCmd_UsesDefaultProfile(t *testing.T) { err := runCrawlerCmd(&CrawlerOptions{ IO: io, config: cfg, + OAuthClientID: func() string { return "test-client-id" }, NewDashboardClient: newDashboardTestClient(server), GetValidToken: func(client *dashboard.Client) (string, error) { return "token-1", nil @@ -59,6 +60,7 @@ func Test_runCrawlerCmd_UsesExplicitProfile(t *testing.T) { err := runCrawlerCmd(&CrawlerOptions{ IO: io, config: cfg, + OAuthClientID: func() string { return "test-client-id" }, NewDashboardClient: newDashboardTestClient(server), GetValidToken: func(client *dashboard.Client) (string, error) { return "token-2", nil From 161728766692a6475977077be16f099762a10304 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8BitTitan@gmail.com> Date: Fri, 20 Mar 2026 11:58:04 -0400 Subject: [PATCH 4/7] Simplify setting crawler auth settings --- pkg/config/config.go | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 46b816be..d25be6d5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -207,22 +207,13 @@ func (c *Config) SetCrawlerAuth(profile, crawlerUserID, crawlerAPIKey string) er profiles := configuration.AllSettings() - found := false - - for profileName := range profiles { - runtimeViper := viper.GetViper() - - if profileName == profile { - found = true - runtimeViper.Set(profileName+".crawler_user_id", crawlerUserID) - runtimeViper.Set(profileName+".crawler_api_key", crawlerAPIKey) - } - } - - if !found { + if _, exists := profiles[profile]; !exists { return fmt.Errorf("profile '%s' not found", profile) } + configuration.Set(profile+".crawler_user_id", crawlerUserID) + configuration.Set(profile+".crawler_api_key", crawlerAPIKey) + return c.write(configuration) } From 2e4ffd136a1c2065571ec7559b58347372814aa4 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8BitTitan@gmail.com> Date: Fri, 20 Mar 2026 12:57:21 -0400 Subject: [PATCH 5/7] Move to just one request for crawler user data --- api/dashboard/client.go | 39 ++++--------------- api/dashboard/client_test.go | 56 ++++++++++++++++++++++++++++ api/dashboard/types.go | 19 ++++------ pkg/cmd/auth/crawler/crawler.go | 10 +---- pkg/cmd/auth/crawler/crawler_test.go | 7 +--- 5 files changed, 74 insertions(+), 57 deletions(-) diff --git a/api/dashboard/client.go b/api/dashboard/client.go index f78d0e79..488cefb6 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -388,8 +388,9 @@ func (c *Client) CreateAPIKey(accessToken, appID string, acl []string, descripti return key, nil } -func (c *Client) GetCrawlerMe(accessToken string) (*CrawlerUserData, error) { - req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/me", nil) +// GetCrawlerUser gets the crawler API user data for the current authenticated user +func (c *Client) GetCrawlerUser(accessToken string) (*CrawlerUserData, error) { + req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/user", nil) if err != nil { return nil, err } @@ -403,41 +404,15 @@ func (c *Client) GetCrawlerMe(accessToken string) (*CrawlerUserData, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("crawler me failed with status: %d", resp.StatusCode) + return nil, fmt.Errorf("crawler user failed with status: %d", resp.StatusCode) } - var meResp CrawlerMeResponse - if err := json.NewDecoder(resp.Body).Decode(&meResp); err != nil { + var userResp CrawlerUserResponse + if err := json.NewDecoder(resp.Body).Decode(&userResp); err != nil { return nil, fmt.Errorf("failed to parse crawler response: %w", err) } - return &meResp.Data, nil -} - -func (c *Client) GetCrawlerAPIKey(accessToken string) (string, error) { - req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/api_key", nil) - if err != nil { - return "", err - } - - c.setAPIHeaders(req, accessToken) - - resp, err := c.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("crawler api key failed with status: %d", resp.StatusCode) - } - - var apiKeyResp CrawlerAPIKeyResponse - if err := json.NewDecoder(resp.Body).Decode(&apiKeyResp); err != nil { - return "", fmt.Errorf("failed to parse crawler response: %w", err) - } - - return apiKeyResp.Data.APIKey, nil + return &userResp.Data, nil } func (c *Client) setAPIHeaders(req *http.Request, accessToken string) { diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index 09b50b9b..a9c7b807 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -197,3 +197,59 @@ func TestCreateApplication_Success(t *testing.T) { assert.Equal(t, "NEW_APP", app.ID) assert.Equal(t, "My App", app.Name) } + +func TestGetCrawlerUser_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + require.NoError(t, json.NewEncoder(w).Encode(CrawlerUserResponse{ + Data: CrawlerUserData{ + ID: "crawler-user-id", + Email: "crawler@example.com", + Name: "Crawler User", + APIKey: "crawler-api-key", + }, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + user, err := client.GetCrawlerUser("test-token") + require.NoError(t, err) + assert.Equal(t, "crawler-user-id", user.ID) + assert.Equal(t, "crawler@example.com", user.Email) + assert.Equal(t, "Crawler User", user.Name) + assert.Equal(t, "crawler-api-key", user.APIKey) +} + +func TestGetCrawlerUser_HTTPError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.GetCrawlerUser("test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "crawler user failed with status: 403") +} + +func TestGetCrawlerUser_InvalidJSON(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(`{"data":`)) + require.NoError(t, err) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.GetCrawlerUser("test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse crawler response") +} diff --git a/api/dashboard/types.go b/api/dashboard/types.go index 7c21dd82..d31eb3fb 100644 --- a/api/dashboard/types.go +++ b/api/dashboard/types.go @@ -111,22 +111,17 @@ type CreateAPIKeyResponse struct { Data APIKeyResource `json:"data"` } +// CrawlerUserData contains the user information from the crawler API type CrawlerUserData struct { - ID string `json:"id"` -} - -type CrawlerMeResponse struct { - Success bool `json:"success"` - Data CrawlerUserData `json:"data"` -} - -type CrawlerAPIKeyData struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` APIKey string `json:"apiKey"` } -type CrawlerAPIKeyResponse struct { - Success bool `json:"success"` - Data CrawlerAPIKeyData `json:"data"` +// CrawlerUserResponse is the JSON:API response from GET /1/crawler/user +type CrawlerUserResponse struct { + Data CrawlerUserData `json:"data"` } // toApplication flattens a JSON:API resource into a simple Application. diff --git a/pkg/cmd/auth/crawler/crawler.go b/pkg/cmd/auth/crawler/crawler.go index df1e13e7..86292e59 100644 --- a/pkg/cmd/auth/crawler/crawler.go +++ b/pkg/cmd/auth/crawler/crawler.go @@ -53,13 +53,7 @@ func runCrawlerCmd(opts *CrawlerOptions) error { } opts.IO.StartProgressIndicatorWithLabel("Fetching crawler information") - crawlerUserData, err := dashboardClient.GetCrawlerMe(accessToken) - if err != nil { - opts.IO.StopProgressIndicator() - return err - } - - crawlerAPIKey, err := dashboardClient.GetCrawlerAPIKey(accessToken) + crawlerUserData, err := dashboardClient.GetCrawlerUser(accessToken) opts.IO.StopProgressIndicator() if err != nil { return err @@ -77,7 +71,7 @@ func runCrawlerCmd(opts *CrawlerOptions) error { return fmt.Errorf("no profile selected and no default profile configured") } - if err = opts.config.SetCrawlerAuth(currentProfileName, crawlerUserData.ID, crawlerAPIKey); err != nil { + if err = opts.config.SetCrawlerAuth(currentProfileName, crawlerUserData.ID, crawlerUserData.APIKey); err != nil { return err } diff --git a/pkg/cmd/auth/crawler/crawler_test.go b/pkg/cmd/auth/crawler/crawler_test.go index 928fe252..d95f1e2d 100644 --- a/pkg/cmd/auth/crawler/crawler_test.go +++ b/pkg/cmd/auth/crawler/crawler_test.go @@ -81,11 +81,8 @@ func newCrawlerTestServer(t *testing.T, token, userID, apiKey string) *httptest. require.Equal(t, "Bearer "+token, r.Header.Get("Authorization")) switch r.URL.Path { - case "/1/crawler/me": - _, err := fmt.Fprintf(w, `{"success":true,"data":{"id":%q}}`, userID) - require.NoError(t, err) - case "/1/crawler/api_key": - _, err := fmt.Fprintf(w, `{"success":true,"data":{"apiKey":%q}}`, apiKey) + case "/1/crawler/user": + _, err := fmt.Fprintf(w, `{"data":{"id":%q,"email":"crawler@example.com","name":"Crawler User","apiKey":%q}}`, userID, apiKey) require.NoError(t, err) default: t.Fatalf("unexpected path: %s", r.URL.Path) From 0d8b4aefd0a370114cacbda55323bedba8c02f2e Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8BitTitan@gmail.com> Date: Fri, 20 Mar 2026 13:37:59 -0400 Subject: [PATCH 6/7] Handle dashboard crawler errors better --- api/dashboard/client.go | 11 ++++++--- api/dashboard/client_test.go | 28 ++++++++++++++++++++--- api/dashboard/types.go | 16 +++++++++---- pkg/cmd/auth/crawler/crawler_test.go | 34 ++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/api/dashboard/client.go b/api/dashboard/client.go index 488cefb6..c1625934 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -389,7 +389,7 @@ func (c *Client) CreateAPIKey(accessToken, appID string, acl []string, descripti } // GetCrawlerUser gets the crawler API user data for the current authenticated user -func (c *Client) GetCrawlerUser(accessToken string) (*CrawlerUserData, error) { +func (c *Client) GetCrawlerUser(accessToken string) (*DashboardCrawlerUserData, error) { req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/user", nil) if err != nil { return nil, err @@ -404,10 +404,15 @@ func (c *Client) GetCrawlerUser(accessToken string) (*CrawlerUserData, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("crawler user failed with status: %d", resp.StatusCode) + var errResp DashboardCrawlerErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { + return nil, fmt.Errorf("failed to parse crawler response: %w", err) + } + + return nil, fmt.Errorf("failed to get crawler user data: %s", errResp.Message) } - var userResp CrawlerUserResponse + var userResp DashboardCrawlerUserResponse if err := json.NewDecoder(resp.Body).Decode(&userResp); err != nil { return nil, fmt.Errorf("failed to parse crawler response: %w", err) } diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index a9c7b807..76141b06 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -204,8 +204,8 @@ func TestGetCrawlerUser_Success(t *testing.T) { assert.Equal(t, http.MethodGet, r.Method) assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - require.NoError(t, json.NewEncoder(w).Encode(CrawlerUserResponse{ - Data: CrawlerUserData{ + require.NoError(t, json.NewEncoder(w).Encode(DashboardCrawlerUserResponse{ + Data: DashboardCrawlerUserData{ ID: "crawler-user-id", Email: "crawler@example.com", Name: "Crawler User", @@ -229,6 +229,11 @@ func TestGetCrawlerUser_HTTPError(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) + require.NoError(t, json.NewEncoder(w).Encode(DashboardCrawlerErrorResponse{ + Success: false, + Code: http.StatusForbidden, + Message: "forbidden", + })) }) ts, client := newTestClient(mux) @@ -236,7 +241,8 @@ func TestGetCrawlerUser_HTTPError(t *testing.T) { _, err := client.GetCrawlerUser("test-token") require.Error(t, err) - assert.Contains(t, err.Error(), "crawler user failed with status: 403") + assert.Contains(t, err.Error(), "failed to get crawler user data: forbidden") + assert.NotContains(t, err.Error(), "403") } func TestGetCrawlerUser_InvalidJSON(t *testing.T) { @@ -253,3 +259,19 @@ func TestGetCrawlerUser_InvalidJSON(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "failed to parse crawler response") } + +func TestGetCrawlerUser_HTTPErrorInvalidJSON(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, err := w.Write([]byte(`{"message":`)) + require.NoError(t, err) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.GetCrawlerUser("test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse crawler response") +} diff --git a/api/dashboard/types.go b/api/dashboard/types.go index d31eb3fb..a2c8cb60 100644 --- a/api/dashboard/types.go +++ b/api/dashboard/types.go @@ -111,17 +111,23 @@ type CreateAPIKeyResponse struct { Data APIKeyResource `json:"data"` } -// CrawlerUserData contains the user information from the crawler API -type CrawlerUserData struct { +// DashboardCrawlerUserData contains the user information from the crawler API +type DashboardCrawlerUserData struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` APIKey string `json:"apiKey"` } -// CrawlerUserResponse is the JSON:API response from GET /1/crawler/user -type CrawlerUserResponse struct { - Data CrawlerUserData `json:"data"` +// DashboardCrawlerUserResponse is the JSON:API response from GET /1/crawler/user +type DashboardCrawlerUserResponse struct { + Data DashboardCrawlerUserData `json:"data"` +} + +type DashboardCrawlerErrorResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + Message string `json:"message"` } // toApplication flattens a JSON:API resource into a simple Application. diff --git a/pkg/cmd/auth/crawler/crawler_test.go b/pkg/cmd/auth/crawler/crawler_test.go index d95f1e2d..559db822 100644 --- a/pkg/cmd/auth/crawler/crawler_test.go +++ b/pkg/cmd/auth/crawler/crawler_test.go @@ -74,6 +74,40 @@ func Test_runCrawlerCmd_UsesExplicitProfile(t *testing.T) { assert.Contains(t, stdout.String(), "configured for profile: target") } +func Test_runCrawlerCmd_ReturnsCrawlerAPIError(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "target"}, + }) + cfg.CurrentProfile.Name = "target" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer token-3", r.Header.Get("Authorization")) + require.Equal(t, "/1/crawler/user", r.URL.Path) + + w.WriteHeader(http.StatusForbidden) + _, err := fmt.Fprint(w, `{"success":false,"code":403,"message":"crawler access denied"}`) + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + err := runCrawlerCmd(&CrawlerOptions{ + IO: io, + config: cfg, + OAuthClientID: func() string { return "test-client-id" }, + NewDashboardClient: newDashboardTestClient(server), + GetValidToken: func(client *dashboard.Client) (string, error) { + return "token-3", nil + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get crawler user data: crawler access denied") + assert.Empty(t, cfg.CrawlerAuth) + assert.Empty(t, stdout.String()) +} + func newCrawlerTestServer(t *testing.T, token, userID, apiKey string) *httptest.Server { t.Helper() From ed72bd58ef5ef7571c3e8a92add6d7df3e9ad3f7 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8BitTitan@gmail.com> Date: Fri, 20 Mar 2026 15:06:20 -0400 Subject: [PATCH 7/7] fix(crawler): parse dashboard error responses --- .tool-versions | 2 ++ api/dashboard/client.go | 13 +++++++++++- api/dashboard/client_test.go | 31 +++++++++++++++++++++++++--- api/dashboard/types.go | 10 ++++++--- pkg/cmd/auth/crawler/crawler_test.go | 2 +- 5 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..58476e2f --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +golangci-lint 1.63.4 +golang 1.23.4 diff --git a/api/dashboard/client.go b/api/dashboard/client.go index c1625934..f3a24c37 100644 --- a/api/dashboard/client.go +++ b/api/dashboard/client.go @@ -409,7 +409,18 @@ func (c *Client) GetCrawlerUser(accessToken string) (*DashboardCrawlerUserData, return nil, fmt.Errorf("failed to parse crawler response: %w", err) } - return nil, fmt.Errorf("failed to get crawler user data: %s", errResp.Message) + if len(errResp.Errors) == 0 { + return nil, fmt.Errorf("failed to get crawler user data: unknown crawler error") + } + + crawlerError := errResp.Errors[0] + + message := crawlerError.Title + if crawlerError.Detail != nil && *crawlerError.Detail != "" { + message = *crawlerError.Detail + } + + return nil, fmt.Errorf("failed to get crawler user data: %s", message) } var userResp DashboardCrawlerUserResponse diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index 76141b06..68d57d1b 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -229,10 +229,13 @@ func TestGetCrawlerUser_HTTPError(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) + detail := "forbidden" require.NoError(t, json.NewEncoder(w).Encode(DashboardCrawlerErrorResponse{ - Success: false, - Code: http.StatusForbidden, - Message: "forbidden", + Errors: []DashboardCrawlerError{{ + Status: http.StatusText(http.StatusForbidden), + Title: "Forbidden", + Detail: &detail, + }}, })) }) @@ -245,6 +248,28 @@ func TestGetCrawlerUser_HTTPError(t *testing.T) { assert.NotContains(t, err.Error(), "403") } +func TestGetCrawlerUser_HTTPErrorWithoutDetail(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + require.NoError(t, json.NewEncoder(w).Encode(DashboardCrawlerErrorResponse{ + Errors: []DashboardCrawlerError{{ + Status: http.StatusText(http.StatusForbidden), + Title: "Forbidden", + Detail: nil, + }}, + })) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + _, err := client.GetCrawlerUser("test-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get crawler user data: Forbidden") + assert.NotContains(t, err.Error(), "403") +} + func TestGetCrawlerUser_InvalidJSON(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/1/crawler/user", func(w http.ResponseWriter, r *http.Request) { diff --git a/api/dashboard/types.go b/api/dashboard/types.go index a2c8cb60..e522f768 100644 --- a/api/dashboard/types.go +++ b/api/dashboard/types.go @@ -125,9 +125,13 @@ type DashboardCrawlerUserResponse struct { } type DashboardCrawlerErrorResponse struct { - Success bool `json:"success"` - Code int `json:"code"` - Message string `json:"message"` + Errors []DashboardCrawlerError `json:"errors"` +} + +type DashboardCrawlerError struct { + Status string `json:"status"` + Title string `json:"title"` + Detail *string `json:"detail"` } // toApplication flattens a JSON:API resource into a simple Application. diff --git a/pkg/cmd/auth/crawler/crawler_test.go b/pkg/cmd/auth/crawler/crawler_test.go index 559db822..92152808 100644 --- a/pkg/cmd/auth/crawler/crawler_test.go +++ b/pkg/cmd/auth/crawler/crawler_test.go @@ -88,7 +88,7 @@ func Test_runCrawlerCmd_ReturnsCrawlerAPIError(t *testing.T) { require.Equal(t, "/1/crawler/user", r.URL.Path) w.WriteHeader(http.StatusForbidden) - _, err := fmt.Fprint(w, `{"success":false,"code":403,"message":"crawler access denied"}`) + _, err := fmt.Fprint(w, `{"errors":[{"status":"Forbidden","title":"Forbidden","detail":"crawler access denied"}]}`) require.NoError(t, err) })) t.Cleanup(server.Close)