Skip to content

Commit 81bd9d8

Browse files
committed
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.
1 parent aeefefe commit 81bd9d8

File tree

7 files changed

+340
-8
lines changed

7 files changed

+340
-8
lines changed

api/dashboard/client.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,58 @@ func (c *Client) ListRegions(accessToken string) ([]Region, error) {
337337
return regionsResp.RegionCodes, nil
338338
}
339339

340+
func (c *Client) GetCrawlerMe(accessToken string) (*CrawlerUserData, error) {
341+
req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/me", nil)
342+
if err != nil {
343+
return nil, err
344+
}
345+
346+
c.setAPIHeaders(req, accessToken)
347+
348+
resp, err := c.client.Do(req)
349+
if err != nil {
350+
return nil, err
351+
}
352+
defer resp.Body.Close()
353+
354+
if resp.StatusCode != http.StatusOK {
355+
return nil, fmt.Errorf("crawler me failed with status: %d", resp.StatusCode)
356+
}
357+
358+
var meResp CrawlerMeResponse
359+
if err := json.NewDecoder(resp.Body).Decode(&meResp); err != nil {
360+
return nil, fmt.Errorf("failed to parse crawler response: %w", err)
361+
}
362+
363+
return &meResp.Data, nil
364+
}
365+
366+
func (c *Client) GetCrawlerAPIKey(accessToken string) (string, error) {
367+
req, err := http.NewRequest(http.MethodGet, c.APIURL+"/1/crawler/api_key", nil)
368+
if err != nil {
369+
return "", err
370+
}
371+
372+
c.setAPIHeaders(req, accessToken)
373+
374+
resp, err := c.client.Do(req)
375+
if err != nil {
376+
return "", err
377+
}
378+
defer resp.Body.Close()
379+
380+
if resp.StatusCode != http.StatusOK {
381+
return "", fmt.Errorf("crawler me failed with status: %d", resp.StatusCode)
382+
}
383+
384+
var apiKeyResp CrawlerAPIKeyResponse
385+
if err := json.NewDecoder(resp.Body).Decode(&apiKeyResp); err != nil {
386+
return "", fmt.Errorf("failed to parse crawler response: %w", err)
387+
}
388+
389+
return apiKeyResp.Data.APIKey, nil
390+
}
391+
340392
func (c *Client) setAPIHeaders(req *http.Request, accessToken string) {
341393
req.Header.Set("Authorization", "Bearer "+accessToken)
342394
req.Header.Set("Accept", "application/vnd.api+json")

api/dashboard/types.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ type OAuthErrorResponse struct {
8888
ErrorDescription string `json:"error_description"`
8989
}
9090

91+
type CrawlerUserData struct {
92+
ID string `json:"id"`
93+
}
94+
95+
type CrawlerMeResponse struct {
96+
Success bool `json:"success"`
97+
Data CrawlerUserData `json:"data"`
98+
}
99+
100+
type CrawlerAPIKeyData struct {
101+
APIKey string `json:"apiKey"`
102+
}
103+
104+
type CrawlerAPIKeyResponse struct {
105+
Success bool `json:"success"`
106+
Data CrawlerAPIKeyData `json:"data"`
107+
}
108+
91109
// toApplication flattens a JSON:API resource into a simple Application.
92110
func (r *ApplicationResource) toApplication() Application {
93111
return Application{

pkg/cmd/auth/auth.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/spf13/cobra"
55

66
"github.com/algolia/cli/pkg/auth"
7+
"github.com/algolia/cli/pkg/cmd/auth/crawler"
78
"github.com/algolia/cli/pkg/cmd/auth/login"
89
"github.com/algolia/cli/pkg/cmd/auth/logout"
910
"github.com/algolia/cli/pkg/cmd/auth/signup"
@@ -22,6 +23,7 @@ func NewAuthCmd(f *cmdutil.Factory) *cobra.Command {
2223
cmd.AddCommand(login.NewLoginCmd(f))
2324
cmd.AddCommand(logout.NewLogoutCmd(f))
2425
cmd.AddCommand(signup.NewSignupCmd(f))
26+
cmd.AddCommand(crawler.NewCrawlerCmd(f))
2527

2628
return cmd
2729
}

pkg/cmd/auth/crawler/crawler.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package crawler
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/algolia/cli/api/dashboard"
7+
"github.com/algolia/cli/pkg/auth"
8+
"github.com/algolia/cli/pkg/cmd/auth/login"
9+
"github.com/algolia/cli/pkg/cmdutil"
10+
"github.com/algolia/cli/pkg/config"
11+
"github.com/algolia/cli/pkg/iostreams"
12+
"github.com/algolia/cli/pkg/validators"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type CrawlerOptions struct {
17+
IO *iostreams.IOStreams
18+
config config.IConfig
19+
NewDashboardClient func(clientID string) *dashboard.Client
20+
GetValidToken func(client *dashboard.Client) (string, error)
21+
}
22+
23+
func NewCrawlerCmd(f *cmdutil.Factory) *cobra.Command {
24+
opts := &CrawlerOptions{
25+
IO: f.IOStreams,
26+
config: f.Config,
27+
NewDashboardClient: func(clientID string) *dashboard.Client {
28+
return dashboard.NewClient(clientID)
29+
},
30+
GetValidToken: auth.GetValidToken,
31+
}
32+
33+
cmd := &cobra.Command{
34+
Use: "crawler",
35+
Short: "Load crawler auth details for the current profile",
36+
Args: validators.NoArgs(),
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
return runCrawlerCmd(opts)
39+
},
40+
}
41+
42+
return cmd
43+
}
44+
45+
func runCrawlerCmd(opts *CrawlerOptions) error {
46+
cs := opts.IO.ColorScheme()
47+
dashboardClient := opts.NewDashboardClient(login.OAuthClientID())
48+
49+
accessToken, err := opts.GetValidToken(dashboardClient)
50+
if err != nil {
51+
return err
52+
}
53+
54+
opts.IO.StartProgressIndicatorWithLabel("Fetching crawler information")
55+
crawlerUserData, err := dashboardClient.GetCrawlerMe(accessToken)
56+
if err != nil {
57+
opts.IO.StopProgressIndicator()
58+
return err
59+
}
60+
61+
crawlerAPIKey, err := dashboardClient.GetCrawlerAPIKey(accessToken)
62+
opts.IO.StopProgressIndicator()
63+
if err != nil {
64+
return err
65+
}
66+
67+
currentProfileName := opts.config.Profile().Name
68+
if currentProfileName == "" {
69+
defaultProfile := opts.config.Default()
70+
if defaultProfile != nil {
71+
currentProfileName = defaultProfile.Name
72+
opts.config.Profile().Name = currentProfileName
73+
}
74+
}
75+
if currentProfileName == "" {
76+
return fmt.Errorf("no profile selected and no default profile configured")
77+
}
78+
79+
if err = opts.config.SetCrawlerAuth(currentProfileName, crawlerUserData.ID, crawlerAPIKey); err != nil {
80+
return err
81+
}
82+
83+
if opts.IO.IsStdoutTTY() {
84+
fmt.Fprintf(opts.IO.Out, "%s Crawler API auth credentials configured for profile: %s\n", cs.SuccessIcon(), currentProfileName)
85+
}
86+
87+
return nil
88+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package crawler
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/algolia/cli/api/dashboard"
13+
"github.com/algolia/cli/pkg/config"
14+
"github.com/algolia/cli/pkg/iostreams"
15+
"github.com/algolia/cli/test"
16+
)
17+
18+
func Test_runCrawlerCmd_UsesDefaultProfile(t *testing.T) {
19+
io, _, stdout, _ := iostreams.Test()
20+
io.SetStdoutTTY(true)
21+
22+
cfg := test.NewConfigStubWithProfiles([]*config.Profile{
23+
{Name: "default", Default: true},
24+
{Name: "other"},
25+
})
26+
cfg.CurrentProfile.Name = ""
27+
28+
server := newCrawlerTestServer(t, "token-1", "crawler-user", "crawler-key")
29+
t.Cleanup(server.Close)
30+
31+
err := runCrawlerCmd(&CrawlerOptions{
32+
IO: io,
33+
config: cfg,
34+
NewDashboardClient: newDashboardTestClient(server),
35+
GetValidToken: func(client *dashboard.Client) (string, error) {
36+
return "token-1", nil
37+
},
38+
})
39+
require.NoError(t, err)
40+
41+
assert.Equal(t, "default", cfg.CurrentProfile.Name)
42+
assert.Equal(t, test.CrawlerAuth{UserID: "crawler-user", APIKey: "crawler-key"}, cfg.CrawlerAuth["default"])
43+
assert.Contains(t, stdout.String(), "configured for profile: default")
44+
}
45+
46+
func Test_runCrawlerCmd_UsesExplicitProfile(t *testing.T) {
47+
io, _, stdout, _ := iostreams.Test()
48+
io.SetStdoutTTY(true)
49+
50+
cfg := test.NewConfigStubWithProfiles([]*config.Profile{
51+
{Name: "target"},
52+
{Name: "default", Default: true},
53+
})
54+
cfg.CurrentProfile.Name = "target"
55+
56+
server := newCrawlerTestServer(t, "token-2", "crawler-user-2", "crawler-key-2")
57+
t.Cleanup(server.Close)
58+
59+
err := runCrawlerCmd(&CrawlerOptions{
60+
IO: io,
61+
config: cfg,
62+
NewDashboardClient: newDashboardTestClient(server),
63+
GetValidToken: func(client *dashboard.Client) (string, error) {
64+
return "token-2", nil
65+
},
66+
})
67+
require.NoError(t, err)
68+
69+
assert.Equal(t, test.CrawlerAuth{UserID: "crawler-user-2", APIKey: "crawler-key-2"}, cfg.CrawlerAuth["target"])
70+
_, hasDefault := cfg.CrawlerAuth["default"]
71+
assert.False(t, hasDefault)
72+
assert.Contains(t, stdout.String(), "configured for profile: target")
73+
}
74+
75+
func newCrawlerTestServer(t *testing.T, token, userID, apiKey string) *httptest.Server {
76+
t.Helper()
77+
78+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+
require.Equal(t, "Bearer "+token, r.Header.Get("Authorization"))
80+
81+
switch r.URL.Path {
82+
case "/1/crawler/me":
83+
_, err := fmt.Fprintf(w, `{"success":true,"data":{"id":%q}}`, userID)
84+
require.NoError(t, err)
85+
case "/1/crawler/api_key":
86+
_, err := fmt.Fprintf(w, `{"success":true,"data":{"apiKey":%q}}`, apiKey)
87+
require.NoError(t, err)
88+
default:
89+
t.Fatalf("unexpected path: %s", r.URL.Path)
90+
}
91+
}))
92+
}
93+
94+
func newDashboardTestClient(server *httptest.Server) func(string) *dashboard.Client {
95+
return func(clientID string) *dashboard.Client {
96+
client := dashboard.NewClientWithHTTPClient(clientID, server.Client())
97+
client.APIURL = server.URL
98+
return client
99+
}
100+
}

