diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index e8298620..ad69d089 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -58,22 +58,40 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { "skipAuthCheck": "true", }, RunE: func(cmd *cobra.Command, args []string) error { - return runSelectCmd(opts) + _, err := runSelectCmd(opts) + return err }, } - cmd.Flags().StringVar(&opts.AppName, "app-name", "", "Select application by name (non-interactive)") + cmd.Flags(). + StringVar(&opts.AppName, "app-name", "", "Select application by name (non-interactive)") return cmd } -func runSelectCmd(opts *SelectOptions) error { +// Run executes the interactive application-selection flow and returns the +// chosen application. Other commands (e.g. open) use it to ensure an +// application is selected before proceeding. A nil application is returned +// when the account has no applications. +func Run(f *cmdutil.Factory) (*dashboard.Application, error) { + opts := &SelectOptions{ + IO: f.IOStreams, + Config: f.Config, + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + } + + return runSelectCmd(opts) +} + +func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) { cs := opts.IO.ColorScheme() client := opts.NewDashboardClient(auth.OAuthClientID()) accessToken, err := auth.EnsureAuthenticated(opts.IO, client) if err != nil { - return err + return nil, err } opts.IO.StartProgressIndicatorWithLabel("Fetching applications") @@ -82,26 +100,26 @@ func runSelectCmd(opts *SelectOptions) error { if err != nil { newToken, reAuthErr := auth.ReauthenticateIfExpired(opts.IO, client, err) if reAuthErr != nil { - return reAuthErr + return nil, reAuthErr } accessToken = newToken opts.IO.StartProgressIndicatorWithLabel("Fetching applications") apps, err = client.ListApplications(accessToken) opts.IO.StopProgressIndicator() if err != nil { - return err + return nil, err } } if len(apps) == 0 { fmt.Fprintf(opts.IO.Out, "%s No applications found.\n", cs.WarningIcon()) fmt.Fprintf(opts.IO.Out, " Use %s to create one.\n", cs.Bold("algolia application create")) - return nil + return nil, nil } chosen, err := pickApplication(opts, apps) if err != nil { - return err + return nil, err } // If a profile already exists for this app, switch the default @@ -119,7 +137,7 @@ func runSelectCmd(opts *SelectOptions) error { } if err := opts.Config.SetDefaultProfile(profileName); err != nil { - return fmt.Errorf("failed to set default profile: %w", err) + return nil, fmt.Errorf("failed to set default profile: %w", err) } fmt.Fprintf(opts.IO.Out, "%s Switched to profile %q (application %s).\n", cs.SuccessIcon(), profileName, cs.Bold(chosen.ID)) @@ -127,27 +145,34 @@ func runSelectCmd(opts *SelectOptions) error { if existingProfile != nil && existingProfile.APIKey == "" { app := &dashboard.Application{ID: chosen.ID, Name: chosen.Name} if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, app); err != nil { - return err + return nil, err } existingProfile.ApplicationID = chosen.ID existingProfile.APIKey = app.APIKey if err := existingProfile.Add(); err != nil { - return err + return nil, err } fmt.Fprintf(opts.IO.Out, "%s Profile %q updated with API key.\n", cs.SuccessIcon(), profileName) } - return nil + return chosen, nil } if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, chosen); err != nil { - return err + return nil, err + } + + if err := apputil.ConfigureProfile(opts.IO, opts.Config, chosen, "", true); err != nil { + return nil, err } - return apputil.ConfigureProfile(opts.IO, opts.Config, chosen, "", true) + return chosen, nil } -func pickApplication(opts *SelectOptions, apps []dashboard.Application) (*dashboard.Application, error) { +func pickApplication( + opts *SelectOptions, + apps []dashboard.Application, +) (*dashboard.Application, error) { if opts.AppName != "" { for i := range apps { if apps[i].Name == opts.AppName { diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index 2118e6ce..5a1fbf18 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -3,110 +3,172 @@ package open import ( "fmt" "sort" + "strings" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" + "github.com/algolia/cli/api/dashboard" "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmd/application/selectapp" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/open" + pkgopen "github.com/algolia/cli/pkg/open" "github.com/algolia/cli/pkg/printers" ) -type OpenURL struct { - Default string - WithAppID string +// resourceURL is a static shortcut that does not require sign-in. +type resourceURL struct { + // Default is the absolute URL used when no application is configured. + Default string + // AppPath, when set, is the dashboard path used when an application is + // configured. It is resolved against ALGOLIA_DASHBOARD_URL as + // {base}/apps/{appID}/{AppPath}. + AppPath string } -var openURLMap = map[string]OpenURL{ - "api": {Default: "https://www.algolia.com/doc/api-reference/rest-api/"}, - "codex": {Default: "https://www.algolia.com/developers/code-exchange/"}, - "cli-docs": {Default: "https://algolia.com/doc/tools/cli/get-started/overview/"}, - "cli-repo": {Default: "https://github.com/algolia/cli"}, - "dashboard": { - Default: "https://www.algolia.com/dashboard", - WithAppID: "https://www.algolia.com/apps/%s/dashboard", - }, +var resourceURLs = map[string]resourceURL{ + "api": {Default: "https://www.algolia.com/doc/api-reference/rest-api/"}, + "codex": {Default: "https://www.algolia.com/developers/code-exchange/"}, + "cli-docs": {Default: "https://algolia.com/doc/tools/cli/get-started/overview/"}, + "cli-repo": {Default: "https://github.com/algolia/cli"}, "devhub": {Default: "https://www.algolia.com/developers/"}, "docs": {Default: "https://algolia.com/doc/"}, "languages": {Default: "https://alg.li/supported-languages"}, "status": { - Default: "https://status.algolia.com/", - WithAppID: "https://www.algolia.com/apps/%s/monitoring/status", + Default: "https://status.algolia.com/", + AppPath: "monitoring/status", }, } -func openNames() []string { - keys := make([]string, 0, len(openURLMap)) - for k := range openURLMap { - keys = append(keys, k) - } +// dashboardTarget is an application dashboard page. These require sign-in and +// are scoped to the current application (selecting one if none is configured). +type dashboardTarget struct { + // path is the dashboard path for the destination. + path string + // accountScoped marks account-level pages. They live at + // {base}/{path}?applicationId={appID}, whereas application pages live at + // {base}/apps/{appID}/{path}. + accountScoped bool +} - return keys +var dashboardTargets = map[string]dashboardTarget{ + "dashboard": {path: "dashboard"}, + "indices": {path: "explorer/browse"}, + "crawler": {path: "crawler"}, + "connectors": {path: "connectors"}, + "api-keys": {path: "account/api-keys/all", accountScoped: true}, + "usage": {path: "account/billing/usage", accountScoped: true}, + "team": {path: "account/teams", accountScoped: true}, + "billing": {path: "account/billing/details", accountScoped: true}, } -func getNameURLMap(applicationID string) map[string]string { - nameURLMap := make(map[string]string) - for _, openName := range openNames() { - url := openURLMap[openName].Default - if applicationID != "" && openURLMap[openName].WithAppID != "" { - url = fmt.Sprintf(openURLMap[openName].WithAppID, applicationID) - } - nameURLMap[openName] = url +// targetNames returns every supported shortcut, sorted. +func targetNames() []string { + names := make([]string, 0, len(resourceURLs)+len(dashboardTargets)) + for name := range resourceURLs { + names = append(names, name) + } + for name := range dashboardTargets { + names = append(names, name) } + sort.Strings(names) + + return names +} + +func unsupportedShortcutError(shortcut string) error { + return fmt.Errorf( + "unsupported open command, given: %s\n\nAvailable shortcuts: %s", + shortcut, + strings.Join(targetNames(), ", "), + ) +} - return nameURLMap +// pageEntry describes an open shortcut for machine-readable output. +type pageEntry struct { + Shortcut string `json:"shortcut"` + URL string `json:"url"` + RequiresLogin bool `json:"requiresLogin"` } -// OpenOptions represents the options for the open command +// OpenOptions represents the options for the open command. The function fields +// are injected so the flow can be exercised without a real OAuth session or +// browser. type OpenOptions struct { config config.IConfig IO *iostreams.IOStreams List bool Shortcut string + + PrintFlags *cmdutil.PrintFlags + + Authenticate func(*iostreams.IOStreams, *dashboard.Client) (string, error) + SelectApplication func() (*dashboard.Application, error) + ListApplications func(*dashboard.Client, string) ([]dashboard.Application, error) + NewDashboardClient func(clientID string) *dashboard.Client + Browser func(string) error } func NewOpenCmd(f *cmdutil.Factory) *cobra.Command { opts := &OpenOptions{ - IO: f.IOStreams, - config: f.Config, + IO: f.IOStreams, + config: f.Config, + PrintFlags: cmdutil.NewPrintFlags(), + Authenticate: auth.EnsureAuthenticated, + SelectApplication: func() (*dashboard.Application, error) { + return selectapp.Run(f) + }, + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + Browser: pkgopen.Browser, } + cmd := &cobra.Command{ Use: "open ", - ValidArgs: openNames(), + ValidArgs: targetNames(), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if opts.List { - return nil, cobra.ShellCompDirectiveNoFileComp - } - return openNames(), cobra.ShellCompDirectiveNoFileComp + return targetNames(), cobra.ShellCompDirectiveNoFileComp }, - Short: "Access Algolia support resources", - Long: `The open command provides links to Algolia support resources. 'algolia open --list' for a list of support links.`, + Short: "Open Algolia pages in your browser", + Long: heredoc.Doc(` + Open Algolia pages in your browser. + + Resource shortcuts (docs, API reference, status, …) open directly. + + Application pages (dashboard, indices, crawler, connectors, api-keys, + usage, team, billing) are scoped to the current application: they + require you to be signed in, and prompt you to select an application + if none is configured. + + Run 'algolia open --list' to see every shortcut. + + With an output format (--output), the resolved page links are printed + instead of opening a browser. + `), Example: heredoc.Doc(` - # The support links + # List all shortcuts $ algolia open --list - # The Algolia dashboard for the current application - $ algolia open dashboard - - # The Algolia REST APIs - $ algolia open api - - # The Algolia documentation home page + # List all shortcuts as JSON + $ algolia open --list --output json + + # Open the documentation home page $ algolia open docs - # The Algolia CLI documentation - $ algolia open cli-docs + # Open the dashboard for the current application + $ algolia open dashboard - # Algolia's status page - $ algolia open status + # Open billing / payment details for the current application + $ algolia open billing - # Algolia's supported languages page - $ algolia open languages + # Print a page link as JSON instead of opening it + $ algolia open billing --output json `), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.Shortcut = args[0] @@ -115,7 +177,8 @@ func NewOpenCmd(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().BoolP("list", "l", false, "List all support links") + cmd.Flags().BoolVarP(&opts.List, "list", "l", false, "List all shortcuts") + opts.PrintFlags.AddFlags(cmd) auth.DisableAuthCheck(cmd) @@ -123,43 +186,255 @@ func NewOpenCmd(f *cmdutil.Factory) *cobra.Command { } func runOpenCmd(opts *OpenOptions) error { - profile := opts.config.Profile() - applicationID, _ := profile.GetApplicationID() - nameURLMap := getNameURLMap(applicationID) - - if opts.List || opts.Shortcut == "" { - fmt.Println("open quickly opens Algolia pages. To use, run 'algolia open '.") - fmt.Println("open supports the following shortcuts:") - fmt.Println() - - shortcuts := openNames() - sort.Strings(shortcuts) - - table := printers.NewTablePrinter(opts.IO) - if table.IsTTY() { - table.AddField("SHORTCUT", nil, nil) - table.AddField("URL", nil, nil) - table.EndRow() + listing := opts.List || opts.Shortcut == "" + + // With an output format, emit page metadata instead of opening a browser. + if opts.structuredOutput() { + return printTargets(opts, listing) + } + + if listing { + return listTargets(opts) + } + + // Resource shortcuts open directly, without sign-in. App-scoped resources + // (such as status) use the dashboard URL only when a profile application is + // configured and belongs to the signed-in account. + if resource, ok := resourceURLs[opts.Shortcut]; ok { + url := resource.Default + if resource.AppPath != "" { + if _, err := opts.config.Profile().GetApplicationID(); err == nil { + client := opts.NewDashboardClient(auth.OAuthClientID()) + accessToken, err := opts.Authenticate(opts.IO, client) + if err != nil { + return err + } + appID, err := opts.resolveApplicationForAccount(client, accessToken) + if err != nil { + return err + } + if appID != "" { + url = fmt.Sprintf("%s/apps/%s/%s", client.DashboardURL, appID, resource.AppPath) + } + } + } + return opts.Browser(url) + } + + // Application pages require sign-in and an application scope. + if target, ok := dashboardTargets[opts.Shortcut]; ok { + return openDashboardTarget(opts, target) + } + + return unsupportedShortcutError(opts.Shortcut) +} + +// structuredOutput reports whether an output format was requested via --output. +func (opts *OpenOptions) structuredOutput() bool { + return opts.PrintFlags != nil && + opts.PrintFlags.OutputFlagSpecified != nil && + opts.PrintFlags.OutputFlagSpecified() +} + +// printTargets renders page metadata with the configured printer. When listing, +// every shortcut is printed; otherwise only the requested shortcut is printed. +func printTargets(opts *OpenOptions, listing bool) error { + printer, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + if listing { + return printer.Print(opts.IO, opts.allEntries()) + } + + baseURL, appID, displayAppID := opts.resolveScope() + entry, ok := entryFor(opts.Shortcut, baseURL, appID, displayAppID) + if !ok { + return unsupportedShortcutError(opts.Shortcut) + } + + return printer.Print(opts.IO, entry) +} + +// resolveScope returns the dashboard base URL and the application id used to +// build dashboard links. displayAppID falls back to a placeholder so links can +// be shown even when no application is configured. +func (opts *OpenOptions) resolveScope() (baseURL, appID, displayAppID string) { + appID, _ = opts.config.Profile().GetApplicationID() + displayAppID = appID + if displayAppID == "" { + displayAppID = "" + } + baseURL = opts.NewDashboardClient(auth.OAuthClientID()).DashboardURL + + return baseURL, appID, displayAppID +} + +// entryFor builds the page entry for a shortcut, or returns false if the +// shortcut is unknown. +func entryFor(name, baseURL, appID, displayAppID string) (pageEntry, bool) { + if resource, ok := resourceURLs[name]; ok { + url := resource.Default + if appID != "" && resource.AppPath != "" { + url = fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, resource.AppPath) } + return pageEntry{Shortcut: name, URL: url}, true + } + + if target, ok := dashboardTargets[name]; ok { + return pageEntry{ + Shortcut: name, + URL: dashboardURL(baseURL, displayAppID, target), + RequiresLogin: true, + }, true + } - for shortcutName, url := range nameURLMap { - table.AddField(shortcutName, nil, nil) - table.AddField(url, nil, nil) - table.EndRow() + return pageEntry{}, false +} + +// allEntries returns every shortcut, sorted by name. +func (opts *OpenOptions) allEntries() []pageEntry { + baseURL, appID, displayAppID := opts.resolveScope() + + entries := make([]pageEntry, 0, len(resourceURLs)+len(dashboardTargets)) + for _, name := range targetNames() { + if entry, ok := entryFor(name, baseURL, appID, displayAppID); ok { + entries = append(entries, entry) } + } + + return entries +} - return table.Render() +// openDashboardTarget signs the user in, resolves the current application +// (selecting one if needed), then opens the dashboard page. +func openDashboardTarget(opts *OpenOptions, target dashboardTarget) error { + client := opts.NewDashboardClient(auth.OAuthClientID()) + accessToken, err := opts.Authenticate(opts.IO, client) + if err != nil { + return err } - var err error - if url, ok := nameURLMap[opts.Shortcut]; ok { - err = open.Browser(url) + appID, err := opts.resolveApplicationForAccount(client, accessToken) + if err != nil { + return err + } + if appID == "" { + // No application is available to scope to; the selection flow has + // already explained the situation to the user. + return nil + } + + // The base URL is resolved from ALGOLIA_DASHBOARD_URL by the dashboard + // client (falling back to its compiled-in default). + url := dashboardURL(client.DashboardURL, appID, target) + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "Opening %s\n", cs.Bold(url)) + + return opts.Browser(url) +} + +func applicationInAccount(apps []dashboard.Application, appID string) bool { + for _, app := range apps { + if app.ID == appID { + return true + } + } + return false +} + +func (opts *OpenOptions) fetchAccountApplications( + client *dashboard.Client, + accessToken string, +) ([]dashboard.Application, error) { + listFn := opts.ListApplications + if listFn == nil { + listFn = func(c *dashboard.Client, token string) ([]dashboard.Application, error) { + return c.ListApplications(token) + } + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching applications") + apps, err := listFn(client, accessToken) + opts.IO.StopProgressIndicator() + if err != nil { + newToken, reAuthErr := auth.ReauthenticateIfExpired(opts.IO, client, err) + if reAuthErr != nil { + return nil, reAuthErr + } + opts.IO.StartProgressIndicatorWithLabel("Fetching applications") + apps, err = listFn(client, newToken) + opts.IO.StopProgressIndicator() if err != nil { - return err + return nil, err } - } else { - return fmt.Errorf("unsupported open command, given: %s", opts.Shortcut) } - return nil + return apps, nil +} + +// resolveApplicationForAccount returns an application ID that belongs to the +// signed-in account. The configured profile application is used only when it +// appears in the account's application list; otherwise the user is prompted to +// select one. +func (opts *OpenOptions) resolveApplicationForAccount( + client *dashboard.Client, + accessToken string, +) (string, error) { + apps, err := opts.fetchAccountApplications(client, accessToken) + if err != nil { + return "", err + } + + appID, err := opts.config.Profile().GetApplicationID() + if err == nil && applicationInAccount(apps, appID) { + return appID, nil + } + + app, selErr := opts.SelectApplication() + if selErr != nil { + return "", selErr + } + if app == nil { + return "", nil + } + + return app.ID, nil +} + +// dashboardURL builds the dashboard URL for an application page. Application +// pages are scoped via the /apps/{appID} path; account pages carry the +// application in an applicationId query parameter. +func dashboardURL(baseURL, appID string, target dashboardTarget) string { + if target.accountScoped { + return fmt.Sprintf("%s/%s?applicationId=%s", baseURL, target.path, appID) + } + + return fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, target.path) +} + +func listTargets(opts *OpenOptions) error { + fmt.Fprintln( + opts.IO.Out, + "open quickly opens Algolia pages. To use, run 'algolia open '.", + ) + fmt.Fprintln(opts.IO.Out, "open supports the following shortcuts:") + fmt.Fprintln(opts.IO.Out) + + table := printers.NewTablePrinter(opts.IO) + if table.IsTTY() { + table.AddField("SHORTCUT", nil, nil) + table.AddField("URL", nil, nil) + table.EndRow() + } + + for _, entry := range opts.allEntries() { + table.AddField(entry.Shortcut, nil, nil) + table.AddField(entry.URL, nil, nil) + table.EndRow() + } + + return table.Render() } diff --git a/pkg/cmd/open/open_test.go b/pkg/cmd/open/open_test.go new file mode 100644 index 00000000..dd281c72 --- /dev/null +++ b/pkg/cmd/open/open_test.go @@ -0,0 +1,384 @@ +package open + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/test" +) + +func TestDashboardURL(t *testing.T) { + base := "https://dashboard.algolia.com" + tests := []struct { + shortcut string + want string + }{ + {"dashboard", "https://dashboard.algolia.com/apps/APP123/dashboard"}, + {"indices", "https://dashboard.algolia.com/apps/APP123/explorer/browse"}, + {"crawler", "https://dashboard.algolia.com/apps/APP123/crawler"}, + {"connectors", "https://dashboard.algolia.com/apps/APP123/connectors"}, + {"api-keys", "https://dashboard.algolia.com/account/api-keys/all?applicationId=APP123"}, + {"usage", "https://dashboard.algolia.com/account/billing/usage?applicationId=APP123"}, + {"team", "https://dashboard.algolia.com/account/teams?applicationId=APP123"}, + {"billing", "https://dashboard.algolia.com/account/billing/details?applicationId=APP123"}, + } + + for _, tt := range tests { + t.Run(tt.shortcut, func(t *testing.T) { + assert.Equal(t, tt.want, dashboardURL(base, "APP123", dashboardTargets[tt.shortcut])) + }) + } +} + +// TestTargetNamesNoCollision guards against a shortcut existing in both maps, +// which would make its behavior ambiguous. +func TestTargetNamesNoCollision(t *testing.T) { + for name := range dashboardTargets { + _, dup := resourceURLs[name] + assert.Falsef(t, dup, "shortcut %q exists in both resourceURLs and dashboardTargets", name) + } +} + +func newTestOptions( + io *iostreams.IOStreams, + cfg config.IConfig, +) (*OpenOptions, *string, *bool) { + opened := new(string) + authed := new(bool) + + opts := &OpenOptions{ + IO: io, + config: cfg, + PrintFlags: cmdutil.NewPrintFlags(), + Authenticate: func(_ *iostreams.IOStreams, _ *dashboard.Client) (string, error) { + *authed = true + return "test-token", nil + }, + SelectApplication: func() (*dashboard.Application, error) { + return nil, errors.New("SelectApplication should not be called") + }, + NewDashboardClient: func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://dashboard.algolia.com"} + }, + ListApplications: func(_ *dashboard.Client, _ string) ([]dashboard.Application, error) { + return []dashboard.Application{{ID: "APP123", Name: "Test"}}, nil + }, + Browser: func(u string) error { + *opened = u + return nil + }, + } + + return opts, opened, authed +} + +// withOutputFormat configures opts to emit structured output, as the --output +// flag would when parsed by cobra. +func withOutputFormat(opts *OpenOptions, format string) { + *opts.PrintFlags.OutputFormat = format + opts.PrintFlags.OutputFlagSpecified = func() bool { return true } +} + +func TestRunOpenCmd_ResourceShortcutNoAuth(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, opened, authed := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Shortcut = "docs" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.False(t, *authed, "resource shortcuts must not require sign-in") + assert.Equal(t, "https://algolia.com/doc/", *opened) +} + +func TestRunOpenCmd_ResourceShortcutWithAppID(t *testing.T) { + io, _, _, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.Shortcut = "status" + opts.NewDashboardClient = func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://staging.algolia.test"} + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.True(t, *authed, "app-scoped status validates the profile application against the signed-in account") + assert.Equal(t, "https://staging.algolia.test/apps/APP123/monitoring/status", *opened) +} + +func TestRunOpenCmd_ResourceShortcutNoAppUsesDefault(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, _, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "status" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal(t, "https://status.algolia.com/", *opened) +} + +func TestRunOpenCmd_DashboardTargetConfiguredApp(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.Shortcut = "billing" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.True(t, *authed, "application pages require sign-in") + assert.Equal( + t, + "https://dashboard.algolia.com/account/billing/details?applicationId=APP123", + *opened, + ) +} + +func TestRunOpenCmd_DashboardTargetAppScoped(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "dashboard" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal(t, "https://dashboard.algolia.com/apps/APP123/dashboard", *opened) +} + +func TestRunOpenCmd_DashboardTargetUsesConfiguredDashboardURL(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "usage" + opts.NewDashboardClient = func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://staging.algolia.test"} + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal( + t, + "https://staging.algolia.test/account/billing/usage?applicationId=APP123", + *opened, + ) +} + +func TestRunOpenCmd_DashboardTargetIgnoresStaleProfileApp(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "USER_A_APP", APIKey: "key", Default: true}, + }) + + selectCalled := false + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "billing" + opts.ListApplications = func(_ *dashboard.Client, _ string) ([]dashboard.Application, error) { + return []dashboard.Application{{ID: "USER_B_APP", Name: "User B App"}}, nil + } + opts.SelectApplication = func() (*dashboard.Application, error) { + selectCalled = true + return &dashboard.Application{ID: "USER_B_APP", Name: "User B App"}, nil + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.True(t, selectCalled, "stale profile application must trigger selection") + assert.Equal( + t, + "https://dashboard.algolia.com/account/billing/details?applicationId=USER_B_APP", + *opened, + ) +} + +func TestRunOpenCmd_DashboardTargetSelectsAppWhenNoneConfigured(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "dashboard" + opts.SelectApplication = func() (*dashboard.Application, error) { + return &dashboard.Application{ID: "SELECTED", Name: "Picked"}, nil + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal(t, "https://dashboard.algolia.com/apps/SELECTED/dashboard", *opened) +} + +func TestRunOpenCmd_Unsupported(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, opened, authed := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Shortcut = "bogus" + + err := runOpenCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported open command, given: bogus") + assert.Contains(t, err.Error(), "Available shortcuts:") + assert.Contains(t, err.Error(), "billing") + assert.Contains(t, err.Error(), "docs") + assert.False(t, *authed) + assert.Empty(t, *opened) +} + +func TestRunOpenCmd_ListIncludesBothKinds(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + + opts, _, _ := newTestOptions(io, test.NewDefaultConfigStub()) + opts.List = true + + err := runOpenCmd(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "docs") + assert.Contains(t, out, "billing") +} + +func TestRunOpenCmd_ListJSONOutput(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, stdout, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.List = true + withOutputFormat(opts, "json") + + err := runOpenCmd(opts) + require.NoError(t, err) + + var entries []pageEntry + require.NoError(t, json.Unmarshal(stdout.Bytes(), &entries)) + assert.Len(t, entries, len(resourceURLs)+len(dashboardTargets)) + + byName := make(map[string]pageEntry, len(entries)) + for _, e := range entries { + byName[e.Shortcut] = e + } + + assert.Equal( + t, + pageEntry{ + Shortcut: "billing", + URL: "https://dashboard.algolia.com/account/billing/details?applicationId=APP123", + RequiresLogin: true, + }, + byName["billing"], + ) + assert.Equal( + t, + pageEntry{Shortcut: "docs", URL: "https://algolia.com/doc/"}, + byName["docs"], + ) + + // Structured output never opens a browser or signs in. + assert.False(t, *authed) + assert.Empty(t, *opened) +} + +func TestRunOpenCmd_SingleShortcutJSONOutput(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.Shortcut = "billing" + withOutputFormat(opts, "json") + + err := runOpenCmd(opts) + require.NoError(t, err) + + var entry pageEntry + require.NoError(t, json.Unmarshal(stdout.Bytes(), &entry)) + assert.Equal( + t, + pageEntry{ + Shortcut: "billing", + URL: "https://dashboard.algolia.com/account/billing/details?applicationId=APP123", + RequiresLogin: true, + }, + entry, + ) + + // A dashboard target with --output does not sign in or open a browser. + assert.False(t, *authed) + assert.Empty(t, *opened) +} + +func TestRunOpenCmd_UnsupportedWithJSONOutput(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, _, _ := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Shortcut = "bogus" + withOutputFormat(opts, "json") + + err := runOpenCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported open command, given: bogus") + assert.Contains(t, err.Error(), "Available shortcuts:") +} + +func TestRunOpenCmd_InvalidOutputFormat(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, _, _ := newTestOptions(io, test.NewDefaultConfigStub()) + opts.List = true + withOutputFormat(opts, "yaml") + + err := runOpenCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") +}