pkg/config/config.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type IConfig interface {
2626
ApplicationIDExists(appID string) (bool, string)
2727
ApplicationIDForProfile(profileName string) (bool, string)
2828

29+
SetCrawlerAuth(profileName, crawlerUserID, crawlerAPIKey string) error
30+
2931
Profile() *Profile
3032
Default() *Profile
3133
}
@@ -148,16 +150,12 @@ func (c *Config) RemoveProfile(name string) error {
148150

149151
// SetDefaultProfile set the default profile
150152
func (c *Config) SetDefaultProfile(name string) error {
151-
runtimeViper := viper.GetViper()
152-
153-
// Below is necessary if the config file was just created
154-
runtimeViper.SetConfigType("toml")
155-
err := runtimeViper.ReadInConfig()
153+
configuration, err := c.read()
156154
if err != nil {
157155
return err
158156
}
159157

160-
configs := runtimeViper.AllSettings()
158+
configs := configuration.AllSettings()
161159

162160
found := false
163161

@@ -175,7 +173,7 @@ func (c *Config) SetDefaultProfile(name string) error {
175173
return fmt.Errorf("profile '%s' not found", name)
176174
}
177175

178-
return c.write(runtimeViper)
176+
return c.write(configuration)
179177
}
180178

181179
// ApplicationIDExists check if an application ID exists in any profiles
@@ -200,6 +198,47 @@ func (c *Config) ApplicationIDForProfile(profileName string) (bool, string) {
200198
return false, ""
201199
}
202200

201+
// SetCrawlerAuth sets the config properties for crawler public api
202+
func (c *Config) SetCrawlerAuth(profile, crawlerUserID, crawlerAPIKey string) error {
203+
configuration, err := c.read()
204+
if err != nil {
205+
return err
206+
}
207+
208+
profiles := configuration.AllSettings()
209+
210+
found := false
211+
212+
for profileName := range profiles {
213+
runtimeViper := viper.GetViper()
214+
215+
if profileName == profile {
216+
found = true
217+
runtimeViper.Set(profileName+".crawler_user_id", crawlerUserID)
218+
runtimeViper.Set(profileName+".crawler_api_key", crawlerAPIKey)
219+
}
220+
}
221+
222+
if !found {
223+
return fmt.Errorf("profile '%s' not found", profile)
224+
}
225+
226+
return c.write(configuration)
227+
}
228+
229+
// read reads the configuration file and returns its runtime
230+
func (c *Config) read() (*viper.Viper, error) {
231+
runtimeViper := viper.GetViper()
232+
233+
runtimeViper.SetConfigType("toml")
234+
err := runtimeViper.ReadInConfig()
235+
if err != nil {
236+
return nil, err
237+
}
238+
239+
return runtimeViper, nil
240+
}
241+
203242
// write writes the configuration file
204243
func (c *Config) write(runtimeViper *viper.Viper) error {
205244
configFile := viper.ConfigFileUsed()

0 commit comments

Comments
 (0)