diff --git a/internal/cli/auth_env.go b/internal/cli/auth_env.go index 1f8e1274e..9068173f5 100644 --- a/internal/cli/auth_env.go +++ b/internal/cli/auth_env.go @@ -106,6 +106,19 @@ func validateAuthMode(getenv func(string) string) error { } } +// envTenantConflictError is the error for a --tenant that names another tenant +// than the AUTH0_DOMAIN that fixes it in env auth mode. +func envTenantConflictError(tenant, domain string) error { + return authError{ + err: fmt.Errorf( + "--tenant %q conflicts with %s=%q; in %s=%s mode the tenant is fixed by %s. "+ + "Drop --tenant, or set %s to the tenant you want to target", + tenant, envDomain, domain, authModeEnvVar, authModeEnv, envDomain, envDomain, + ), + reason: "env_auth_tenant_conflict", + } +} + // setupWithEnvAuthentication authenticates purely from environment variables, // touching neither the on-disk config nor the keychain. It accepts a pre-minted // Management API token via AUTH0_API_TOKEN, or client credentials via @@ -136,14 +149,7 @@ func (c *cli) setupWithEnvAuthentication(ctx context.Context) error { // tenant. if c.tenantExplicit { if requested := strings.TrimSpace(c.tenant); !strings.EqualFold(requested, domain) { - return authError{ - err: fmt.Errorf( - "--tenant %q conflicts with %s=%q; in %s=%s mode the tenant is fixed by %s. "+ - "Drop --tenant, or set %s to the tenant you want to target", - c.tenant, envDomain, domain, authModeEnvVar, authModeEnv, envDomain, envDomain, - ), - reason: "env_auth_tenant_conflict", - } + return envTenantConflictError(c.tenant, domain) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index df4f43aa3..82cfe2e7e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -44,6 +44,10 @@ type cli struct { renderer *display.Renderer tracker *analytics.Tracker + // Builds the plugin service when a plugin command first needs it. When it + // is nil the real one is built. Tests set it to inject a fake. + plugins func() (*pluginService, error) + // Set of flags which are user specified. debug bool tenant string diff --git a/internal/cli/doc-gen.go b/internal/cli/doc-gen.go index e39169b07..2bac2a788 100644 --- a/internal/cli/doc-gen.go +++ b/internal/cli/doc-gen.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "text/template" @@ -15,6 +16,11 @@ import ( const ( docsPath = "./docs/" + // The annotation with this key, set to "true" on a command, keeps the command + // and everything below it out of the generated docs and out of the links to + // them. + skipDocsAnnotation = "skipDocs" + associatedCommandsFragment = `{{range .AssociatedCommands}}- [{{.CommandPath}}]({{ link .CommandPath }}) - {{.Short}} {{end}}` @@ -169,6 +175,11 @@ type page struct { // GenerateDocs will generate the documentation // for all the commands under the ./docs folder. func GenerateDocs() error { + return GenMarkdownTree(newDocsRoot(), docsPath) +} + +// newDocsRoot returns the command tree that the docs are generated from. +func newDocsRoot() *cobra.Command { baseCmd := &cobra.Command{ Use: "auth0", Short: rootShort, @@ -179,12 +190,20 @@ func GenerateDocs() error { addPersistentFlags(baseCmd, cli) addSubCommands(baseCmd, cli) - return GenMarkdownTree(baseCmd, docsPath) + return baseCmd +} + +// documentedCommands returns the commands that have a page in the docs, which is +// all of them except those that are marked with skipDocsAnnotation. +func documentedCommands(commands []*cobra.Command) []*cobra.Command { + return slices.DeleteFunc(slices.Clone(commands), func(c *cobra.Command) bool { + return c.Annotations[skipDocsAnnotation] == "true" + }) } // GenMarkdownTree is the same as GenMarkdownTree, but with linkHandler. func GenMarkdownTree(cmd *cobra.Command, dir string) error { - for _, c := range cmd.Commands() { + for _, c := range documentedCommands(cmd.Commands()) { if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { continue } @@ -222,7 +241,7 @@ func GenerateHomepage(cmd *cobra.Command, w io.Writer) error { pageData := page{ CommandPath: cmd.CommandPath(), - AssociatedCommands: cmd.Commands(), + AssociatedCommands: documentedCommands(cmd.Commands()), } return GeneratePage(w, "homepageTemplate", templateBody, pageData) @@ -233,7 +252,7 @@ func GenerateParentPage(cmd *cobra.Command, w io.Writer) error { templateBody := fmt.Sprintf(parentPageTemplate, associatedCommandsFragment) hasRunnableChildren := false - for _, c := range cmd.Commands() { + for _, c := range documentedCommands(cmd.Commands()) { if c.Runnable() { hasRunnableChildren = true } @@ -248,7 +267,7 @@ func GenerateParentPage(cmd *cobra.Command, w io.Writer) error { Name: cmd.CommandPath(), Description: cmd.Long, CommandPath: cmd.CommandPath(), - AssociatedCommands: cmd.Commands(), + AssociatedCommands: documentedCommands(cmd.Commands()), ParentCommandPath: parentCommand, HasRunnableChildren: hasRunnableChildren, } @@ -262,7 +281,7 @@ func GenerateCommandPage(cmd *cobra.Command, w io.Writer) error { parentCommand := cmd.Parent() - associatedCommands := parentCommand.Commands() + associatedCommands := documentedCommands(parentCommand.Commands()) if parentCommand.Name() == "auth0" { associatedCommands = nil } diff --git a/internal/cli/doc-gen_test.go b/internal/cli/doc-gen_test.go new file mode 100644 index 000000000..962b9aa74 --- /dev/null +++ b/internal/cli/doc-gen_test.go @@ -0,0 +1,97 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readDocs generates the docs of the tree into a new directory and returns the +// content of every page by file name. +func readDocs(t *testing.T, root *cobra.Command) map[string]string { + t.Helper() + + dir := t.TempDir() + require.NoError(t, GenMarkdownTree(root, dir)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + pages := make(map[string]string, len(entries)) + + for _, entry := range entries { + content, err := os.ReadFile(filepath.Join(dir, entry.Name())) + require.NoError(t, err) + + pages[entry.Name()] = string(content) + } + + return pages +} + +func TestGenMarkdownTree_LeavesOutThePluginCommands(t *testing.T) { + root := newDocsRoot() + + group, _, err := root.Find([]string{"plugin"}) + require.NoError(t, err) + require.NotSame(t, root, group, "the plugin group must be in the tree, or this test proves nothing") + + pages := readDocs(t, root) + + assert.Contains(t, pages, "auth0.md") + assert.Contains(t, pages, "auth0_apps.md") + + for name, content := range pages { + assert.False(t, strings.HasPrefix(name, "auth0_plugin"), "page %s", name) + assert.NotContains(t, content, "auth0_plugin", "link in %s", name) + assert.NotContains(t, content, "auth0 plugin", "mention in %s", name) + } +} + +func TestGenMarkdownTree_SkipsAnnotatedCommands(t *testing.T) { + newTree := func() *cobra.Command { + run := func(*cobra.Command, []string) {} + skip := map[string]string{skipDocsAnnotation: "true"} + + root := &cobra.Command{Use: "auth0"} + + things := &cobra.Command{Use: "things", Long: "Things."} + things.AddCommand( + &cobra.Command{Use: "keep", Short: "Keep it", Run: run}, + &cobra.Command{Use: "hide", Short: "Hide it", Run: run, Annotations: skip}, + ) + + onlyHidden := &cobra.Command{Use: "only-hidden", Long: "Only hidden."} + onlyHidden.AddCommand(&cobra.Command{Use: "hide", Short: "Hide it", Run: run, Annotations: skip}) + + hidden := &cobra.Command{Use: "secret", Short: "Secret group", Annotations: skip} + hidden.AddCommand(&cobra.Command{Use: "child", Short: "Child", Run: run}) + + root.AddCommand(things, onlyHidden, hidden) + + return root + } + + pages := readDocs(t, newTree()) + + assert.Contains(t, pages, "auth0_things_keep.md") + assert.NotContains(t, pages, "auth0_things_hide.md") + assert.NotContains(t, pages, "auth0_only-hidden_hide.md") + assert.NotContains(t, pages, "auth0_secret.md") + assert.NotContains(t, pages, "auth0_secret_child.md") + + assert.NotContains(t, pages["auth0.md"], "auth0_secret") + assert.Contains(t, pages["auth0.md"], "auth0_things.md") + + assert.Contains(t, pages["auth0_things.md"], "auth0_things_keep.md") + assert.NotContains(t, pages["auth0_things.md"], "auth0_things_hide") + assert.NotContains(t, pages["auth0_things_keep.md"], "auth0_things_hide") + + assert.NotContains(t, pages["auth0_only-hidden.md"], "has_children") + assert.NotContains(t, pages["auth0_only-hidden.md"], "auth0_only-hidden_hide") +} diff --git a/internal/cli/plugin.go b/internal/cli/plugin.go new file mode 100644 index 000000000..ba0663694 --- /dev/null +++ b/internal/cli/plugin.go @@ -0,0 +1,1474 @@ +package cli + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/buildinfo" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/iostream" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/store" + "github.com/auth0/auth0-cli/internal/prompt" +) + +// pluginCommandPath is the command path of the plugin group, which every plugin +// command starts with. It is built from the name of the group, never from an +// alias. +const pluginCommandPath = "auth0 plugin" + +var pluginUpdateAll = Flag{ + Name: "All", + LongForm: "all", + Help: "Update every installed plugin that is not pinned.", +} + +// registerPluginCmd adds the plugin command to the root when a CLI build with +// the given version has the plugin feature. A stable release carries the code +// but not the feature. Graduating the feature means removing this check. +func registerPluginCmd(rootCmd *cobra.Command, cli *cli, version string) { + if plugins.FeatureEnabled(version) { + rootCmd.AddCommand(pluginCmd(cli)) + } +} + +func pluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin", + Aliases: []string{"plugins"}, + Short: "(Experimental) Search, inspect and manage Auth0 CLI plugins", + Long: "(Experimental) Search the signed Auth0 plugin registry, install, update and remove plugins, and approve what a plugin may do on a tenant." + + "\n\nPlugin commands do not need you to be logged in." + + "\n\n**Warning:** This command is experimental and is subject to change in future versions.", + // The plugin commands stay out of the generated docs until the feature + // graduates. + Annotations: map[string]string{skipDocsAnnotation: "true"}, + } + + cmd.SetUsageTemplate(resourceUsageTemplate()) + cmd.AddCommand(searchPluginCmd(cli)) + cmd.AddCommand(listPluginCmd(cli)) + cmd.AddCommand(infoPluginCmd(cli)) + cmd.AddCommand(installPluginCmd(cli)) + cmd.AddCommand(updatePluginCmd(cli)) + cmd.AddCommand(uninstallPluginCmd(cli)) + cmd.AddCommand(approvePluginCmd(cli)) + + return cmd +} + +func searchPluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "search [query]", + Args: cobra.MaximumNArgs(1), + Short: "Search the plugin registry", + Long: "Search the signed plugin registry. The query is matched against the plugin ID and description, ignoring case. " + + "Without a query, every plugin is listed.\n\n" + + "The registry index is checked for a newer copy when it is due. When the registry cannot be reached, the last verified copy is used.", + Example: ` auth0 plugin search + auth0 plugin search security + auth0 plugin search security --json + auth0 plugin search --csv`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := cli.getPlugins() + if err != nil { + return err + } + + index, err := loadPluginIndex(cmd.Context(), cli, svc) + if err != nil { + return err + } + + installed, err := svc.store.List() + if err != nil { + return err + } + + query := "" + if len(args) == 1 { + query = strings.ToLower(args[0]) + } + + var results []display.PluginSearchResult + + for _, plugin := range sortedPlugins(index.Plugins) { + if !strings.Contains(strings.ToLower(plugin.ID), query) && !strings.Contains(strings.ToLower(plugin.Description), query) { + continue + } + + view := display.PluginSearchResult{ + ID: plugin.ID, + Name: plugins.NameOf(plugin.ID), + Version: plugin.Latest, + Installed: isInstalled(installed, plugin.ID), + Description: plugin.Description, + Deprecated: pluginDeprecation(index, plugin.ID, plugin.Latest), + } + + results = append(results, view) + } + + cli.renderer.PluginSearchList(results) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +// pluginDeprecation returns the view of the deprecation of a version of a plugin, +// or nil when the version is not deprecated. +func pluginDeprecation(index *registry.Index, id, version string) *display.PluginDeprecation { + deprecation, ok := index.Deprecation(id, version) + if !ok { + return nil + } + + return &display.PluginDeprecation{Message: deprecation.Message, Replacement: deprecation.Replacement} +} + +// sortedPlugins returns the plugins ordered by ID, without changing the index. +func sortedPlugins(list []registry.Plugin) []registry.Plugin { + sorted := slices.Clone(list) + slices.SortFunc(sorted, func(a, b registry.Plugin) int { return strings.Compare(a.ID, b.ID) }) + + return sorted +} + +func listPluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Args: cobra.NoArgs, + Short: "List installed plugins", + Long: "List the installed plugins with their active version, whether the version is pinned, the version kept for going back, and the space they use on disk.", + Example: ` auth0 plugin list + auth0 plugin list --json + auth0 plugin list --csv`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := cli.getPlugins() + if err != nil { + return err + } + + installed, err := svc.store.List() + if err != nil { + return err + } + + sizes, err := svc.store.Sizes() + if err != nil { + return err + } + + results := make([]display.PluginListEntry, 0, len(installed)) + for _, plugin := range installed { + results = append(results, display.PluginListEntry{ + ID: plugin.ID, + Name: plugins.NameOf(plugin.ID), + Version: plugin.Active, + Pinned: plugin.Pinned, + Previous: plugin.Previous, + SizeBytes: sizes[plugin.ID], + }) + } + + cli.renderer.PluginList(results) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + return cmd +} + +func infoPluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "info ", + Args: cobra.ExactArgs(1), + Short: "Show what a plugin is and what it asks to do", + Long: "Show the owner, requirements, platforms, permissions and versions of a plugin, whether it is installed, and the tenants where it is approved.\n\n" + + "The plugin can be given as `name`, `auth0/name` or `name@version`. Without a version, the latest one is shown.", + Example: ` auth0 plugin info checkmate + auth0 plugin info auth0/checkmate + auth0 plugin info checkmate@1.0.0 + auth0 plugin info checkmate --json`, + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := parsePluginRef(args[0]) + if err != nil { + return validationError{err: err, reason: "plugin_reference"} + } + + svc, err := cli.getPlugins() + if err != nil { + return err + } + + index, err := loadPluginIndex(cmd.Context(), cli, svc) + if err != nil { + return err + } + + view, err := buildPluginInfo(cmd, cli, svc, index, ref) + if err != nil { + return err + } + + cli.renderer.PluginInfo(*view) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + return cmd +} + +// buildPluginInfo gathers everything info shows about the plugin of the +// reference: the index, the installed copy and the approvals. +func buildPluginInfo(cmd *cobra.Command, cli *cli, svc *pluginService, index *registry.Index, ref pluginRef) (*display.PluginDetails, error) { + plugin, ok := index.FindPlugin(ref.ID) + if !ok { + return nil, &store.NotAvailableError{ID: ref.ID} + } + + // A plugin whose versions are in a separate file needs the registry, even when + // the index came from the cache. There is nothing safe to show without them, + // so the error says what to run once the registry can be reached. + records, err := svc.client.Versions(cmd.Context(), plugin) + if err != nil { + return nil, withRegistryNextAction(err, "auth0 plugin info "+ref.String()) + } + + sortVersionRecords(records) + + shown := ref.Version + if shown == "" { + shown = plugin.Latest + } + + n := slices.IndexFunc(records, func(r registry.VersionRecord) bool { return r.Version == shown }) + if n < 0 { + available := make([]string, 0, len(records)) + for _, record := range records { + available = append(available, record.Version) + } + + return nil, &store.NotAvailableError{ID: ref.ID, Version: ref.Version, Available: available} + } + + record := records[n] + + view := &display.PluginDetails{ + ID: plugin.ID, + Version: record.Version, + Description: plugin.Description, + Owner: plugin.Owner, + Homepage: plugin.Homepage, + Platforms: record.Platforms, + Permissions: display.PluginPermissions{ + Scopes: len(record.Permissions.Scopes), + Hosts: len(record.Permissions.Hosts), + Files: len(record.Permissions.Files), + Env: len(record.Permissions.Env), + }, + PermissionsHash: record.PermissionsHash, + Versions: make([]display.PluginVersion, 0, len(records)), + ApprovedTenants: []display.PluginApproval{}, + } + + if record.Engines != nil { + view.Requires = record.Engines.Auth0CLI + } + + for _, other := range records { + _, revoked := index.Revoked(plugin.ID, other.Version) + view.Versions = append(view.Versions, display.PluginVersion{Version: other.Version, Latest: other.Version == plugin.Latest, Revoked: revoked}) + } + + view.Deprecated = pluginDeprecation(index, plugin.ID, record.Version) + + if view.Installed, err = installedPluginView(cli, svc, plugin.ID); err != nil { + return nil, err + } + + approved, err := svc.approvals.List(plugin.ID) + if err != nil { + return nil, err + } + + for _, approval := range approved { + view.ApprovedTenants = append(view.ApprovedTenants, display.PluginApproval{ + Tenant: approval.Tenant, + PermissionsHash: approval.PermissionsHash, + ApprovedAt: approval.ApprovedAt, + }) + } + + return view, nil +} + +// installedPluginView describes the installed copy of a plugin, or returns nil +// when it is not installed. A copy that fails verification is reported with the +// reason and a warning, never hidden. +func installedPluginView(cli *cli, svc *pluginService, id string) (*display.PluginInstalled, error) { + installed, err := svc.store.List() + if err != nil { + return nil, err + } + + entry, ok := findInstalled(installed, id) + if !ok { + return nil, nil //nolint:nilnil // A plugin that is not installed has no installed copy to describe. + } + + verified, err := svc.store.Verify(id) + if err != nil { + if !cli.wantsJSONError() { + cli.renderer.Warnf("The installed copy of %s failed verification: %v", id, err) + cli.renderer.Warnf("Reinstall it with: auth0 plugin install %s", plugins.NameOf(id)) + } + + return &display.PluginInstalled{Version: entry.Active, Error: err.Error()}, nil + } + + return &display.PluginInstalled{Version: verified.Version, Verified: true, PermissionsHash: verified.PermissionsHash}, nil +} + +// sortVersionRecords orders versions from the highest to the lowest. +func sortVersionRecords(records []registry.VersionRecord) { + slices.SortStableFunc(records, func(a, b registry.VersionRecord) int { + left, leftOK := plugins.ParseVersion(a.Version) + right, rightOK := plugins.ParseVersion(b.Version) + + if leftOK && rightOK { + return right.Compare(left) + } + + return strings.Compare(b.Version, a.Version) + }) +} + +func installPluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "install ", + Args: cobra.ExactArgs(1), + Short: "Install a plugin", + Long: "Install a plugin from the signed registry. The plugin can be given as `name`, `auth0/name` or `name@version`. " + + "Installing an exact version pins the plugin, so that `auth0 plugin update --all` leaves it alone.\n\n" + + "In a terminal, the permissions that the plugin asks for are shown first, and the plugin is approved for the tenant when you agree. " + + "Without a terminal, with `--no-input` and in agent mode, the plugin is installed without an approval, and the command to approve it in a terminal is printed. " + + "Only a person approves a plugin.\n\n" + + "Installing needs a current copy of the registry index, so it fails when the registry cannot be reached and the saved copy is out of date.", + Example: ` auth0 plugin install checkmate + auth0 plugin install auth0/checkmate + auth0 plugin install checkmate@1.0.0 --tenant acme.us.auth0.com + auth0 plugin install checkmate --json`, + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := parsePluginRef(args[0]) + if err != nil { + return validationError{err: err, reason: "plugin_reference"} + } + + if err := checkPluginNameAvailable(cmd.Root(), ref.Name); err != nil { + return err + } + + svc, err := changePluginService(cmd, cli) + if err != nil { + return err + } + + index, err := freshPluginIndex(cmd.Context(), svc, "installing a plugin", "auth0 plugin install "+args[0]) + if err != nil { + return err + } + + op := newPluginOperation(cmd, cli, svc, index) + + result, err := svc.store.Install(cmd.Context(), index, ref.ID, ref.Version, store.Options{Consent: op.consent(false)}) + if declinedByPerson(err) { + cli.renderer.Infof("%s was not approved, so nothing was installed. To review the permissions again, run: auth0 plugin install %s", ref.ID, ref.Name) + return op.declined(ref.ID) + } + + if err != nil { + return err + } + + view, err := op.finish(cmd.Context(), result) + if err != nil { + return err + } + + op.say(result, view, false) + cli.renderer.PluginChange(*view) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + return cmd +} + +func updatePluginCmd(cli *cli) *cobra.Command { + var all bool + + cmd := &cobra.Command{ + Use: "update [plugin]", + Args: cobra.MaximumNArgs(1), + Short: "Update a plugin, or every plugin", + Long: "Update an installed plugin to the latest version of the registry, and clear its pin. " + + "With `--all`, every installed plugin is updated, except the ones that are pinned to a version. " + + "The pinned ones are listed with the newer version that is available.\n\n" + + "When the new version asks for other permissions than the installed one, you are asked to approve what changed. " + + "Without a terminal, with `--no-input` and in agent mode, the installed version stays active, and the command says why. " + + "The update of a named plugin fails then, and `--all` fails when some plugin could not be updated.\n\n" + + "Updating needs a current copy of the registry index, so it fails when the registry cannot be reached and the saved copy is out of date.", + Example: ` auth0 plugin update checkmate + auth0 plugin update --all + auth0 plugin update --all --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if all == (len(args) == 1) { + return validationError{err: errors.New("give the name of one plugin, or use --all to update every plugin"), reason: "plugin_update_target"} + } + + var ref pluginRef + + if len(args) == 1 { + var err error + if ref, err = parsePluginName(args[0], "update"); err != nil { + return err + } + } + + svc, err := changePluginService(cmd, cli) + if err != nil { + return err + } + + retry := "auth0 plugin update --all" + if !all { + retry = "auth0 plugin update " + ref.Name + } + + index, err := freshPluginIndex(cmd.Context(), svc, "updating plugins", retry) + if err != nil { + return err + } + + op := newPluginOperation(cmd, cli, svc, index) + opts := store.Options{Consent: op.consent(true)} + + if all { + return op.updateAll(cmd.Context(), opts) + } + + return op.updateOne(cmd.Context(), ref, opts) + }, + } + + pluginUpdateAll.RegisterBool(cmd, &all, false) + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact") + + return cmd +} + +func uninstallPluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "uninstall ", + Aliases: []string{"remove"}, + Args: cobra.ExactArgs(1), + Short: "Uninstall a plugin", + Long: "Uninstall a plugin: every version of it, its entry in the lockfile and the approvals on every tenant are removed, and the space that was freed is printed.\n\n" + + "This works without the registry. To uninstall without being asked to confirm, use the `--force` flag.", + Example: ` auth0 plugin uninstall checkmate + auth0 plugin remove checkmate + auth0 plugin uninstall checkmate --force`, + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := parsePluginName(args[0], "uninstall") + if err != nil { + return err + } + + // Uninstalling must work on a machine where plugins are turned off, where + // the registry cannot be reached and where the approval pins are wrong. + svc, err := cli.getPlugins() + if err != nil { + return err + } + + if _, err := requireInstalled(svc.store, ref); err != nil { + return err + } + + if !cli.force && cli.agentMode { + return errDestructiveNoConfirm + } + + if !cli.force && svc.canPrompt(cmd) { + if !svc.confirm(fmt.Sprintf("Are you sure you want to uninstall %s? This removes every version and its approvals.", ref.ID)) { + cli.renderer.Infof("%s was not uninstalled.", ref.ID) + return nil + } + } + + freed, err := svc.store.Remove(cmd.Context(), ref.ID) + if err != nil { + return err + } + + cli.renderer.PluginUninstalled(ref.ID, freed) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.force, "force", false, "Skip confirmation.") + + return cmd +} + +func approvePluginCmd(cli *cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "approve ", + Args: cobra.ExactArgs(1), + Short: "Approve what an installed plugin may do on a tenant", + Long: "Show the permissions of an installed plugin and, when you agree, approve them for a tenant. " + + "A plugin runs on a tenant only after a person approved its permissions there. " + + "The approval holds for later versions that ask for the same permissions.\n\n" + + "This needs a terminal. It cannot run with `--no-input`, in agent mode or in a script, because only a person approves a plugin. " + + "To approve a plugin in CI, pin its permissions with the AUTH0_CLI_PLUGIN_APPROVALS environment variable instead.", + Example: ` auth0 plugin approve checkmate --tenant acme.us.auth0.com + auth0 plugin approve auth0/checkmate`, + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := parsePluginName(args[0], "approve") + if err != nil { + return err + } + + svc, err := changePluginService(cmd, cli) + if err != nil { + return err + } + + tenant := pluginTenant(cli) + if tenant == "" { + return validationError{err: errPluginNoTenant, reason: "plugin_no_tenant"} + } + + if _, err := requireInstalled(svc.store, ref); err != nil { + return err + } + + verified, err := svc.store.Verify(ref.ID) + + if _, failed := errors.AsType[*store.VerificationError](err); failed { + return fmt.Errorf("the installed copy of %s failed verification, so it cannot be approved: %w. Reinstall it with: auth0 plugin install %s", ref.ID, err, ref.Name) + } + + if err != nil { + return err + } + + cli.renderer.PluginSummary(verified.ID, verified.Version, verified.Manifest.Description) + + op := newPluginOperation(cmd, cli, svc, nil) + request := approvals.Request{ + ID: verified.ID, + Version: verified.Version, + New: verified.Manifest.Permissions(), + NewCommands: verified.Manifest.ConsentCommands(), + } + + err = op.consenter.Ask(cmd.Context(), request) + + switch { + case errors.Is(err, approvals.ErrAgentMode), errors.Is(err, approvals.ErrNotInteractive): + return fmt.Errorf("only a person can approve a plugin. Run this in a terminal: %s", approveCommand(ref.ID, tenant)) + case errors.Is(err, approvals.ErrDeclined): + cli.renderer.Infof("%s was not approved for %s.", ref.ID, tenant) + return nil + case err != nil: + return err + } + + cli.renderer.Infof("Approved %s %s for %s.", verified.ID, verified.Version, tenant) + + return nil + }, + } + + return cmd +} + +// pluginsDisabledEnv turns off the plugin commands that bring plugins onto a +// machine or approve them, for managed machines where plugins are not wanted. +const pluginsDisabledEnv = "AUTH0_CLI_PLUGINS_DISABLED" + +// errPluginNoTenant is what a command says when it has no tenant to approve a +// plugin for. +var errPluginNoTenant = errors.New("there is no tenant to approve the plugin for. Pass --tenant , or log in with: auth0 login") + +// changePluginService returns the plugin service for a command that installs, +// updates or approves a plugin. Those commands fail when plugins are turned off, +// when --tenant contradicts the tenant that env auth mode fixes, when the +// approval pins are not valid, and when the tenant or the approvals file cannot +// be used. All of that is checked before the registry is asked for anything, so +// a command that fails leaves nothing installed. The commands that only read or +// only remove need none of it. +func changePluginService(cmd *cobra.Command, cli *cli) (*pluginService, error) { + if err := checkPluginsEnabled(cmd); err != nil { + return nil, err + } + + if err := checkPluginTenantFlag(cmd, cli); err != nil { + return nil, err + } + + svc, err := cli.getPlugins() + if err != nil { + return nil, err + } + + if err := svc.usePins(); err != nil { + return nil, err + } + + if err := svc.checkApprovals(pluginTenant(cli)); err != nil { + return nil, err + } + + return svc, nil +} + +// checkPluginsEnabled returns an error when plugins are turned off by +// AUTH0_CLI_PLUGINS_DISABLED. The variable is read as a boolean. A value that is +// not one turns plugins off as well, because a managed machine that mistyped the +// switch must not end up with plugins on. +func checkPluginsEnabled(cmd *cobra.Command) error { + value := strings.TrimSpace(os.Getenv(pluginsDisabledEnv)) + if value == "" { + return nil + } + + disabled, err := strconv.ParseBool(value) + + switch { + case err != nil: + return fmt.Errorf("%s is set to %q, which is not true or false, so plugins are turned off on this machine and %s is not available. Set it to true to keep plugins off, or unset it to allow them. Searching, listing and uninstalling still work", + pluginsDisabledEnv, value, cmd.CommandPath()) + case disabled: + return fmt.Errorf("plugins are turned off on this machine by %s=true, so %s is not available. Searching, listing and uninstalling still work", pluginsDisabledEnv, cmd.CommandPath()) + } + + return nil +} + +// checkPluginTenantFlag fails when env auth mode fixes the tenant with +// AUTH0_DOMAIN and --tenant names another one, as the commands that call the API +// do, rather than approving a plugin for a tenant that was not meant. +func checkPluginTenantFlag(cmd *cobra.Command, cli *cli) error { + if !envAuthEnabled(os.Getenv) || !cmd.Flags().Changed("tenant") { + return nil + } + + domain := strings.TrimSpace(os.Getenv(envDomain)) + if domain == "" || strings.EqualFold(strings.TrimSpace(cli.tenant), domain) { + return nil + } + + return envTenantConflictError(cli.tenant, domain) +} + +// parsePluginName reads the name of an installed plugin, which has no version. +func parsePluginName(value, command string) (pluginRef, error) { + ref, err := parsePluginRef(value) + if err != nil { + return pluginRef{}, validationError{err: err, reason: "plugin_reference"} + } + + if ref.Version != "" { + return pluginRef{}, validationError{ + err: fmt.Errorf("%s takes the name of a plugin, without a version. To install an exact version, run: auth0 plugin install %s@%s", command, ref.Name, ref.Version), + reason: "plugin_reference", + } + } + + return ref, nil +} + +// freshPluginIndex returns the verified registry index for a command that +// changes what is installed. Such a command needs an index that is current, so +// a saved copy that could not be refreshed is not good enough. The retry +// command is what to run once the registry can be reached. +func freshPluginIndex(ctx context.Context, svc *pluginService, what, retry string) (*registry.Index, error) { + index, status, err := svc.client.EnsureFresh(ctx) + if err != nil { + return nil, withRegistryNextAction(err, retry) + } + + if status.Stale { + return nil, fmt.Errorf("%s needs a current registry index, but the registry at %s could not be refreshed: %s. Reconnect and run: %s", + what, svc.client.Registry.URL, status.Warning, retry) + } + + return index, nil +} + +// pluginTenant returns the tenant a plugin is approved for: the one that was +// asked for with --tenant, else AUTH0_DOMAIN in env auth mode, else the default +// tenant. It is trimmed and in lower case, the form that the config, the +// approvals and the printed commands use. It needs no session, and it is empty +// when there is none of them. +func pluginTenant(cli *cli) string { + tenant := strings.TrimSpace(cli.tenant) + + switch { + case tenant != "": + case envAuthEnabled(os.Getenv): + tenant = os.Getenv(envDomain) + default: + tenant = cli.Config.DefaultTenant + } + + return strings.ToLower(strings.TrimSpace(tenant)) +} + +// declinedByPerson reports whether an install or update stopped because the +// person answered no at the consent screen, which is not a failure. +func declinedByPerson(err error) bool { + declined, ok := errors.AsType[*store.DeclinedError](err) + + return ok && errors.Is(declined.Err, approvals.ErrDeclined) +} + +// approveCommand returns the command that approves a plugin on a tenant. +func approveCommand(id, tenant string) string { + return fmt.Sprintf("auth0 plugin approve %s --tenant %s", id, tenant) +} + +// findInstalled returns the installed plugin with the ID. +func findInstalled(installed []store.Installed, id string) (store.Installed, bool) { + n := slices.IndexFunc(installed, func(i store.Installed) bool { return i.ID == id }) + if n < 0 { + return store.Installed{}, false + } + + return installed[n], true +} + +// isInstalled reports whether the plugin with the ID is installed. +func isInstalled(installed []store.Installed, id string) bool { + _, ok := findInstalled(installed, id) + + return ok +} + +// requireInstalled returns the installed plugin of the reference, or the error +// that says it is not installed and which plugins are. +func requireInstalled(st *store.Store, ref pluginRef) (store.Installed, error) { + installed, err := st.List() + if err != nil { + return store.Installed{}, err + } + + entry, ok := findInstalled(installed, ref.ID) + if !ok { + return store.Installed{}, notInstalledError(ref, installed) + } + + return entry, nil +} + +// notInstalledError says that a plugin is not installed, and which plugins are. +func notInstalledError(ref pluginRef, installed []store.Installed) error { + if len(installed) == 0 { + return validationError{ + err: fmt.Errorf("the plugin %s is not installed, and no plugin is. Find one with: auth0 plugin search", ref.ID), + reason: "plugin_not_installed", + } + } + + ids := make([]string, 0, len(installed)) + for _, plugin := range installed { + ids = append(ids, plugin.ID) + } + + return validationError{ + err: fmt.Errorf("the plugin %s is not installed. Installed plugins: %s", ref.ID, strings.Join(ids, ", ")), + reason: "plugin_not_installed", + } +} + +// pluginOperation is one install, update or approval for one tenant. It holds +// the consenter of the operation, which remembers what the person approved until +// the plugins are installed. +type pluginOperation struct { + cli *cli + svc *pluginService + index *registry.Index + + // Tenant is the tenant the plugins are approved for, or empty. + tenant string + + // Asks is true when a person at a terminal can be asked to approve. An agent + // is never asked, even at a terminal. + asks bool + + consenter *approvals.Consenter +} + +func newPluginOperation(cmd *cobra.Command, cli *cli, svc *pluginService, index *registry.Index) *pluginOperation { + tenant := pluginTenant(cli) + interactive := svc.canPrompt(cmd) + + return &pluginOperation{ + cli: cli, + svc: svc, + index: index, + tenant: tenant, + asks: interactive && !cli.agentMode, + consenter: &approvals.Consenter{ + Tenant: tenant, + Approvals: svc.approvals, + Session: approvals.Session{Interactive: interactive, Agent: cli.agentMode}, + Prompter: svc.prompter, + Out: cli.renderer.MessageWriter, + }, + } +} + +// consent returns the Consent of the install or update. It names the plugin +// before anything else is shown, because the consent screen does not, and checks +// the scopes of the session once the person agreed. +// +// An install of a plugin the person cannot approve now goes ahead without an +// approval, which is recorded by nobody. An update must not: new permissions +// need an approval first, so when the person cannot give it the update fails and +// the installed version stays active. +func (o *pluginOperation) consent(mustApprove bool) store.ConsentFunc { + var approve store.ConsentFunc + + switch { + case o.tenant == "" && mustApprove: + approve = func(context.Context, store.Change) error { return errPluginNoTenant } + case o.tenant != "" && (o.asks || mustApprove): + approve = o.consenter.ConsentFunc() + } + + return func(ctx context.Context, change store.Change) error { + o.announce(ctx, change) + + if approve != nil { + if err := approve(ctx, change); err != nil { + return err + } + } + + o.warnScopes(change.New.Scopes) + + return nil + } +} + +// announce prints the plugin line: its ID, version and description, and the size +// of the download for this machine. +func (o *pluginOperation) announce(ctx context.Context, change store.Change) { + plugin, ok := o.index.FindPlugin(change.ID) + if !ok { + return + } + + o.cli.renderer.PluginSummary(change.ID, change.NewVersion, plugin.Description) + + records, err := o.svc.client.Versions(ctx, plugin) + if err != nil { + return + } + + machine := plugins.CurrentPlatform() + + n := slices.IndexFunc(records, func(r registry.VersionRecord) bool { return r.Version == change.NewVersion }) + if n < 0 { + return + } + + for _, artifact := range records[n].Artifacts { + if built, ok := artifact.Platform(); ok && built == machine { + o.cli.renderer.PluginDownload(artifact.Size, machine) + } + } +} + +// warnScopes warns when the session of the tenant may not have the scopes that +// the plugin asks for. It never stops the install and never starts a login. +func (o *pluginOperation) warnScopes(declared []string) { + if len(declared) == 0 || o.tenant == "" { + return + } + + list := strings.Join(declared, ", ") + tenant, recorded := o.cli.Config.Tenants[o.tenant] + + switch { + case envAuthEnabled(os.Getenv) || !recorded: + o.cli.renderer.Warnf("The plugin asks for the scopes %s. The scopes of your session could not be checked, so make sure it has them.", list) + case tenant.IsAuthenticatedWithClientCredentials(): + o.cli.renderer.Warnf("The plugin asks for the scopes %s. The application you are logged in with needs a grant for them on the Auth0 Management API, or the calls of the plugin are denied.", list) + default: + missing := slices.DeleteFunc(slices.Clone(declared), func(scope string) bool { return slices.Contains(tenant.Scopes, scope) }) + if len(missing) == 0 { + return + } + + // A login asks for exactly the scopes it is given, so the extra scopes + // that the session has now must be asked for again. + request := slices.Concat(tenant.GetExtraRequestedScopes(), missing) + slices.Sort(request) + + o.cli.renderer.Warnf("The plugin asks for scopes that your login does not have: %s. Its calls that need them are denied until you log in again with: auth0 login --domain %s --scopes %q", + strings.Join(missing, ", "), o.tenant, strings.Join(request, ",")) + } +} + +// finish records the approval the person gave for what was installed, and works +// out whether the plugin still needs one. It is called only after the install +// succeeded. +func (o *pluginOperation) finish(ctx context.Context, result store.Result) (*display.PluginChangeResult, error) { + if err := o.consenter.Record(ctx, result); err != nil { + return nil, fmt.Errorf("%s %s was installed, but its approval could not be saved: %w", result.ID, result.Version, err) + } + + return o.changeView(result) +} + +// changeView returns the view of what was installed, with whether the plugin +// still needs an approval. +func (o *pluginOperation) changeView(result store.Result) (*display.PluginChangeResult, error) { + required, err := o.approvalRequired(result.ID) + if err != nil { + return nil, err + } + + view := &display.PluginChangeResult{ + ID: result.ID, + Version: result.Version, + PreviousVersion: result.Previous, + Action: string(result.Action), + Pinned: result.Pinned, + ApprovalRequired: required, + } + + // The command is data for the agent that cannot approve. Without a tenant, + // there is no command to give. + if required && o.tenant != "" { + view.ApproveCommand = approveCommand(result.ID, o.tenant) + } + + return view, nil +} + +// approvalRequired reports whether the active version of the plugin is not +// approved for the tenant, by an approval or by a pin. Without a tenant it is +// always not approved. +func (o *pluginOperation) approvalRequired(id string) (bool, error) { + if o.tenant == "" { + return true, nil + } + + verified, err := o.svc.store.Verify(id) + if err != nil { + return false, fmt.Errorf("%s was installed, but it failed verification: %w", id, err) + } + + approved, err := o.svc.approvals.Approved(o.tenant, id, verified.PermissionsHash) + if err != nil { + return false, err + } + + return !approved, nil +} + +// say tells the person what the install or update did, and how to approve the +// plugin when it still needs an approval. +func (o *pluginOperation) say(result store.Result, view *display.PluginChangeResult, updated bool) { + r := o.cli.renderer + + switch { + case result.Action == store.ActionUnchanged && updated: + r.Infof("%s %s is up to date.", result.ID, result.Version) + case result.Action == store.ActionUnchanged: + r.Infof("%s %s is already installed.", result.ID, result.Version) + case result.Previous == "": + r.Infof("Installed %s %s.", result.ID, result.Version) + case updated: + r.Infof("Updated %s from %s to %s.", result.ID, result.Previous, result.Version) + default: + r.Infof("Switched %s from %s to %s.", result.ID, result.Previous, result.Version) + } + + if result.Pinned { + r.Infof("%s is pinned to %s, so `auth0 plugin update --all` leaves it alone. Move it to the latest version with: auth0 plugin update %s", result.ID, result.Version, plugins.NameOf(result.ID)) + } + + o.sayApproval(view) +} + +// sayApproval prints the command that approves the plugin of the view, when it +// needs an approval. +func (o *pluginOperation) sayApproval(view *display.PluginChangeResult) { + switch { + case !view.ApprovalRequired: + case o.tenant == "": + o.cli.renderer.Infof("%s is not approved on any tenant. Log in with auth0 login, then approve it in a terminal with: auth0 plugin approve %s --tenant ", view.ID, view.ID) + default: + o.cli.renderer.Infof("%s is not approved for %s yet. Approve it in a terminal with: %s", view.ID, o.tenant, view.ApproveCommand) + } +} + +// declined prints the view of an install or update that the person declined, as +// update --all does for a plugin it left alone: the version that stays active, +// which is empty when nothing is installed, and the action declined. The human +// message was printed already, and declining is not a failure. +func (o *pluginOperation) declined(id string) error { + installed, err := o.svc.store.List() + if err != nil { + return err + } + + view := &display.PluginChangeResult{ID: id, Action: "declined"} + + if entry, ok := findInstalled(installed, id); ok { + view.Version, view.Pinned = entry.Active, entry.Pinned + } + + o.cli.renderer.PluginChange(*view) + + return nil +} + +// updateOne updates the plugin of the reference. +func (o *pluginOperation) updateOne(ctx context.Context, ref pluginRef, opts store.Options) error { + installed, err := requireInstalled(o.svc.store, ref) + if err != nil { + return err + } + + result, err := o.svc.store.Update(ctx, o.index, ref.ID, opts) + if declinedByPerson(err) { + o.cli.renderer.Infof("%s was not updated, because the new permissions were not approved. %s stays active.", ref.ID, installed.Active) + return o.declined(ref.ID) + } + + if err != nil { + return notUpdatedError(err, installed.Active) + } + + view, err := o.finish(ctx, result) + if err != nil { + return err + } + + o.say(result, view, true) + o.cli.renderer.PluginChange(*view) + + return nil +} + +// updateAll updates every plugin that is not pinned and reports each of them. It +// fails when some update failed, but not for a pinned plugin or a person who +// declined the new permissions of one. +func (o *pluginOperation) updateAll(ctx context.Context, opts store.Options) error { + results, err := o.svc.store.UpdateAll(ctx, o.index, opts) + + // Only the plugins that were updated can be approved: never a skipped one, + // a declined one or one that failed. All of them are recorded in one call, + // because a call discards the approvals of the plugins that it is not given. + var done []store.Result + + for _, entry := range results { + if entry.Err == nil && !entry.Skipped { + done = append(done, entry.Result) + } + } + + recordErr := o.consenter.Record(ctx, done...) + + var ( + views []display.PluginChangeResult + failed []string + ) + + for _, entry := range results { + view, failure := o.reportEntry(entry) + views = append(views, view) + + if failure { + failed = append(failed, plugins.NameOf(entry.ID)) + } + } + + if len(views) == 0 { + o.cli.renderer.EmptyState("installed plugins", "Find one with: auth0 plugin search") + } else { + o.cli.renderer.PluginChanges(views) + } + + switch { + case err != nil: + return err + case recordErr != nil: + return fmt.Errorf("the plugins were updated, but their approvals could not be saved: %w", recordErr) + case len(failed) > 0: + return fmt.Errorf("%s could not be updated: %s", pluralize(len(failed), "plugin", "plugins"), strings.Join(failed, ", ")) + } + + return nil +} + +// reportEntry prints the line of one plugin of update --all and returns its +// result. The second value is true when the update failed. +func (o *pluginOperation) reportEntry(entry store.UpdateResult) (display.PluginChangeResult, bool) { + r := o.cli.renderer + + view := display.PluginChangeResult{ID: entry.ID, Version: entry.Active} + + var failure error + + switch { + case entry.Skipped: + view.Action, view.Pinned, view.Available = "skipped", true, entry.Available + + if entry.Available == "" { + r.Infof("%s is pinned to %s, which is the newest version.", entry.ID, entry.Active) + } else { + r.Infof("%s is pinned to %s and was skipped. Version %s is available: auth0 plugin update %s", entry.ID, entry.Active, entry.Available, plugins.NameOf(entry.ID)) + } + case declinedByPerson(entry.Err): + view.Action = "declined" + r.Infof("%s was not updated, because the new permissions were not approved. %s stays active.", entry.ID, entry.Active) + case entry.Err != nil: + failure = notUpdatedError(entry.Err, entry.Active) + default: + done, err := o.changeView(entry.Result) + failure = err + + if err == nil { + view = *done + o.say(entry.Result, done, true) + } + } + + if failure != nil { + view.Action, view.Error = "failed", failure.Error() + r.Errorf("%s: %v", entry.ID, failure) + + return view, true + } + + return view, false +} + +// notUpdatedError says why an update did not happen when the person could not +// approve the new permissions, and leaves every other error as it is. +func notUpdatedError(err error, active string) error { + declined, ok := errors.AsType[*store.DeclinedError](err) + if !ok { + return err + } + + why := declined.Err.Error() + + switch { + case errors.Is(declined.Err, approvals.ErrAgentMode): + why = "an agent cannot approve plugins" + case errors.Is(declined.Err, approvals.ErrNotInteractive): + why = "there is no terminal to ask in" + case errors.Is(declined.Err, errPluginNoTenant): + why = "there is no tenant to approve them for" + } + + name := plugins.NameOf(declined.ID) + + return fmt.Errorf("%s %s asks for permissions that are not approved, and %s. %s was not updated, and %s stays active. Run this in a terminal to review them: auth0 plugin update %s", + name, declined.Version, strings.TrimRight(why, "."), name, active, name) +} + +// pluginService is what the plugin commands work with: the registry client, the +// store of installed plugins, the approvals, and the way to ask the person. +type pluginService struct { + client *registry.Client + store *store.Store + approvals *approvals.Approvals + prompter approvals.Prompter + + // Interactive says whether a person can answer at the terminal of the + // command. When it is nil, canPrompt decides. Tests set it, because they have + // no terminal. + interactive func(cmd *cobra.Command) bool + + // Confirmer asks the person a yes or no question. When it is nil, the prompt + // of the CLI is used. Tests set it for the same reason. + confirmer func(message string) bool +} + +// canPrompt reports whether a person can answer at the terminal of the command. +func (s *pluginService) canPrompt(cmd *cobra.Command) bool { + if s.interactive != nil { + return s.interactive(cmd) + } + + return canPrompt(cmd) +} + +// confirm asks the person to confirm something. +func (s *pluginService) confirm(message string) bool { + if s.confirmer != nil { + return s.confirmer(message) + } + + return prompt.Confirm(message) +} + +// usePins reads the approval pins from AUTH0_CLI_PLUGIN_APPROVALS. Only the +// commands that approve, install and update need them, so a value that cannot be +// read fails those and leaves the commands that only read or remove working. It +// is an error and is never ignored. +func (s *pluginService) usePins() error { + pins, err := approvals.ParsePins(os.Getenv(approvals.PinsEnv)) + if err != nil { + return validationError{err: err} + } + + s.approvals.Pins = pins + + return nil +} + +// checkApprovals fails when the approvals cannot be used for the tenant: the +// tenant is not a tenant domain, or the approvals file is damaged. It runs before +// anything is downloaded, so an install or update that would fail at the end +// fails at the start and leaves nothing behind. Without a tenant nothing is +// approved, so there is nothing to check. +func (s *pluginService) checkApprovals(tenant string) error { + if tenant == "" { + return nil + } + + return s.approvals.Usable(tenant) +} + +// getPlugins returns the plugin service. It is built when a command asks for it, +// not when the CLI starts, so that commands that never ask read no configuration. +// Each call builds a new service, unless one was injected. +func (c *cli) getPlugins() (*pluginService, error) { + if c.plugins != nil { + return c.plugins() + } + + return newPluginService(c) +} + +// newPluginService builds the plugin service for the plugins directory next to +// the config file. It does not read the approval pins, see usePins. +func newPluginService(c *cli) (*pluginService, error) { + // Env auth mode promises not to read the config file, so it is left out and + // the built-in registry is used. + if !envAuthEnabled(os.Getenv) { + if err := c.Config.Initialize(); err != nil && !errors.Is(err, config.ErrConfigFileMissing) { + return nil, fmt.Errorf("failed to read the config: %w", err) + } + } + + configured, err := registry.ResolveRegistries(c.Config.Registries) + if err != nil { + return nil, validationError{err: err} + } + + dir := c.Config.PluginsDir() + + client := registry.NewClient(configured, filepath.Join(dir, "registries", configured.Name)) + approved := &approvals.Approvals{Dir: dir} + + return &pluginService{ + client: client, + approvals: approved, + store: &store.Store{Dir: dir, CLIVersion: buildinfo.Version, Approvals: approved, Versions: client}, + prompter: newPluginPrompter(iostream.Input, iostream.Messages), + }, nil +} + +// loadPluginIndex returns the verified index of the registry for a command that +// only reads it. The index is refreshed when it is due, and a copy that could +// not be refreshed is used with a warning, unless the output is for a machine. +// When the registry cannot be used the error says what to run next. +func loadPluginIndex(ctx context.Context, cli *cli, svc *pluginService) (*registry.Index, error) { + index, status, err := svc.client.EnsureFresh(ctx) + if err != nil { + return nil, withRegistryNextAction(err, "auth0 plugin search") + } + + if status.Warning != "" && !cli.wantsJSONError() { + cli.renderer.Warnf("%s", status.Warning) + } + + return index, nil +} + +// withRegistryNextAction adds what to do next to the registry errors that +// reconnecting fixes. +func withRegistryNextAction(err error, command string) error { + if errors.Is(err, registry.ErrExpired) || errors.Is(err, registry.ErrOffline) { + return fmt.Errorf("%w. Reconnect and run: %s", err, command) + } + + return err +} + +// pluginPrompter reads the answers of the person from one reader, so that +// nothing that was buffered for one question is lost before the next. +type pluginPrompter struct { + in *bufio.Reader + out io.Writer +} + +func newPluginPrompter(in io.Reader, out io.Writer) *pluginPrompter { + return &pluginPrompter{in: bufio.NewReader(in), out: out} +} + +// ReadLine writes the prompt and returns the next line without its line break. +// It returns io.EOF when the input ends before anything was typed. A last line +// that has no line break is still an answer. +func (p *pluginPrompter) ReadLine(prompt string) (string, error) { + if _, err := io.WriteString(p.out, prompt); err != nil { + return "", err + } + + line, err := p.in.ReadString('\n') + if err != nil && (!errors.Is(err, io.EOF) || line == "") { + return "", err + } + + return strings.TrimRight(line, "\r\n"), nil +} + +// pluginIDPattern is the form of a plugin ID, registry/name. It is a copy of the +// idPattern of the store package, which is unexported, and it is what makes a +// name safe to use as a path. Keep the two in step. +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*$`) + +// pluginRef is a reference to a plugin, as typed on the command line. +type pluginRef struct { + // ID is the plugin ID, such as auth0/checkmate. + ID string + + // Name is the part of the ID after the registry, which is the name of the + // command that the plugin adds. + Name string + + // Version is the exact version that was asked for, or empty. + Version string +} + +// String returns the reference the way it is typed in a command: the name, and +// the version after an at sign when there is one. +func (r pluginRef) String() string { + if r.Version == "" { + return r.Name + } + + return r.Name + "@" + r.Version +} + +// parsePluginRef reads [registry/]name[@version]. The registry can only be the +// built-in one, the name must be a valid plugin name, and the version must be +// MAJOR.MINOR.PATCH, written the way the registry writes it. +func parsePluginRef(value string) (pluginRef, error) { + if value == "" { + return pluginRef{}, errors.New("a plugin name is required, for example: auth0 plugin info checkmate") + } + + rest, version, hasVersion := strings.Cut(value, "@") + + registryName, name, hasRegistry := strings.Cut(rest, "/") + if !hasRegistry { + registryName, name = registry.DefaultRegistryName, rest + } + + if registryName != registry.DefaultRegistryName { + return pluginRef{}, fmt.Errorf("%q is not a registry this CLI knows: the only available registry is %q", registryName, registry.DefaultRegistryName) + } + + id := registryName + "/" + name + if !pluginIDPattern.MatchString(id) { + return pluginRef{}, fmt.Errorf("%q is not a valid plugin name: use lowercase letters, digits and hyphens, starting with a letter or digit", name) + } + + if hasVersion { + parsed, ok := plugins.ParseVersion(version) + if !ok || parsed.String() != version { + return pluginRef{}, fmt.Errorf("%q is not a valid version: use MAJOR.MINOR.PATCH, for example %s@1.0.0", version, name) + } + } + + return pluginRef{ID: id, Name: name, Version: version}, nil +} + +// checkPluginNameAvailable refuses a plugin name that the running CLI already +// uses for one of its own commands. The names come from the command tree of the +// root at run time, hidden commands and aliases included, plus the help and +// completion commands that Cobra adds itself. +func checkPluginNameAvailable(root *cobra.Command, name string) error { + reserved := name == "help" || name == "completion" + + for _, command := range root.Commands() { + if command.Name() == name || slices.Contains(command.Aliases, name) { + reserved = true + } + } + + if reserved { + // The error renderer adds the final period. + return validationError{ + err: fmt.Errorf("auth0 already has a command named %q. Plugin names cannot shadow core commands", name), + reason: "plugin_name_reserved", + } + } + + return nil +} diff --git a/internal/cli/plugin_test.go b/internal/cli/plugin_test.go new file mode 100644 index 000000000..e7f9ab827 --- /dev/null +++ b/internal/cli/plugin_test.go @@ -0,0 +1,2860 @@ +package cli + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth" + "github.com/auth0/auth0-cli/internal/buildinfo" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/registry/registrytest" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +const ( + pluginTestID = "auth0/checkmate" + pluginTestBin = "checkmate" + pluginTestTenant = "acme.us.auth0.com" +) + +// pluginEnv is a CLI with a plugin service that works on a registry served by a +// test server and on a temporary plugins directory. It does not need a build tag +// or a network: the registry is signed with throwaway TUF keys that only the +// client of the environment trusts, through its root, and downloads are sent to +// the test server whatever host the artifact URL names. +type pluginEnv struct { + t *testing.T + server *registrytest.Server + repo *registrytest.Repo + platform plugins.Platform + + // Dir is the plugins directory. + Dir string + + // Now is the time the client of the registry sees. A test moves it to make an + // index stale or expired. + Now time.Time + + // ManifestDescription, when it is set, is the description in the manifest of + // the archives that release builds, instead of the one of the test plugin. + ManifestDescription string + + // IndexVersion is the version of the index that was published last. + indexVersion int + + // Terminal is true when a person sits at a terminal. The answers of the + // person are set with answer, and the answer to a confirmation with Confirm. + Terminal bool + Confirm bool + + // Confirmations are the questions that the commands asked the person to + // confirm, since the last run. + Confirmations []string + + cli *cli + svc *pluginService + stdout *bytes.Buffer + stderr *bytes.Buffer +} + +// newPluginEnv returns an environment whose registry serves nothing yet. It skips +// the test on a machine that is not one of the plugin platforms. +func newPluginEnv(t *testing.T) *pluginEnv { + t.Helper() + + // The commands read the config file of the user, and these variables, which + // the machine of a developer may set. + t.Setenv("HOME", t.TempDir()) + + for _, name := range []string{approvals.PinsEnv, pluginsDisabledEnv, authModeEnvVar, envDomain} { + t.Setenv(name, "") + } + + platform := plugins.CurrentPlatform() + if platform == "" { + t.Skip("this machine is not one of the plugin platforms") + } + + repo := registrytest.NewRepo(t) + server := registrytest.NewServer(t) + dir := filepath.Join(t.TempDir(), "plugins") + + e := &pluginEnv{ + t: t, + server: server, + repo: repo, + platform: platform, + Dir: dir, + Now: time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC), + + indexVersion: 1, + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + } + + client := ®istry.Client{ + Registry: server.Registry(), + Root: repo.Root(), + HTTP: server.Client(), + Dir: filepath.Join(dir, "registries", registry.DefaultRegistryName), + Now: func() time.Time { return e.Now }, + } + approved := &approvals.Approvals{Dir: dir, Now: func() time.Time { return e.Now }} + + e.svc = &pluginService{ + client: client, + approvals: approved, + store: &store.Store{Dir: dir, CLIVersion: "1.39.0", HTTP: server.AnyHostClient(), Approvals: approved, Versions: client}, + } + e.svc.interactive = func(cmd *cobra.Command) bool { + noInput, _ := cmd.Root().Flags().GetBool("no-input") + + return e.Terminal && !noInput + } + e.svc.confirmer = func(message string) bool { + e.Confirmations = append(e.Confirmations, message) + + return e.Confirm + } + e.cli = &cli{plugins: func() (*pluginService, error) { return e.svc, nil }} + + return e +} + +// answer puts a person at a terminal who types the given text, line by line. +func (e *pluginEnv) answer(text string) { + e.Terminal = true + e.svc.prompter = newPluginPrompter(strings.NewReader(text), e.stderr) +} + +// loginAs records a login session for a tenant in the config file of the CLI. +func (e *pluginEnv) loginAs(tenant config.Tenant) { + e.cli.Config.Tenants = config.Tenants{tenant.Domain: tenant} +} + +// installedIDs returns the IDs of the installed plugins. +func (e *pluginEnv) installedIDs() []string { + e.t.Helper() + + installed, err := e.svc.store.List() + require.NoError(e.t, err) + + ids := []string{} + for _, plugin := range installed { + ids = append(ids, plugin.ID) + } + + return ids +} + +// activeVersion returns the active version of an installed plugin. +func (e *pluginEnv) activeVersion(id string) string { + e.t.Helper() + + installed, err := e.svc.store.List() + require.NoError(e.t, err) + + for _, plugin := range installed { + if plugin.ID == id { + return plugin.Active + } + } + + return "" +} + +// approvedHashes returns the consent hashes that are approved for a plugin, by +// tenant. It reads the approvals file, so pins are not in it. +func (e *pluginEnv) approvedHashes(id string) map[string]string { + e.t.Helper() + + records, err := e.svc.approvals.List(id) + require.NoError(e.t, err) + + hashes := map[string]string{} + for _, record := range records { + hashes[record.Tenant] = record.PermissionsHash + } + + return hashes +} + +// release builds a version of the test plugin for this machine, serves its +// archive, and returns its registry record. The mutators change the record +// before it is sealed. +func (e *pluginEnv) release(version string, mutate ...func(*registry.VersionRecord)) registry.VersionRecord { + e.t.Helper() + + return e.releaseAs(pluginTestID, e.platform, version, mutate...) +} + +// releaseAs is release for another plugin, or for a build for another platform. +func (e *pluginEnv) releaseAs(id string, platform plugins.Platform, version string, mutate ...func(*registry.VersionRecord)) registry.VersionRecord { + e.t.Helper() + + name := plugins.NameOf(id) + + record := registrytest.NewVersion(version) + record.Platforms = []plugins.Platform{platform} + + for _, change := range mutate { + change(&record) + } + + record = registrytest.Seal(record) + + binary := []byte("#!/bin/sh\necho " + name + " " + version + "\n") + + // A newer major version is bigger, so that the size of the download tells the + // versions apart. The padding is a comment that does not compress. + block := sha256.Sum256([]byte(version)) + binary = append(binary, '#') + + for range int(version[0] - '0') { + binary = append(binary, hex.EncodeToString(block[:])...) + block = sha256.Sum256(block[:]) + } + + binary = append(binary, '\n') + archive := registrytest.Package(id, record, registry.ArtifactFormatTarGz, name, binary) + + if e.ManifestDescription != "" { + description, err := json.Marshal(e.ManifestDescription) + require.NoError(e.t, err) + + manifest := bytes.Replace(registrytest.Manifest(id, record), []byte(`"description":"Test plugin `+id+`"`), append([]byte(`"description":`), description...), 1) + archive = registrytest.TarGz(registrytest.ArchiveEntry{Name: name, Body: binary}, registrytest.ArchiveEntry{Name: "plugin.json", Body: manifest}) + } + + path := pluginArtifactPath(name, version) + + record.Artifacts = []registry.Artifact{ + registrytest.NewArtifact(platform, "https://github.com"+path, registry.ArtifactFormatTarGz, name, archive), + } + + e.server.Set(path, archive) + + return record +} + +// pluginArtifactPath is where the archive of a version of a plugin is served. +func pluginArtifactPath(name, version string) string { + return "/auth0/auth0-cli-plugins/releases/download/v" + version + "/" + name + ".tar.gz" +} + +// index returns a builder for an index with the test plugin, whose latest +// version is the last record. +func (e *pluginEnv) index(records ...registry.VersionRecord) *registrytest.IndexBuilder { + return registrytest.NewIndexBuilder().AddPlugin(registrytest.NewPlugin(pluginTestID, records...)) +} + +// publish signs the index and serves it. +func (e *pluginEnv) publish(builder *registrytest.IndexBuilder) { + e.t.Helper() + + e.server.Publish(builder, e.repo) +} + +// republish serves a newer index a day later, so that the client fetches it. +func (e *pluginEnv) republish(builder *registrytest.IndexBuilder) { + e.t.Helper() + + e.indexVersion++ + e.Now = e.Now.Add(25 * time.Hour) + e.publish(builder.SetVersion(e.indexVersion)) +} + +// install installs a version of the test plugin from the published index, +// without asking for consent. +func (e *pluginEnv) install(version string) store.Result { + e.t.Helper() + + return e.installPlugin(pluginTestID, version) +} + +// installPlugin is install for any plugin of the published index. +func (e *pluginEnv) installPlugin(id, version string) store.Result { + e.t.Helper() + + index, _, err := e.svc.client.EnsureFresh(context.Background()) + require.NoError(e.t, err) + + result, err := e.svc.store.Install(context.Background(), index, id, version, store.Options{}) + require.NoError(e.t, err) + + return result +} + +// run executes a command line on a new command tree, with empty output buffers, +// and returns the error of the command. Colors are off, and no terminal is +// attached, so nothing can prompt. +func (e *pluginEnv) run(args ...string) error { + e.t.Helper() + + e.stdout.Reset() + e.stderr.Reset() + e.Confirmations = nil + + e.cli.renderer = &display.Renderer{MessageWriter: e.stderr, ResultWriter: e.stdout} + + return executeTree(e.cli, args...) +} + +// executeTree runs a command line on a new command tree of the CLI, with colors +// off, and returns the error of the command. +func executeTree(c *cli, args ...string) error { + root := buildRootCmd(c) + addPersistentFlags(root, c) + addSubCommands(root, c) + enforceUnknownSubcommand(root) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"--no-color"}, args...)) + + return root.ExecuteContext(context.Background()) +} + +// pluginWithDescription returns a plugin with the given ID and description, for a +// test that needs more than the test plugin. +func pluginWithDescription(id, description string, records ...registry.VersionRecord) registry.Plugin { + plugin := registrytest.NewPlugin(id, records...) + plugin.Description = description + + return plugin +} + +// refIndex returns a builder for an index whose test plugin lists its versions +// in a separate file, as large registries do. +func (e *pluginEnv) refIndex(records ...registry.VersionRecord) *registrytest.IndexBuilder { + return registrytest.NewIndexBuilder().AddRefPlugin(registrytest.NewPlugin(pluginTestID, records...)) +} + +func TestRegisterPluginCmd(t *testing.T) { + tests := []struct { + version string + want bool + }{ + {"", true}, + {"dev", true}, + {"1.39.0-beta.1", true}, + {"v1.39.0-rc.2+build", true}, + {"1.39.0", false}, + {"v1.39.0", false}, + {"1.39.0+build", false}, + {"garbage", false}, + } + + for _, test := range tests { + t.Run(test.version, func(t *testing.T) { + root := &cobra.Command{Use: "auth0"} + registerPluginCmd(root, &cli{}, test.version) + + found, _, err := root.Find([]string{"plugin"}) + require.NoError(t, err) + assert.Equal(t, test.want, found != root) + + if !test.want { + // The alias must not leak the feature either. + found, _, err = root.Find([]string{"plugins"}) + require.NoError(t, err) + assert.Equal(t, root, found) + } + }) + } +} + +func TestPluginCmd(t *testing.T) { + group := pluginCmd(&cli{}) + + assert.Equal(t, "plugin", group.Name()) + assert.Equal(t, []string{"plugins"}, group.Aliases) + assert.Equal(t, "true", group.Annotations[skipDocsAnnotation]) + assert.False(t, group.Hidden, "the group must show in help") + assert.True(t, strings.HasPrefix(group.Short, "(Experimental)"), group.Short) + assert.True(t, strings.HasPrefix(group.Long, "(Experimental)"), group.Long) + + var names []string + + for _, sub := range group.Commands() { + names = append(names, sub.Name()) + + assert.NotEmpty(t, sub.Short, sub.Name()) + assert.NotEmpty(t, sub.Example, sub.Name()) + + if sub.Name() == "uninstall" { + assert.Equal(t, []string{"remove"}, sub.Aliases) + } else { + assert.Empty(t, sub.Aliases, sub.Name()) + } + } + + slices.Sort(names) + assert.Equal(t, []string{"approve", "info", "install", "list", "search", "uninstall", "update"}, names) +} + +func TestSearchPluginCmd(t *testing.T) { + t.Run("lists the plugins of the registry", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("1.1.0"))) + + require.NoError(t, e.run("plugin", "search")) + + assert.Regexp(t, `(?m)^\s*ID\s+VERSION\s+INSTALLED\s+DESCRIPTION\s*$`, e.stdout.String()) + assert.Regexp(t, `(?m)^\s*auth0/checkmate\s+1\.1\.0\s+no\s+Test plugin auth0/checkmate\s*$`, e.stdout.String()) + assert.Empty(t, e.stderr.String()) + }) + + t.Run("marks an installed plugin and works through the plugins alias", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + + require.NoError(t, e.run("plugins", "search")) + + assert.Regexp(t, `auth0/checkmate\s+1\.0\.0\s+yes\s`, e.stdout.String()) + }) + + t.Run("matches the query against the ID and the description, ignoring case", func(t *testing.T) { + e := newPluginEnv(t) + other := pluginWithDescription("auth0/other", "Audits the tenant", e.release("1.0.0")) + e.publish(e.index(e.release("1.0.0")).AddPlugin(other)) + + tests := []struct { + query string + want []string + }{ + {"", []string{"auth0/checkmate", "auth0/other"}}, + {"CHECK", []string{"auth0/checkmate"}}, + {"audits", []string{"auth0/other"}}, + {"auth0/", []string{"auth0/checkmate", "auth0/other"}}, + {"zzz", nil}, + } + + for _, test := range tests { + t.Run(test.query, func(t *testing.T) { + args := []string{"plugin", "search", "--json"} + if test.query != "" { + args = append(args, test.query) + } + + require.NoError(t, e.run(args...)) + + var found []map[string]any + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &found)) + + ids := make([]string, 0, len(found)) + for _, plugin := range found { + ids = append(ids, plugin["id"].(string)) + } + + assert.ElementsMatch(t, test.want, ids) + }) + } + }) + + t.Run("says so when nothing matches", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + + require.NoError(t, e.run("plugin", "search", "zzz")) + + assert.Empty(t, e.stdout.String()) + assert.Contains(t, e.stderr.String(), "No plugins available") + }) + + t.Run("prints stable JSON with the deprecation", func(t *testing.T) { + e := newPluginEnv(t) + // The registry lists the plugins in no particular order. + builder := registrytest.NewIndexBuilder(). + AddPlugin(pluginWithDescription("auth0/other", "Fine", e.release("2.0.0"))). + AddPlugin(registrytest.NewPlugin(pluginTestID, e.release("1.0.0"))). + Deprecate(registry.Deprecation{ID: pluginTestID, Message: "Use the new one.", Replacement: "auth0/other"}) + e.publish(builder) + + require.NoError(t, e.run("plugin", "search", "--json-compact")) + + assert.JSONEq(t, `[ + {"id":"auth0/checkmate","name":"checkmate","version":"1.0.0","installed":false,"description":"Test plugin auth0/checkmate", + "deprecated":{"message":"Use the new one.","replacement":"auth0/other"}}, + {"id":"auth0/other","name":"other","version":"2.0.0","installed":false,"description":"Fine","deprecated":null} + ]`, e.stdout.String()) + assert.Empty(t, e.stderr.String()) + }) + + t.Run("marks a deprecated plugin in the table and prints CSV", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0")).Deprecate(registry.Deprecation{ID: pluginTestID, Message: "Old."})) + + require.NoError(t, e.run("plugin", "search")) + assert.Contains(t, e.stdout.String(), "(deprecated) Test plugin auth0/checkmate") + + require.NoError(t, e.run("plugin", "search", "--csv")) + assert.Equal(t, "ID,Version,Installed,Description\nauth0/checkmate,1.0.0,no,(deprecated) Test plugin auth0/checkmate\n", e.stdout.String()) + }) +} + +func TestSearchPluginCmd_Freshness(t *testing.T) { + t.Run("keeps the stale warning out of JSON and agent mode output", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + require.NoError(t, e.run("plugin", "search")) + + e.Now = e.Now.Add(7 * 24 * time.Hour) + e.server.Fail("/timestamp.json", 503) + + for _, flag := range []string{"--json", "--json-compact", "--agent-mode"} { + require.NoError(t, e.run("plugin", "search", flag), flag) + + assert.Contains(t, e.stdout.String(), "auth0/checkmate", flag) + assert.NotContains(t, e.stderr.String(), pluginStaleWarning, flag) + } + }) + + t.Run("does not use an index that fails its signature", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + require.NoError(t, e.run("plugin", "search")) + e.stdout.Reset() + + // The registry retires the targets key and then signs with it, as a registry + // would whose old key leaked. The client has the first index cached, and + // republish moves the clock past the check interval so that it looks again. + e.repo.RotateKey(registrytest.RoleTargets) + e.repo.SignWithRetiredKey(registrytest.RoleTargets) + e.republish(e.index(e.release("1.0.0"))) + + err := e.run("plugin", "search") + + require.ErrorIs(t, err, registry.ErrSignatureInvalid) + assert.Empty(t, e.stdout.String()) + }) +} + +// pluginStaleWarning is what the registry client says about an index that is a +// week old and could not be refreshed. +const pluginStaleWarning = "using the cached index from 7 days ago" + +// TestPluginCommands_IndexFreshness runs every command that reads the registry +// index against a saved index that is stale or expired. Search works from a stale +// copy with a warning. Install and update need a current copy and refuse it. No +// command works from an expired copy, and each says what to run once the registry +// can be reached. The registry tests have the missing copy and the copy that +// expires while it is refreshed, and TestWithRegistryNextAction has the text. +func TestPluginCommands_IndexFreshness(t *testing.T) { + // The environment has version 1.0.0 installed and the index cached, and the + // registry has a newer version that it cannot reach. A positive expiry is + // relative to the time of the environment. + newEnv := func(t *testing.T, expiresIn time.Duration) *pluginEnv { + t.Helper() + + e := newPluginEnv(t) + builder := e.index(e.release("1.0.0"), e.release("2.0.0")) + + if expiresIn > 0 { + builder.SetExpires(e.Now.Add(expiresIn)) + } + + e.publish(builder) + e.installPlugin(pluginTestID, "1.0.0") + + return e + } + + commands := []struct { + name string + args []string + next string + + // Uses a stale copy is true for the command that works from a stale index. + usesStale bool + }{ + {"search", []string{"plugin", "search"}, "Reconnect and run: auth0 plugin search", true}, + {"install", []string{"plugin", "install", "checkmate"}, "Reconnect and run: auth0 plugin install checkmate", false}, + {"update", []string{"plugin", "update", "checkmate"}, "Reconnect and run: auth0 plugin update checkmate", false}, + {"update --all", []string{"plugin", "update", "--all"}, "Reconnect and run: auth0 plugin update --all", false}, + } + + for _, command := range commands { + t.Run(command.name+" with a stale copy", func(t *testing.T) { + e := newEnv(t, 0) + e.Now = e.Now.Add(7 * 24 * time.Hour) + e.server.Fail("/timestamp.json", 503) + + err := e.run(command.args...) + + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + + if command.usesStale { + require.NoError(t, err) + assert.Contains(t, e.stdout.String(), "auth0/checkmate") + assert.Contains(t, e.stderr.String(), pluginStaleWarning) + + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), "a current registry index") + assert.Contains(t, err.Error(), e.svc.client.Registry.URL) + assert.Contains(t, err.Error(), pluginStaleWarning) + assert.True(t, strings.HasSuffix(err.Error(), command.next), err.Error()) + assert.Empty(t, e.stdout.String()) + }) + + t.Run(command.name+" with an expired copy", func(t *testing.T) { + e := newEnv(t, time.Hour) + e.Now = e.Now.Add(2 * time.Hour) + e.server.Fail("/timestamp.json", 503) + + err := e.run(command.args...) + + require.ErrorIs(t, err, registry.ErrExpired) + assert.True(t, strings.HasSuffix(err.Error(), command.next), err.Error()) + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + assert.Empty(t, e.stdout.String()) + }) + } +} + +func TestWithRegistryNextAction(t *testing.T) { + const command = "auth0 plugin search" + + tests := []struct { + name string + err error + next bool + }{ + {"an expired index", registry.ErrExpired, true}, + {"an index that could not be fetched", registry.ErrOffline, true}, + {"an error that wraps one of them", fmt.Errorf("fetching the index: %w", registry.ErrOffline), true}, + {"an index that fails its signature", registry.ErrSignatureInvalid, false}, + {"any other error", errors.New("boom"), false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := withRegistryNextAction(test.err, command) + + require.ErrorIs(t, got, test.err) + + if !test.next { + assert.Equal(t, test.err, got) + return + } + + assert.Equal(t, test.err.Error()+". Reconnect and run: "+command, got.Error()) + }) + } +} + +func TestListPluginCmd(t *testing.T) { + t.Run("says so when nothing is installed", func(t *testing.T) { + e := newPluginEnv(t) + + require.NoError(t, e.run("plugin", "list")) + assert.Empty(t, e.stdout.String()) + assert.Contains(t, e.stderr.String(), "No installed plugins available") + + require.NoError(t, e.run("plugin", "list", "--json")) + assert.JSONEq(t, `[]`, e.stdout.String()) + }) + + t.Run("prints stable JSON with the previous version and the size", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("2.0.0"))) + e.install("1.0.0") + e.install("2.0.0") + + require.NoError(t, e.run("plugin", "list", "--json-compact")) + + var listed []map[string]any + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &listed)) + require.Len(t, listed, 1) + + assert.Equal(t, "auth0/checkmate", listed[0]["id"]) + assert.Equal(t, "checkmate", listed[0]["name"]) + assert.Equal(t, "2.0.0", listed[0]["version"]) + assert.Equal(t, "1.0.0", listed[0]["previous"]) + assert.Equal(t, true, listed[0]["pinned"]) + assert.Greater(t, listed[0]["sizeBytes"], float64(0)) + assert.Len(t, listed[0], 6) + }) + + t.Run("prints CSV", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + + require.NoError(t, e.run("plugin", "list", "--csv")) + + assert.Regexp(t, `^ID,Version,Pinned,Previous,Size\nauth0/checkmate,1\.0\.0,yes,,[^,]+\n$`, e.stdout.String()) + }) +} + +// withAsks gives a version of the test plugin a requirement and permissions. +func withAsks(record *registry.VersionRecord) { + record.Engines = &plugins.Engines{Auth0CLI: ">=1.38.0"} + record.Permissions = plugins.Permissions{ + Scopes: []string{"read:clients", "read:users"}, + Hosts: []plugins.HostPermission{{Host: "api.example.com", Reason: "Reports"}}, + Files: []plugins.FilePermission{}, + Env: []plugins.EnvPermission{}, + } +} + +func TestInfoPluginCmd(t *testing.T) { + t.Run("shows the latest version of a plugin that is not installed", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("2.0.0", withAsks))) + + require.NoError(t, e.run("plugin", "info", "checkmate")) + + out := e.stdout.String() + assert.Regexp(t, `ID\s+auth0/checkmate`, out) + assert.Regexp(t, `VERSION\s+2\.0\.0`, out) + assert.Regexp(t, `OWNER\s+Test team https://example\.com/bugs`, out) + assert.Regexp(t, `REQUIRES\s+auth0-cli >=1\.38\.0`, out) + assert.Regexp(t, `PERMISSIONS\s+2 scopes, 1 host, 0 file paths, 0 environment variables`, out) + assert.Regexp(t, `PERMISSIONS HASH\s+sha256:[0-9a-f]{64}`, out) + assert.Regexp(t, `VERSIONS\s+2\.0\.0 \(latest\), 1\.0\.0`, out) + assert.Regexp(t, `INSTALLED\s+no`, out) + assert.Regexp(t, `APPROVED TENANTS\s+none`, out) + assert.NotContains(t, out, "DEPRECATED") + assert.Empty(t, e.stderr.String()) + }) + + t.Run("prints stable JSON", func(t *testing.T) { + e := newPluginEnv(t) + old, latest := e.release("1.0.0"), e.release("2.0.0", withAsks) + e.publish(e.index(old, latest)) + + require.NoError(t, e.run("plugin", "info", "auth0/checkmate", "--json-compact")) + + assert.JSONEq(t, `{ + "id": "auth0/checkmate", + "version": "2.0.0", + "description": "Test plugin auth0/checkmate", + "owner": {"team": "Test team", "bugs": "https://example.com/bugs"}, + "homepage": "", + "requires": ">=1.38.0", + "platforms": ["`+string(e.platform)+`"], + "permissions": {"scopes": 2, "hosts": 1, "files": 0, "env": 0}, + "permissionsHash": "`+latest.PermissionsHash+`", + "versions": [{"version": "2.0.0", "latest": true, "revoked": false}, {"version": "1.0.0", "latest": false, "revoked": false}], + "installed": null, + "deprecated": null, + "approvedTenants": [] + }`, e.stdout.String()) + }) + + t.Run("shows the version that was asked for", func(t *testing.T) { + e := newPluginEnv(t) + old := e.release("1.0.0") + e.publish(e.index(old, e.release("2.0.0", withAsks))) + + require.NoError(t, e.run("plugin", "info", "checkmate@1.0.0", "--json")) + + var shown map[string]any + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &shown)) + + assert.Equal(t, "1.0.0", shown["version"]) + assert.Equal(t, old.PermissionsHash, shown["permissionsHash"]) + assert.Equal(t, "", shown["requires"]) + }) + + t.Run("reads the versions from the file the index points to", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.refIndex(e.release("1.0.0"), e.release("1.1.0"))) + + require.NoError(t, e.run("plugin", "info", "checkmate@1.0.0", "--json")) + + var shown struct { + Version string + Versions []struct{ Version string } + } + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &shown)) + + assert.Equal(t, "1.0.0", shown.Version) + assert.Len(t, shown.Versions, 2) + }) + + t.Run("marks revoked and deprecated versions", func(t *testing.T) { + e := newPluginEnv(t) + builder := e.index(e.release("1.0.0"), e.release("2.0.0")) + builder.Revoke(pluginTestID, "2.0.0", "Leaked a token.") + builder.Deprecate(registry.Deprecation{ID: pluginTestID, Message: "Moved.", Replacement: "auth0/other"}) + e.publish(builder) + + require.NoError(t, e.run("plugin", "info", "checkmate")) + + assert.Regexp(t, `VERSIONS\s+2\.0\.0 \(latest, revoked\), 1\.0\.0`, e.stdout.String()) + assert.Regexp(t, `DEPRECATED\s+Moved\. \(use auth0/other instead\)`, e.stdout.String()) + + require.NoError(t, e.run("plugin", "info", "checkmate", "--json")) + assert.Contains(t, e.stdout.String(), `"replacement": "auth0/other"`) + assert.Contains(t, e.stdout.String(), `"revoked": true`) + }) + + t.Run("shows the verified installed copy and the tenants that approved it", func(t *testing.T) { + e := newPluginEnv(t) + record := e.release("1.0.0", withAsks) + e.publish(e.index(record)) + e.install("1.0.0") + require.NoError(t, e.svc.approvals.Approve(context.Background(), "acme.us.auth0.com", pluginTestID, record.PermissionsHash)) + + require.NoError(t, e.run("plugin", "info", "checkmate")) + + assert.Regexp(t, `INSTALLED\s+1\.0\.0 *\n`, e.stdout.String()) + assert.Regexp(t, `APPROVED TENANTS\s+acme\.us\.auth0\.com`, e.stdout.String()) + + require.NoError(t, e.run("plugin", "info", "checkmate", "--json")) + + var shown struct { + Installed map[string]any + ApprovedTenants []map[string]any + } + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &shown)) + + assert.Equal(t, map[string]any{"version": "1.0.0", "verified": true, "permissionsHash": record.PermissionsHash}, shown.Installed) + require.Len(t, shown.ApprovedTenants, 1) + assert.Equal(t, "acme.us.auth0.com", shown.ApprovedTenants[0]["tenant"]) + assert.Equal(t, record.PermissionsHash, shown.ApprovedTenants[0]["permissionsHash"]) + }) + + t.Run("still shows a plugin whose installed copy fails verification, with a warning", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + + binary := filepath.Join(e.Dir, "store", "auth0", "checkmate", "1.0.0", pluginTestBin) + require.NoError(t, os.WriteFile(binary, []byte("tampered"), 0o700)) + + require.NoError(t, e.run("plugin", "info", "checkmate")) + + assert.Regexp(t, `INSTALLED\s+1\.0\.0 \(failed verification\)`, e.stdout.String()) + assert.Contains(t, e.stderr.String(), "The installed copy of auth0/checkmate failed verification") + assert.Contains(t, e.stderr.String(), "auth0 plugin install checkmate") + + require.NoError(t, e.run("plugin", "info", "checkmate", "--json")) + + var shown struct{ Installed map[string]any } + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &shown)) + + assert.Equal(t, "1.0.0", shown.Installed["version"]) + assert.Equal(t, false, shown.Installed["verified"]) + assert.NotEmpty(t, shown.Installed["error"]) + assert.NotContains(t, shown.Installed, "permissionsHash") + assert.Empty(t, e.stderr.String()) + }) + + t.Run("fails on a damaged approvals file instead of showing none", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(e.Dir, "approvals.json"), []byte("{"), 0o600)) + + err := e.run("plugin", "info", "checkmate") + + require.Error(t, err) + assert.Empty(t, e.stdout.String()) + }) + + t.Run("orders the versions by number", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.9.0"), e.release("1.2.0"), e.release("1.10.0"))) + + require.NoError(t, e.run("plugin", "info", "checkmate", "--json")) + + var shown struct{ Versions []struct{ Version string } } + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &shown)) + + var versions []string + for _, version := range shown.Versions { + versions = append(versions, version.Version) + } + + assert.Equal(t, []string{"1.10.0", "1.9.0", "1.2.0"}, versions) + }) +} + +func TestInfoPluginCmd_Errors(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("2.0.0"))) + + tests := []struct { + name string + args []string + want string + }{ + {"unknown plugin", []string{"nothing"}, "nothing is not listed in the auth0 registry"}, + {"unknown version", []string{"checkmate@3.0.0"}, "checkmate 3.0.0 is not available in the auth0 registry"}, + {"no argument", nil, "accepts 1 arg(s), received 0"}, + {"other registry", []string{"other/checkmate"}, `"other" is not a registry this CLI knows`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := e.run(append([]string{"plugin", "info"}, test.args...)...) + + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Empty(t, e.stdout.String()) + }) + } + + t.Run("lists the versions that exist", func(t *testing.T) { + var notAvailable *store.NotAvailableError + + require.ErrorAs(t, e.run("plugin", "info", "checkmate@3.0.0"), ¬Available) + assert.Equal(t, []string{"2.0.0", "1.0.0"}, notAvailable.Available) + }) +} + +func TestNewPluginService(t *testing.T) { + setup := func(t *testing.T) string { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv(approvals.PinsEnv, "") + t.Setenv(authModeEnvVar, "") + + return home + } + + writeConfig := func(t *testing.T, home, content string) { + t.Helper() + + dir := filepath.Join(home, ".config", "auth0") + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o600)) + } + + t.Run("works on a machine that never logged in", func(t *testing.T) { + home := setup(t) + + svc, err := newPluginService(&cli{}) + require.NoError(t, err) + + dir := filepath.Join(home, ".config", "auth0", "plugins") + + assert.Equal(t, dir, svc.store.Dir) + assert.Equal(t, buildinfo.Version, svc.store.CLIVersion) + assert.Equal(t, dir, svc.approvals.Dir) + assert.Same(t, svc.approvals, svc.store.Approvals) + assert.Same(t, svc.client, svc.store.Versions) + assert.Equal(t, filepath.Join(dir, "registries", "auth0"), svc.client.Dir) + assert.Equal(t, registry.Registry{Name: "auth0", URL: registry.DefaultRegistryURL}, svc.client.Registry) + assert.NotNil(t, svc.prompter) + }) + + t.Run("takes the mirror from the config file", func(t *testing.T) { + home := setup(t) + writeConfig(t, home, `{"registries":[{"name":"auth0","url":"https://mirror.example.com/index.json"}]}`) + + svc, err := newPluginService(&cli{}) + require.NoError(t, err) + + assert.Equal(t, "https://mirror.example.com/index.json", svc.client.Registry.URL) + }) + + t.Run("does not read the config file in env auth mode", func(t *testing.T) { + home := setup(t) + writeConfig(t, home, `{"registries":[{"name":"other","url":"https://mirror.example.com/index.json"}]}`) + t.Setenv(authModeEnvVar, authModeEnv) + + svc, err := newPluginService(&cli{}) + require.NoError(t, err) + + assert.Equal(t, registry.DefaultRegistryURL, svc.client.Registry.URL) + }) + + t.Run("does not read the approval pins, whatever they are", func(t *testing.T) { + setup(t) + t.Setenv(approvals.PinsEnv, "checkmate") + + svc, err := newPluginService(&cli{}) + require.NoError(t, err) + + assert.Nil(t, svc.approvals.Pins) + }) + + failures := []struct { + name string + setup func(t *testing.T, home string) + want error + }{ + { + name: "a custom registry", + setup: func(t *testing.T, home string) { + writeConfig(t, home, `{"registries":[{"name":"other","url":"https://mirror.example.com/index.json"}]}`) + }, + }, + { + name: "an insecure mirror", + setup: func(t *testing.T, home string) { + writeConfig(t, home, `{"registries":[{"name":"auth0","url":"http://mirror.example.com/index.json"}]}`) + }, + want: registry.ErrInsecureURL, + }, + { + name: "a damaged config file", + setup: func(t *testing.T, home string) { writeConfig(t, home, `{`) }, + }, + } + + for _, test := range failures { + t.Run("fails on "+test.name, func(t *testing.T) { + home := setup(t) + test.setup(t, home) + + svc, err := newPluginService(&cli{}) + + require.Error(t, err) + assert.Nil(t, svc) + + if test.want != nil { + assert.True(t, errors.Is(err, test.want), err.Error()) + } + }) + } +} + +func TestGetPlugins(t *testing.T) { + t.Run("returns the service that was injected", func(t *testing.T) { + injected := &pluginService{} + + svc, err := (&cli{plugins: func() (*pluginService, error) { return injected, nil }}).getPlugins() + + require.NoError(t, err) + assert.Same(t, injected, svc) + }) + + t.Run("builds the service of the config otherwise", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv(authModeEnvVar, "") + + svc, err := (&cli{}).getPlugins() + + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", "auth0", "plugins"), svc.store.Dir) + }) +} + +func TestPluginPrompter(t *testing.T) { + t.Run("returns the lines one after the other and shows each prompt", func(t *testing.T) { + var out strings.Builder + + prompter := newPluginPrompter(strings.NewReader("yes\r\nn\nlast"), &out) + + for _, want := range []string{"yes", "n", "last"} { + got, err := prompter.ReadLine("Approve? ") + require.NoError(t, err) + assert.Equal(t, want, got) + } + + assert.Equal(t, strings.Repeat("Approve? ", 3), out.String()) + }) + + t.Run("returns EOF when the input has ended", func(t *testing.T) { + prompter := newPluginPrompter(strings.NewReader("a\n"), io.Discard) + + _, err := prompter.ReadLine("") + require.NoError(t, err) + + line, err := prompter.ReadLine("") + assert.Equal(t, io.EOF, err) + assert.Empty(t, line) + }) +} + +func TestParsePluginRef(t *testing.T) { + tests := []struct { + name string + value string + want pluginRef + fails string + }{ + {name: "plain", value: "checkmate", want: pluginRef{ID: "auth0/checkmate", Name: "checkmate"}}, + {name: "with the registry", value: "auth0/checkmate", want: pluginRef{ID: "auth0/checkmate", Name: "checkmate"}}, + {name: "with a version", value: "checkmate@1.0.0", want: pluginRef{ID: "auth0/checkmate", Name: "checkmate", Version: "1.0.0"}}, + {name: "with the registry and a version", value: "auth0/checkmate@10.20.30", want: pluginRef{ID: "auth0/checkmate", Name: "checkmate", Version: "10.20.30"}}, + {name: "digits and hyphens", value: "a-1", want: pluginRef{ID: "auth0/a-1", Name: "a-1"}}, + {name: "empty", value: "", fails: "a plugin name is required"}, + {name: "empty name", value: "@1.0.0", fails: "not a valid plugin name"}, + {name: "empty name after the registry", value: "auth0/", fails: "not a valid plugin name"}, + {name: "other registry", value: "other/checkmate", fails: `"other" is not a registry this CLI knows: the only available registry is "auth0"`}, + {name: "uppercase name", value: "Checkmate", fails: "not a valid plugin name"}, + {name: "uppercase registry", value: "Auth0/checkmate", fails: "not a registry this CLI knows"}, + {name: "parent directory", value: "auth0/..", fails: "not a valid plugin name"}, + {name: "parent directory in the middle", value: "auth0/../checkmate", fails: "not a valid plugin name"}, + {name: "extra path part", value: "auth0/checkmate/extra", fails: "not a valid plugin name"}, + {name: "backslash", value: `auth0\checkmate`, fails: "not a valid plugin name"}, + {name: "leading hyphen", value: "-checkmate", fails: "not a valid plugin name"}, + {name: "space", value: "check mate", fails: "not a valid plugin name"}, + {name: "second at sign", value: "checkmate@1.0.0@2.0.0", fails: "not a valid version"}, + {name: "missing version", value: "checkmate@", fails: "not a valid version"}, + {name: "leading v", value: "checkmate@v1.0.0", fails: "not a valid version"}, + {name: "prerelease", value: "checkmate@1.0.0-beta.1", fails: "not a valid version"}, + {name: "build metadata", value: "checkmate@1.0.0+build", fails: "not a valid version"}, + {name: "leading zero", value: "checkmate@01.0.0", fails: "not a valid version"}, + {name: "two parts", value: "checkmate@1.0", fails: "not a valid version"}, + {name: "latest", value: "checkmate@latest", fails: "not a valid version"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := parsePluginRef(test.value) + + if test.fails != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.fails) + assert.Equal(t, pluginRef{}, got) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestCheckPluginNameAvailable(t *testing.T) { + testCLI := &cli{renderer: display.NewRenderer()} + root := buildRootCmd(testCLI) + addSubCommands(root, testCLI) + root.AddCommand(&cobra.Command{Use: "shadow", Aliases: []string{"darkness"}, Hidden: true}) + + tests := []struct { + name string + reserved bool + }{ + {"apps", true}, + {"tenants", true}, + {"orgs", true}, + {"organizations", true}, + {"tf", true}, + {"login", true}, + {"plugin", true}, + {"plugins", true}, + {"shadow", true}, + {"darkness", true}, + {"help", true}, + {"completion", true}, + {"checkmate", false}, + {"apps-plus", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := checkPluginNameAvailable(root, test.name) + + if !test.reserved { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.Equal(t, `auth0 already has a command named "`+test.name+`". Plugin names cannot shadow core commands`, err.Error()) + }) + } +} + +// withScopes gives a version of the test plugin permissions to the given scopes, +// which must be sorted. +func withScopes(scopes ...string) func(*registry.VersionRecord) { + return func(record *registry.VersionRecord) { + record.Permissions = plugins.Permissions{ + Scopes: scopes, + Hosts: []plugins.HostPermission{}, + Files: []plugins.FilePermission{}, + Env: []plugins.EnvPermission{}, + } + } +} + +// newInstallEnv returns an environment whose registry serves version 1.0.0 of the +// test plugin, which asks for scopes, and the record of the version. +func newInstallEnv(t *testing.T) (*pluginEnv, registry.VersionRecord) { + t.Helper() + + e := newPluginEnv(t) + record := e.release("1.0.0", withAsks) + e.publish(e.index(record)) + + return e, record +} + +// assertValidationError fails when the error is not a validation error with the +// reason, which is what the JSON error envelope has as its code and reason. +func assertValidationError(t *testing.T, err error, reason string) { + t.Helper() + + assert.Equal(t, "validation", errorClass(err)) + assert.Equal(t, reason, errorReason(err)) +} + +// jsonObject returns the standard output of the last run, which must be one JSON +// object and nothing else. +func (e *pluginEnv) jsonObject() map[string]any { + e.t.Helper() + + var object map[string]any + require.NoError(e.t, json.Unmarshal(e.stdout.Bytes(), &object), e.stdout.String()) + + return object +} + +func TestInstallPluginCmd(t *testing.T) { + t.Run("names the plugin, shows the permissions, installs and then records the approval", func(t *testing.T) { + e, record := newInstallEnv(t) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "install", "checkmate", "--tenant", pluginTestTenant)) + + out := e.stderr.String() + line := strings.Index(out, "auth0/checkmate 1.0.0 Test plugin auth0/checkmate") + download := strings.Index(out, "Download: ") + screen := strings.Index(out, "This plugin asks to use your Auth0 tenant "+pluginTestTenant) + question := strings.Index(out, "Approve for "+pluginTestTenant+"? [y/N/d] (d shows the exact scopes) ") + installed := strings.Index(out, "Installed auth0/checkmate 1.0.0.") + + assert.True(t, 0 <= line && line < download && download < screen && screen < question && question < installed, out) + assert.Contains(t, out, "("+string(e.platform)+")") + assert.NotContains(t, out, "not approved") + assert.Empty(t, e.stdout.String()) + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + assert.Equal(t, map[string]string{pluginTestTenant: record.PermissionsHash}, e.approvedHashes(pluginTestID)) + }) + + t.Run("asks, and installs only for a clear yes", func(t *testing.T) { + tests := []struct { + name string + input string + installed bool + }{ + {"y", "y\n", true}, + {"yes in capitals", " YES \n", true}, + {"the exact scopes, then yes", "d\ny\n", true}, + {"n", "n\n", false}, + {"nothing typed", "\n", false}, + {"input that ended", "", false}, + {"something else", "sure\n", false}, + {"the exact scopes, then no", "d\nn\n", false}, + {"the exact scopes twice", "d\nd\n", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0", withScopes("read:actions", "read:clients", "read:logs", "read:roles", "read:users")))) + e.answer(test.input) + + require.NoError(t, e.run("plugin", "install", "checkmate", "--tenant", pluginTestTenant, "--json")) + + out := e.stderr.String() + assert.Equal(t, 1, strings.Count(out, "This plugin asks to use your Auth0 tenant"), out) + assert.Equal(t, strings.Contains(test.input, "d"), strings.Contains(out, "Exact scopes"), out) + + if test.installed { + assert.Equal(t, "installed", e.jsonObject()["action"]) + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + assert.Len(t, e.approvedHashes(pluginTestID), 1) + + return + } + + // The view says what happened, as update --all does for a declined plugin. + assert.Equal(t, map[string]any{"id": pluginTestID, "version": "", "previousVersion": "", "action": "declined", "pinned": false, "approvalRequired": false}, e.jsonObject()) + assert.Empty(t, e.installedIDs()) + assert.Empty(t, e.approvedHashes(pluginTestID)) + assert.Contains(t, out, "auth0/checkmate was not approved, so nothing was installed.") + assert.NotContains(t, out, "Installed") + }) + } + }) + + t.Run("installs without an approval where nobody can approve, and says how to approve", func(t *testing.T) { + tests := []struct { + name string + setup func(e *pluginEnv) + args []string + }{ + {"no terminal", func(*pluginEnv) {}, nil}, + {"--no-input at a terminal", func(e *pluginEnv) { e.answer("y\n") }, []string{"--no-input"}}, + {"a tenant in capitals", func(*pluginEnv) {}, []string{"--tenant", "ACME.us.auth0.com"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, _ := newInstallEnv(t) + test.setup(e) + + require.NoError(t, e.run(append([]string{"plugin", "install", "checkmate", "--tenant", pluginTestTenant}, test.args...)...)) + + out := e.stderr.String() + assert.Contains(t, out, "Installed auth0/checkmate 1.0.0.") + assert.Contains(t, out, "Approve it in a terminal with: auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com") + assert.NotContains(t, out, "This plugin asks to use your Auth0 tenant") + assert.NotContains(t, out, "Approve for") + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + } + }) + + t.Run("prints one JSON object and no human text on standard output", func(t *testing.T) { + for _, flag := range []string{"--json", "--json-compact"} { + e, _ := newInstallEnv(t) + + require.NoError(t, e.run("plugin", "install", "checkmate", "--tenant", pluginTestTenant, flag), flag) + + assert.JSONEq(t, `{"id":"auth0/checkmate","version":"1.0.0","previousVersion":"","action":"installed","pinned":false,"approvalRequired":true, + "approveCommand":"auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com"}`, e.stdout.String(), flag) + assert.Contains(t, e.stderr.String(), "Installed auth0/checkmate 1.0.0.", flag) + } + }) + + t.Run("installs in agent mode without an approval, even at a terminal", func(t *testing.T) { + e, _ := newInstallEnv(t) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "install", "checkmate", "--tenant", pluginTestTenant, "--agent-mode", "--no-input=false")) + + assert.Equal(t, true, e.jsonObject()["approvalRequired"]) + assert.Equal(t, "auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com", e.jsonObject()["approveCommand"]) + assert.Empty(t, e.stderr.String()) + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + + t.Run("installs without consent when there is no tenant, and says to log in", func(t *testing.T) { + e, _ := newInstallEnv(t) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "install", "checkmate")) + + out := e.stderr.String() + assert.Contains(t, out, "Installed auth0/checkmate 1.0.0.") + assert.Contains(t, out, "Log in with auth0 login, then approve it in a terminal with: auth0 plugin approve auth0/checkmate --tenant ") + assert.NotContains(t, out, "This plugin asks to use your Auth0 tenant") + assert.NotContains(t, out, "The plugin asks for") + assert.Empty(t, e.approvedHashes(pluginTestID)) + + require.NoError(t, e.run("plugin", "install", "checkmate", "--json")) + assert.Equal(t, true, e.jsonObject()["approvalRequired"]) + assert.NotContains(t, e.jsonObject(), "approveCommand", "there is no tenant to name in a command") + }) + + t.Run("needs no approval for permissions that are approved already", func(t *testing.T) { + tests := []struct { + name string + approve func(t *testing.T, e *pluginEnv, record registry.VersionRecord) + terminal bool + }{ + { + name: "by a recorded approval", + approve: func(t *testing.T, e *pluginEnv, record registry.VersionRecord) { + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, record.PermissionsHash)) + }, + }, + { + name: "by a pin", + approve: func(t *testing.T, _ *pluginEnv, record registry.VersionRecord) { + t.Setenv(approvals.PinsEnv, pluginTestID+"@"+record.PermissionsHash) + }, + }, + { + name: "by a recorded approval, at a terminal where nothing is asked", + approve: func(t *testing.T, e *pluginEnv, record registry.VersionRecord) { + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, record.PermissionsHash)) + }, + terminal: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, record := newInstallEnv(t) + test.approve(t, e, record) + + if test.terminal { + e.answer("") + } + + require.NoError(t, e.run("plugin", "install", "checkmate", "--tenant", pluginTestTenant, "--json")) + + assert.Equal(t, false, e.jsonObject()["approvalRequired"]) + assert.NotContains(t, e.jsonObject(), "approveCommand") + assert.NotContains(t, e.stderr.String(), "approve") + assert.NotContains(t, e.stderr.String(), "This plugin asks to use your Auth0 tenant") + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + }) + } + }) + + t.Run("reports what it did, and pins an exact version", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("2.0.0"))) + + steps := []struct { + ref, version, action, previous, said string + pinned bool + }{ + {"checkmate@1.0.0", "1.0.0", "installed", "", "Installed auth0/checkmate 1.0.0.", true}, + {"checkmate@1.0.0", "1.0.0", "unchanged", "1.0.0", "auth0/checkmate 1.0.0 is already installed.", true}, + {"checkmate@2.0.0", "2.0.0", "installed", "1.0.0", "Switched auth0/checkmate from 1.0.0 to 2.0.0.", true}, + {"checkmate@1.0.0", "1.0.0", "switched", "2.0.0", "Switched auth0/checkmate from 2.0.0 to 1.0.0.", true}, + {"checkmate", "2.0.0", "switched", "1.0.0", "Switched auth0/checkmate from 1.0.0 to 2.0.0.", false}, + } + + for _, step := range steps { + require.NoError(t, e.run("plugin", "install", step.ref, "--json"), step.ref) + + assert.Equal(t, step.version, e.jsonObject()["version"], step.ref) + assert.Equal(t, step.action, e.jsonObject()["action"], step.ref) + assert.Equal(t, step.previous, e.jsonObject()["previousVersion"], step.ref) + assert.Equal(t, step.pinned, e.jsonObject()["pinned"], step.ref) + assert.Contains(t, e.stderr.String(), step.said, step.ref) + assert.Equal(t, step.pinned, strings.Contains(e.stderr.String(), "auth0/checkmate is pinned to "+step.version), step.ref) + } + }) +} + +// TestInstallPluginCmd_Refusals checks that an install the store refuses installs +// and approves nothing, and shows no consent screen. The store tests have the +// other refusals and their messages. +func TestInstallPluginCmd_Refusals(t *testing.T) { + t.Run("a revoked version", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("2.0.0")).Revoke(pluginTestID, "2.0.0", "Leaked a token.")) + e.answer("y\n") + + err := e.run("plugin", "install", "checkmate@2.0.0", "--tenant", pluginTestTenant) + + var revoked *store.RevokedError + + require.ErrorAs(t, err, &revoked) + assert.Contains(t, err.Error(), "checkmate 2.0.0 was revoked: Leaked a token. Install a newer version with: auth0 plugin update checkmate") + assert.Empty(t, e.installedIDs()) + assert.Empty(t, e.approvedHashes(pluginTestID)) + assert.NotContains(t, e.stderr.String(), "This plugin asks to use your Auth0 tenant") + assert.Empty(t, e.stdout.String()) + }) + + t.Run("a plugin named like a core command, without touching the registry", func(t *testing.T) { + for _, name := range []string{"apps", "plugins", "help"} { + e := newPluginEnv(t) + + err := e.run("plugin", "install", name) + + require.Error(t, err, name) + assertValidationError(t, err, "plugin_name_reserved") + assert.Equal(t, `auth0 already has a command named "`+name+`". Plugin names cannot shadow core commands`, err.Error()) + assert.Zero(t, e.server.Requests("/timestamp.json"), name) + assert.Empty(t, e.installedIDs()) + } + }) + + t.Run("a reference that is not one", func(t *testing.T) { + for _, ref := range []string{"other/checkmate", "auth0/../x", "checkmate@latest", ""} { + e, _ := newInstallEnv(t) + + var invalid validationError + + require.ErrorAs(t, e.run("plugin", "install", ref), &invalid, ref) + assert.Zero(t, e.server.Requests("/timestamp.json"), ref) + assert.Empty(t, e.installedIDs()) + } + }) +} + +func TestInstallPluginCmd_WarnsAboutScopes(t *testing.T) { + declared := withScopes("read:clients", "read:hooks", "read:signing_keys") + session := func(extra ...string) config.Tenant { + return config.Tenant{Domain: pluginTestTenant, Scopes: append(slices.Clone(auth.RequiredScopes), extra...)} + } + + tests := []struct { + name string + setup func(t *testing.T, e *pluginEnv) + args []string + want string + terminal bool + }{ + { + name: "a login that lacks scopes is told to log in again, keeping the scopes it has", + setup: func(_ *testing.T, e *pluginEnv) { e.loginAs(session("read:signing_keys")) }, + want: `have: read:hooks. Its calls that need them are denied until you log in again with: auth0 login --domain acme.us.auth0.com --scopes "read:hooks,read:signing_keys"`, + terminal: true, + }, + { + name: "a login that lacks scopes is told so without a terminal as well", + setup: func(_ *testing.T, e *pluginEnv) { e.loginAs(session()) }, + want: `auth0 login --domain acme.us.auth0.com --scopes "read:hooks,read:signing_keys"`, + }, + { + name: "a tenant typed in capitals is the tenant of the login", + setup: func(_ *testing.T, e *pluginEnv) { e.loginAs(session()) }, + args: []string{"--tenant", "ACME.us.auth0.com"}, + want: `auth0 login --domain acme.us.auth0.com --scopes "read:hooks,read:signing_keys"`, + }, + { + name: "a login that has every scope is told nothing", + setup: func(_ *testing.T, e *pluginEnv) { e.loginAs(session("read:hooks", "read:signing_keys")) }, + terminal: true, + }, + { + name: "client credentials are told to grant the scopes", + setup: func(_ *testing.T, e *pluginEnv) { + e.loginAs(config.Tenant{Domain: pluginTestTenant, ClientID: "client"}) + }, + want: "The plugin asks for the scopes read:clients, read:hooks, read:signing_keys. The application you are logged in with needs a grant for them", + terminal: true, + }, + { + name: "env auth mode says the scopes could not be checked", + setup: func(t *testing.T, _ *pluginEnv) { + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, pluginTestTenant) + }, + args: []string{}, + want: "The scopes of your session could not be checked", + terminal: true, + }, + { + name: "a tenant that is not in the config says the scopes could not be checked", + setup: func(_ *testing.T, e *pluginEnv) { e.loginAs(session()) }, + args: []string{"--tenant", "other.us.auth0.com"}, + want: "The scopes of your session could not be checked", + terminal: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0", declared))) + test.setup(t, e) + + args := []string{"plugin", "install", "checkmate"} + if test.args == nil { + args = append(args, "--tenant", pluginTestTenant) + } + + args = append(args, test.args...) + + if test.terminal { + e.answer("y\n") + } + + require.NoError(t, e.run(args...)) + + out := e.stderr.String() + assert.Equal(t, []string{pluginTestID}, e.installedIDs(), "a warning never stops the install") + + if test.want == "" { + assert.NotContains(t, out, "The plugin asks for") + return + } + + assert.Contains(t, out, test.want) + + if test.terminal { + question, warning, done := strings.Index(out, "Approve for"), strings.Index(out, "The plugin asks for"), strings.Index(out, "Installed") + assert.True(t, 0 <= question && question < warning && warning < done, out) + } + }) + } + + t.Run("says nothing for a plugin that asks for no scopes", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0", withScopes([]string{}...)))) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "install", "checkmate", "--tenant", "other.us.auth0.com")) + + assert.NotContains(t, e.stderr.String(), "The plugin asks for") + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + }) + + t.Run("says nothing in agent mode", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0", declared))) + e.loginAs(session()) + + require.NoError(t, e.run("plugin", "install", "checkmate", "--tenant", pluginTestTenant, "--agent-mode")) + + assert.Empty(t, e.stderr.String()) + }) +} + +func TestCheckPluginsEnabled(t *testing.T) { + const ( + off = "plugins are turned off on this machine by AUTH0_CLI_PLUGINS_DISABLED=true, so auth0 plugin install is not available" + notABool = `AUTH0_CLI_PLUGINS_DISABLED is set to %q, which is not true or false, so plugins are turned off on this machine` + ) + + tests := []struct { + value string + + // Want is empty when plugins are on. Invalid is true for a value that is not + // a boolean, which is told how to be set right. + want string + invalid bool + }{ + {"true", off, false}, + {"TRUE", off, false}, + {" True ", off, false}, + {"1", off, false}, + {"yes", fmt.Sprintf(notABool, "yes"), true}, + {"ttrue", fmt.Sprintf(notABool, "ttrue"), true}, + {"on", fmt.Sprintf(notABool, "on"), true}, + {"false", "", false}, + {"0", "", false}, + {" ", "", false}, + {"", "", false}, + } + + root := &cobra.Command{Use: "auth0"} + group := &cobra.Command{Use: "plugin"} + install := &cobra.Command{Use: "install"} + root.AddCommand(group) + group.AddCommand(install) + + for _, test := range tests { + t.Run(strconv.Quote(test.value), func(t *testing.T) { + t.Setenv(pluginsDisabledEnv, test.value) + + err := checkPluginsEnabled(install) + + if test.want == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Contains(t, err.Error(), "Searching, listing and uninstalling still work") + assert.Equal(t, test.invalid, strings.Contains(err.Error(), "Set it to true to keep plugins off, or unset it to allow them")) + }) + } +} + +// TestPluginsDisabled checks that every command that brings a plugin onto the +// machine or approves one refuses when plugins are turned off, before it asks the +// registry for anything, and that they work when plugins are not turned off. +func TestPluginsDisabled(t *testing.T) { + disabled := [][]string{ + {"plugin", "install", "checkmate"}, + {"plugin", "update", "checkmate"}, + {"plugin", "update", "--all"}, + {"plugin", "approve", "checkmate", "--tenant", pluginTestTenant}, + } + + t.Run("true", func(t *testing.T) { + e, _ := newInstallEnv(t) + t.Setenv(pluginsDisabledEnv, "true") + + e.install("1.0.0") + requests := e.server.Requests("/timestamp.json") + e.answer("y\n") + + for _, args := range disabled { + err := e.run(args...) + + require.Error(t, err, args) + assert.Contains(t, err.Error(), "AUTH0_CLI_PLUGINS_DISABLED=true") + assert.Contains(t, err.Error(), "auth0 plugin "+args[1]) + } + + assert.Equal(t, requests, e.server.Requests("/timestamp.json"), "nothing is fetched") + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + + t.Run("false", func(t *testing.T) { + e, _ := newInstallEnv(t) + t.Setenv(pluginsDisabledEnv, "false") + + require.NoError(t, e.run("plugin", "install", "checkmate")) + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + }) +} + +// TestPluginCommands_ReadingIsNotBlocked checks that a machine where plugins are +// turned off, or where the approval pins are wrong, can still search, list and +// inspect plugins. Uninstalling is checked with the registry down, in +// TestPluginCommands_WorkWithoutTheRegistry. +func TestPluginCommands_ReadingIsNotBlocked(t *testing.T) { + for name, value := range map[string]string{pluginsDisabledEnv: "yes", approvals.PinsEnv: "checkmate"} { + t.Run(name, func(t *testing.T) { + e, _ := newInstallEnv(t) + e.install("1.0.0") + t.Setenv(name, value) + + for _, args := range [][]string{{"plugin", "search"}, {"plugin", "list"}, {"plugin", "info", "checkmate"}} { + require.NoError(t, e.run(args...), args) + } + }) + } +} + +// TestPluginApprovalPins_FailTheCommandsThatUseThem checks that a bad value of +// the approval pins fails install, update and approve before they do anything. +func TestPluginApprovalPins_FailTheCommandsThatUseThem(t *testing.T) { + tests := []struct { + args []string + installed bool + }{ + {[]string{"plugin", "install", "checkmate"}, false}, + {[]string{"plugin", "install", "checkmate@1.0.0"}, true}, + {[]string{"plugin", "update", "checkmate"}, true}, + {[]string{"plugin", "update", "--all"}, true}, + {[]string{"plugin", "approve", "checkmate", "--tenant", pluginTestTenant}, true}, + } + + for _, test := range tests { + e, _ := newInstallEnv(t) + + active := "" + if test.installed { + e.install("1.0.0") + + active = "1.0.0" + } + + t.Setenv(approvals.PinsEnv, "checkmate") + e.answer("y\n") + requests := e.server.Requests("/timestamp.json") + + err := e.run(test.args...) + + var invalid validationError + + require.ErrorAs(t, err, &invalid, test.args) + require.ErrorIs(t, err, approvals.ErrBadPins, test.args) + assert.Contains(t, err.Error(), approvals.PinsEnv) + assert.Equal(t, requests, e.server.Requests("/timestamp.json"), test.args) + assert.Equal(t, active, e.activeVersion(pluginTestID), test.args) + assert.Empty(t, e.approvedHashes(pluginTestID), test.args) + } +} + +func TestPluginTenant(t *testing.T) { + tests := []struct { + name string + flag string + defaultTenant string + env map[string]string + want string + }{ + {name: "none"}, + {name: "the flag", flag: " acme.us.auth0.com ", want: "acme.us.auth0.com"}, + {name: "the flag in capitals", flag: " ACME.us.Auth0.com ", want: "acme.us.auth0.com"}, + {name: "the default tenant in capitals", defaultTenant: "Default.us.auth0.com", want: "default.us.auth0.com"}, + {name: "the domain in capitals in env auth mode", env: map[string]string{authModeEnvVar: authModeEnv, envDomain: "ENV.us.auth0.com"}, want: "env.us.auth0.com"}, + {name: "the default tenant", defaultTenant: "default.us.auth0.com", want: "default.us.auth0.com"}, + {name: "the flag wins over the default tenant", flag: "flag.us.auth0.com", defaultTenant: "default.us.auth0.com", want: "flag.us.auth0.com"}, + {name: "the domain in env auth mode", env: map[string]string{authModeEnvVar: authModeEnv, envDomain: " env.us.auth0.com "}, want: "env.us.auth0.com"}, + {name: "the flag wins over the domain in env auth mode", flag: "flag.us.auth0.com", env: map[string]string{authModeEnvVar: authModeEnv, envDomain: "env.us.auth0.com"}, want: "flag.us.auth0.com"}, + {name: "the domain is ignored outside env auth mode", defaultTenant: "default.us.auth0.com", env: map[string]string{envDomain: "env.us.auth0.com"}, want: "default.us.auth0.com"}, + {name: "env auth mode without a domain", env: map[string]string{authModeEnvVar: authModeEnv}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv(authModeEnvVar, "") + t.Setenv(envDomain, "") + + for name, value := range test.env { + t.Setenv(name, value) + } + + c := &cli{tenant: test.flag} + c.Config.DefaultTenant = test.defaultTenant + + assert.Equal(t, test.want, pluginTenant(c)) + }) + } +} + +// newUpdateEnv returns an environment that has version 1.0.0 of the test plugin +// installed, not pinned, whose registry serves version 2.0.0 as well, which the +// client sees after a day. The mutators change the two versions. +func newUpdateEnv(t *testing.T, old, newer func(*registry.VersionRecord)) (*pluginEnv, registry.VersionRecord, registry.VersionRecord) { + t.Helper() + + e := newPluginEnv(t) + before, after := e.release("1.0.0", old), e.release("2.0.0", newer) + e.publish(e.index(before)) + e.installPlugin(pluginTestID, "") + e.republish(e.index(before, after)) + + return e, before, after +} + +func TestUpdatePluginCmd(t *testing.T) { + newEnv := newUpdateEnv + + t.Run("updates without a word about permissions that did not change", func(t *testing.T) { + e, _, _ := newEnv(t, withAsks, withAsks) + + require.NoError(t, e.run("plugin", "update", "checkmate", "--tenant", pluginTestTenant, "--json")) + + assert.JSONEq(t, `{"id":"auth0/checkmate","version":"2.0.0","previousVersion":"1.0.0","action":"installed","pinned":false,"approvalRequired":true, + "approveCommand":"auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com"}`, e.stdout.String()) + assert.Contains(t, e.stderr.String(), "Updated auth0/checkmate from 1.0.0 to 2.0.0.") + assert.NotContains(t, e.stderr.String(), "Permissions") + assert.NotContains(t, e.stderr.String(), "different permissions") + assert.Equal(t, "2.0.0", e.activeVersion(pluginTestID)) + }) + + t.Run("clears the pin of an exact install", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("2.0.0"))) + e.install("1.0.0") + + require.NoError(t, e.run("plugin", "update", "checkmate", "--json")) + + assert.Equal(t, false, e.jsonObject()["pinned"]) + assert.Equal(t, "2.0.0", e.jsonObject()["version"]) + }) + + t.Run("needs no approval when the tenant approved the new permissions already", func(t *testing.T) { + e, _, after := newEnv(t, withAsks, withAsks) + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, after.PermissionsHash)) + + require.NoError(t, e.run("plugin", "update", "checkmate", "--tenant", pluginTestTenant, "--json")) + + assert.Equal(t, false, e.jsonObject()["approvalRequired"]) + assert.NotContains(t, e.stderr.String(), "approve") + }) + + t.Run("shows what changed when the tenant approved the installed version, and everything when not", func(t *testing.T) { + tests := []struct { + name string + approvedOld bool + want string + notWant string + }{ + {"the installed version was approved", true, "auth0/checkmate 2.0.0 asks for different permissions on tenant acme.us.auth0.com\n Added can read logs", "This plugin asks to use your Auth0 tenant"}, + {"the installed version was not approved", false, "This plugin asks to use your Auth0 tenant acme.us.auth0.com", "asks for different permissions"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, before, after := newEnv(t, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + e.answer("y\n") + + if test.approvedOld { + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, before.PermissionsHash)) + } + + require.NoError(t, e.run("plugin", "update", "checkmate", "--tenant", pluginTestTenant)) + + out := e.stderr.String() + assert.Contains(t, out, "auth0/checkmate 2.0.0 Test plugin auth0/checkmate") + // The test archives are under a kilobyte, which prints as bytes. + require.Less(t, after.Artifacts[0].Size, int64(1000)) + assert.Contains(t, out, fmt.Sprintf("Download: %d B (%s)", after.Artifacts[0].Size, e.platform)) + assert.NotEqual(t, before.Artifacts[0].Size, after.Artifacts[0].Size) + assert.Contains(t, out, test.want) + assert.NotContains(t, out, test.notWant) + assert.Contains(t, out, "Updated auth0/checkmate from 1.0.0 to 2.0.0.") + assert.Equal(t, "2.0.0", e.activeVersion(pluginTestID)) + assert.Equal(t, map[string]string{pluginTestTenant: after.PermissionsHash}, e.approvedHashes(pluginTestID)) + }) + } + }) + + t.Run("keeps the installed version when the person says no", func(t *testing.T) { + for _, input := range []string{"n\n", ""} { + e, before, _ := newEnv(t, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, before.PermissionsHash)) + e.answer(input) + + require.NoError(t, e.run("plugin", "update", "checkmate", "--tenant", pluginTestTenant, "--json")) + + // The view says what happened, as update --all does for a declined plugin. + assert.Equal(t, map[string]any{"id": pluginTestID, "version": "1.0.0", "previousVersion": "", "action": "declined", "pinned": false, "approvalRequired": false}, e.jsonObject()) + assert.Contains(t, e.stderr.String(), "auth0/checkmate was not updated, because the new permissions were not approved. 1.0.0 stays active.") + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + assert.Equal(t, map[string]string{pluginTestTenant: before.PermissionsHash}, e.approvedHashes(pluginTestID)) + } + }) + + t.Run("keeps the installed version, says why and fails where nobody can approve", func(t *testing.T) { + tests := []struct { + name string + setup func(e *pluginEnv) + args []string + want string + }{ + {"no terminal", func(*pluginEnv) {}, nil, "there is no terminal to ask in"}, + {"--no-input at a terminal", func(e *pluginEnv) { e.answer("y\n") }, []string{"--no-input"}, "there is no terminal to ask in"}, + {"agent mode at a terminal", func(e *pluginEnv) { e.answer("y\n") }, []string{"--agent-mode", "--no-input=false"}, "an agent cannot approve plugins"}, + {"no tenant", func(e *pluginEnv) { e.answer("y\n") }, []string{"--tenant", ""}, "there is no tenant to approve them for"}, + } + + for _, test := range tests { + for _, target := range [][]string{{"checkmate"}, {"--all"}} { + t.Run(test.name+" "+strings.Join(target, ""), func(t *testing.T) { + e, before, _ := newEnv(t, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, before.PermissionsHash)) + test.setup(e) + + args := append([]string{"plugin", "update"}, target...) + if !slices.Contains(test.args, "--tenant") { + args = append(args, "--tenant", pluginTestTenant) + } + + err := e.run(append(args, test.args...)...) + + require.Error(t, err) + assert.Contains(t, e.stdout.String()+e.stderr.String()+err.Error(), test.want) + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + assert.Equal(t, map[string]string{pluginTestTenant: before.PermissionsHash}, e.approvedHashes(pluginTestID)) + + if target[0] == "checkmate" { + assert.Contains(t, err.Error(), "checkmate 2.0.0 asks for permissions that are not approved, and "+test.want+". checkmate was not updated, and 1.0.0 stays active. Run this in a terminal to review them: auth0 plugin update checkmate") + } else { + assert.Contains(t, err.Error(), "1 plugin could not be updated: checkmate") + } + }) + } + } + }) + + t.Run("is a no-op for a plugin that is up to date", func(t *testing.T) { + e, _, _ := newEnv(t, withAsks, withAsks) + + require.NoError(t, e.run("plugin", "update", "checkmate")) + require.NoError(t, e.run("plugin", "update", "checkmate", "--json")) + + assert.Equal(t, "unchanged", e.jsonObject()["action"]) + assert.Contains(t, e.stderr.String(), "auth0/checkmate 2.0.0 is up to date.") + assert.NotContains(t, e.stderr.String(), "already installed") + }) + + t.Run("says how to install a plugin when none is installed", func(t *testing.T) { + e, _ := newInstallEnv(t) + + err := e.run("plugin", "update", "checkmate") + + require.EqualError(t, err, "the plugin auth0/checkmate is not installed, and no plugin is. Find one with: auth0 plugin search") + assertValidationError(t, err, "plugin_not_installed") + }) + + t.Run("asks for exactly one target", func(t *testing.T) { + e, _, _ := newEnv(t, withAsks, withAsks) + + tests := []struct { + args []string + want string + }{ + {[]string{"plugin", "update"}, "give the name of one plugin, or use --all"}, + {[]string{"plugin", "update", "checkmate", "--all"}, "give the name of one plugin, or use --all"}, + {[]string{"plugin", "update", "checkmate@2.0.0"}, "update takes the name of a plugin, without a version. To install an exact version, run: auth0 plugin install checkmate@2.0.0"}, + {[]string{"plugin", "update", "other/checkmate"}, "is not a registry this CLI knows"}, + {[]string{"plugin", "update", "nothing"}, "the plugin auth0/nothing is not installed. Installed plugins: auth0/checkmate"}, + } + + for _, test := range tests { + err := e.run(test.args...) + + require.Error(t, err, test.args) + assert.Contains(t, err.Error(), test.want) + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + } + }) +} + +// pluginOtherID is a second plugin, for the tests that work on more than one. +const pluginOtherID = "auth0/other" + +// newPairEnv returns an environment that has auth0/checkmate and auth0/other +// installed at 1.0.0, both with the given permissions, and whose registry serves +// version 2.0.0 of both with other permissions, which the client sees after a +// day. The test plugin is pinned to 1.0.0 when pinned is true, and the other one +// never is. The records returned are the 2.0.0 ones. +func newPairEnv(t *testing.T, pinned bool, old, newer func(*registry.VersionRecord)) (*pluginEnv, registry.VersionRecord, registry.VersionRecord) { + t.Helper() + + e := newPluginEnv(t) + first1, first2 := e.release("1.0.0", old), e.release("2.0.0", newer) + other1, other2 := e.releaseAs(pluginOtherID, e.platform, "1.0.0", old), e.releaseAs(pluginOtherID, e.platform, "2.0.0", newer) + + e.publish(e.index(first1).AddPlugin(registrytest.NewPlugin(pluginOtherID, other1))) + + version := "" + if pinned { + version = "1.0.0" + } + + e.installPlugin(pluginTestID, version) + e.installPlugin(pluginOtherID, "") + e.republish(e.index(first1, first2).AddPlugin(registrytest.NewPlugin(pluginOtherID, other1, other2))) + + return e, first2, other2 +} + +func TestUpdatePluginCmd_All(t *testing.T) { + const otherID = pluginOtherID + + // The environment has auth0/checkmate pinned to 1.0.0, and auth0/other not + // pinned at 1.0.0. The registry serves version 2.0.0 of both after a day. + newEnv := func(t *testing.T, old, newer func(*registry.VersionRecord)) *pluginEnv { + t.Helper() + + e, _, _ := newPairEnv(t, true, old, newer) + + return e + } + + t.Run("skips the pinned plugins, says which version is available, and updates the others", func(t *testing.T) { + e := newEnv(t, withAsks, withAsks) + + require.NoError(t, e.run("plugin", "update", "--all", "--tenant", pluginTestTenant)) + + out := e.stderr.String() + assert.Contains(t, out, "auth0/checkmate is pinned to 1.0.0 and was skipped. Version 2.0.0 is available: auth0 plugin update checkmate") + assert.Contains(t, out, "Updated auth0/other from 1.0.0 to 2.0.0.") + assert.Empty(t, e.stdout.String()) + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + assert.Equal(t, "2.0.0", e.activeVersion(otherID)) + }) + + t.Run("prints one JSON entry per plugin", func(t *testing.T) { + e := newEnv(t, withAsks, withAsks) + + require.NoError(t, e.run("plugin", "update", "--all", "--tenant", pluginTestTenant, "--json-compact")) + + assert.JSONEq(t, `[ + {"id":"auth0/checkmate","version":"1.0.0","previousVersion":"","action":"skipped","pinned":true,"approvalRequired":false,"available":"2.0.0"}, + {"id":"auth0/other","version":"2.0.0","previousVersion":"1.0.0","action":"installed","pinned":false,"approvalRequired":true, + "approveCommand":"auth0 plugin approve auth0/other --tenant acme.us.auth0.com"} + ]`, e.stdout.String()) + }) + + t.Run("says so when a pinned plugin is on the newest version", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.install("1.0.0") + + require.NoError(t, e.run("plugin", "update", "--all", "--json")) + + assert.Contains(t, e.stderr.String(), "auth0/checkmate is pinned to 1.0.0, which is the newest version.") + assert.JSONEq(t, `[{"id":"auth0/checkmate","version":"1.0.0","previousVersion":"","action":"skipped","pinned":true,"approvalRequired":false}]`, e.stdout.String()) + }) + + t.Run("prints a list, not an object, for one plugin", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.installPlugin(pluginTestID, "") + + require.NoError(t, e.run("plugin", "update", "--all", "--json")) + + var entries []map[string]any + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &entries)) + assert.Len(t, entries, 1) + assert.Equal(t, "unchanged", entries[0]["action"]) + assert.Contains(t, e.stderr.String(), "auth0/checkmate 1.0.0 is up to date.") + }) + + t.Run("says so when nothing is installed", func(t *testing.T) { + e, _ := newInstallEnv(t) + + require.NoError(t, e.run("plugin", "update", "--all")) + assert.Contains(t, e.stderr.String(), "No installed plugins available") + + require.NoError(t, e.run("plugin", "update", "--all", "--json")) + assert.JSONEq(t, `[]`, e.stdout.String()) + }) + + t.Run("does not fail for a plugin it skipped or a person who declined", func(t *testing.T) { + e := newEnv(t, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + e.answer("n\n") + + require.NoError(t, e.run("plugin", "update", "--all", "--tenant", pluginTestTenant, "--json")) + + var entries []map[string]any + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &entries)) + require.Len(t, entries, 2) + assert.Equal(t, "skipped", entries[0]["action"]) + assert.Equal(t, "declined", entries[1]["action"]) + assert.Equal(t, "1.0.0", e.activeVersion(otherID)) + assert.Empty(t, e.approvedHashes(otherID)) + }) + + t.Run("records the approvals of the plugins that were updated, and fails only when an update failed", func(t *testing.T) { + tests := []struct { + name string + + // FailOther makes the download of the second plugin fail, after the person + // approved both. + failOther bool + }{ + {"every plugin was updated", false}, + {"the second download fails", true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, first, other := newPairEnv(t, false, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + e.answer("y\ny\n") + + if test.failOther { + e.server.Fail(pluginArtifactPath("other", "2.0.0"), 503) + } + + err := e.run("plugin", "update", "--all", "--tenant", pluginTestTenant, "--json") + + var entries []map[string]any + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &entries)) + require.Len(t, entries, 2) + + assert.Equal(t, 2, strings.Count(e.stderr.String(), "Approve for")) + assert.Equal(t, map[string]string{pluginTestTenant: first.PermissionsHash}, e.approvedHashes(pluginTestID)) + assert.Equal(t, "2.0.0", e.activeVersion(pluginTestID)) + + if test.failOther { + require.EqualError(t, err, "1 plugin could not be updated: other") + assert.Equal(t, "failed", entries[1]["action"]) + assert.Contains(t, entries[1]["error"], "could not be downloaded") + assert.Empty(t, e.approvedHashes(otherID)) + assert.Equal(t, "1.0.0", e.activeVersion(otherID)) + + return + } + + require.NoError(t, err) + assert.Equal(t, "installed", entries[1]["action"]) + assert.Equal(t, map[string]string{pluginTestTenant: other.PermissionsHash}, e.approvedHashes(otherID)) + }) + } + }) +} + +func TestUninstallPluginCmd(t *testing.T) { + // The environment has the test plugin installed, approved for a tenant. + newEnv := func(t *testing.T) *pluginEnv { + t.Helper() + + e, record := newInstallEnv(t) + e.install("1.0.0") + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, record.PermissionsHash)) + + return e + } + + t.Run("asks, removes every trace and says how much space it freed", func(t *testing.T) { + e := newEnv(t) + e.Terminal, e.Confirm = true, true + + sizes, err := e.svc.store.Sizes() + require.NoError(t, err) + + size := sizes[pluginTestID] + + require.NoError(t, e.run("plugin", "uninstall", "checkmate")) + + assert.Equal(t, []string{"Are you sure you want to uninstall auth0/checkmate? This removes every version and its approvals."}, e.Confirmations) + // The size is under a kilobyte, which prints as bytes. + require.Less(t, size, int64(1000)) + assert.Contains(t, e.stderr.String(), fmt.Sprintf("Uninstalled auth0/checkmate and freed %d B.", size)) + assert.Empty(t, e.installedIDs()) + assert.Empty(t, e.approvedHashes(pluginTestID)) + assert.NoDirExists(t, filepath.Join(e.Dir, "store", "auth0", "checkmate")) + assert.NotContains(t, readFile(t, filepath.Join(e.Dir, "plugins.lock.json")), "checkmate") + }) + + t.Run("does nothing when the person does not confirm", func(t *testing.T) { + e := newEnv(t) + e.Terminal = true + + require.NoError(t, e.run("plugin", "uninstall", "checkmate")) + + assert.Len(t, e.Confirmations, 1) + assert.Contains(t, e.stderr.String(), "auth0/checkmate was not uninstalled.") + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + assert.Len(t, e.approvedHashes(pluginTestID), 1) + }) + + t.Run("asks nothing with --force, at a terminal, with the alias, and where nobody can answer", func(t *testing.T) { + tests := []struct { + name string + args []string + terminal bool + }{ + {"--force", []string{"plugin", "uninstall", "checkmate", "--force"}, true}, + {"the remove alias", []string{"plugin", "remove", "checkmate", "--force"}, false}, + {"--force in agent mode", []string{"plugin", "uninstall", "checkmate", "--force", "--agent-mode"}, false}, + {"no terminal", []string{"plugin", "uninstall", "checkmate"}, false}, + {"--no-input at a terminal", []string{"plugin", "uninstall", "checkmate", "--no-input"}, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := newEnv(t) + e.Terminal = test.terminal + + require.NoError(t, e.run(test.args...)) + + assert.Empty(t, e.Confirmations) + assert.Empty(t, e.installedIDs()) + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + } + }) + + t.Run("refuses in agent mode without --force", func(t *testing.T) { + e := newEnv(t) + e.Terminal, e.Confirm = true, true + + err := e.run("plugin", "uninstall", "checkmate", "--agent-mode") + + require.ErrorIs(t, err, errDestructiveNoConfirm) + assert.Empty(t, e.Confirmations) + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + }) + + t.Run("removes a damaged approvals file with the plugin", func(t *testing.T) { + e := newEnv(t) + require.NoError(t, os.WriteFile(filepath.Join(e.Dir, "approvals.json"), []byte("{"), 0o600)) + + require.NoError(t, e.run("plugin", "uninstall", "checkmate", "--force")) + + assert.Empty(t, e.installedIDs()) + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + + t.Run("lists the installed plugins when the plugin is not installed", func(t *testing.T) { + e := newEnv(t) + e.republish(e.index(e.release("1.0.0")).AddPlugin(registrytest.NewPlugin("auth0/other", e.releaseAs("auth0/other", e.platform, "1.0.0")))) + e.installPlugin("auth0/other", "") + e.Terminal, e.Confirm = true, true + + err := e.run("plugin", "uninstall", "nothing") + + require.EqualError(t, err, "the plugin auth0/nothing is not installed. Installed plugins: auth0/checkmate, auth0/other") + assertValidationError(t, err, "plugin_not_installed") + assert.Empty(t, e.Confirmations) + }) + + t.Run("says so when no plugin is installed", func(t *testing.T) { + e := newPluginEnv(t) + + err := e.run("plugin", "uninstall", "checkmate") + + require.EqualError(t, err, "the plugin auth0/checkmate is not installed, and no plugin is. Find one with: auth0 plugin search") + assertValidationError(t, err, "plugin_not_installed") + }) + + t.Run("takes a name, not a version or another registry", func(t *testing.T) { + e := newEnv(t) + + require.ErrorContains(t, e.run("plugin", "uninstall", "checkmate@1.0.0", "--force"), "uninstall takes the name of a plugin, without a version") + require.ErrorContains(t, e.run("plugin", "uninstall", "other/checkmate", "--force"), "is not a registry this CLI knows") + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + }) +} + +// readFile returns the content of a file. +func readFile(t *testing.T, path string) string { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err) + + return string(content) +} + +func TestApprovePluginCmd(t *testing.T) { + // The environment has the test plugin installed without an approval. + newEnv := func(t *testing.T) (*pluginEnv, registry.VersionRecord) { + t.Helper() + + e, record := newInstallEnv(t) + e.install("1.0.0") + + return e, record + } + + t.Run("names the plugin, shows the permissions and records the approval of a person", func(t *testing.T) { + e, record := newEnv(t) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant)) + + out := e.stderr.String() + line := strings.Index(out, "auth0/checkmate 1.0.0 Test plugin auth0/checkmate") + screen := strings.Index(out, "This plugin asks to use your Auth0 tenant "+pluginTestTenant) + question := strings.Index(out, "Approve for "+pluginTestTenant+"? [y/N/d] (d shows the exact scopes) ") + done := strings.Index(out, "Approved auth0/checkmate 1.0.0 for "+pluginTestTenant+".") + + assert.True(t, 0 <= line && line < screen && screen < question && question < done, out) + assert.Equal(t, map[string]string{pluginTestTenant: record.PermissionsHash}, e.approvedHashes(pluginTestID)) + assert.Empty(t, e.stdout.String()) + }) + + t.Run("takes the tenant from the default tenant, and approves one tenant at a time", func(t *testing.T) { + e, record := newEnv(t) + e.cli.Config.DefaultTenant = "default.us.auth0.com" + e.answer("y\ny\n") + + require.NoError(t, e.run("plugin", "approve", "auth0/checkmate")) + require.NoError(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant)) + + assert.Equal(t, map[string]string{"default.us.auth0.com": record.PermissionsHash, pluginTestTenant: record.PermissionsHash}, e.approvedHashes(pluginTestID)) + }) + + t.Run("records nothing when the person says no", func(t *testing.T) { + for _, input := range []string{"n\n", "", "\n", "d\nn\n"} { + e, _ := newEnv(t) + e.answer(input) + + require.NoError(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant), input) + + assert.Contains(t, e.stderr.String(), "auth0/checkmate was not approved for "+pluginTestTenant+".", input) + assert.Empty(t, e.approvedHashes(pluginTestID), input) + } + }) + + t.Run("works with approval pins for other plugins and writes none to disk", func(t *testing.T) { + e, record := newEnv(t) + t.Setenv(approvals.PinsEnv, "auth0/other@sha256:"+strings.Repeat("a", 64)) + e.answer("y\n") + + require.NoError(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant)) + + assert.Equal(t, map[string]string{pluginTestTenant: record.PermissionsHash}, e.approvedHashes(pluginTestID)) + assert.NotContains(t, readFile(t, filepath.Join(e.Dir, "approvals.json")), strings.Repeat("a", 64)) + }) + + t.Run("is a person's decision: never in agent mode and never without a terminal", func(t *testing.T) { + tests := []struct { + name string + args []string + terminal bool + }{ + {"agent mode at a terminal", []string{"--agent-mode", "--no-input=false"}, true}, + {"agent mode", []string{"--agent-mode"}, false}, + {"--no-input at a terminal", []string{"--no-input"}, true}, + {"no terminal", nil, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, _ := newEnv(t) + e.answer("y\n") + e.Terminal = test.terminal + + err := e.run(append([]string{"plugin", "approve", "checkmate", "--tenant", pluginTestTenant}, test.args...)...) + + require.EqualError(t, err, "only a person can approve a plugin. Run this in a terminal: auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com") + assert.NotContains(t, e.stderr.String(), "This plugin asks to use your Auth0 tenant") + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + } + }) + + t.Run("needs a tenant", func(t *testing.T) { + e, _ := newEnv(t) + e.answer("y\n") + + err := e.run("plugin", "approve", "checkmate") + + require.ErrorIs(t, err, errPluginNoTenant) + assertValidationError(t, err, "plugin_no_tenant") + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + + t.Run("fails when the approvals cannot be trusted", func(t *testing.T) { + e, _ := newEnv(t) + e.answer("y\n") + require.NoError(t, os.WriteFile(filepath.Join(e.Dir, "approvals.json"), []byte("{"), 0o600)) + + require.ErrorIs(t, e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant), approvals.ErrDamaged) + }) + + t.Run("needs an installed plugin that passes verification", func(t *testing.T) { + e, _ := newEnv(t) + e.answer("y\ny\n") + + err := e.run("plugin", "approve", "nothing", "--tenant", pluginTestTenant) + require.EqualError(t, err, "the plugin auth0/nothing is not installed. Installed plugins: auth0/checkmate") + assertValidationError(t, err, "plugin_not_installed") + + require.NoError(t, os.WriteFile(filepath.Join(e.Dir, "store", "auth0", "checkmate", "1.0.0", pluginTestBin), []byte("tampered"), 0o700)) + + err = e.run("plugin", "approve", "checkmate", "--tenant", pluginTestTenant) + + var failed *store.VerificationError + require.ErrorAs(t, err, &failed) + assert.Contains(t, err.Error(), "the installed copy of auth0/checkmate failed verification, so it cannot be approved") + assert.Contains(t, err.Error(), "Reinstall it with: auth0 plugin install checkmate") + assert.Empty(t, e.approvedHashes(pluginTestID)) + assert.NotContains(t, e.stderr.String(), "This plugin asks to use your Auth0 tenant") + }) + + t.Run("takes a name, not a version", func(t *testing.T) { + e, _ := newEnv(t) + e.answer("y\n") + + require.ErrorContains(t, e.run("plugin", "approve", "checkmate@1.0.0", "--tenant", pluginTestTenant), "approve takes the name of a plugin, without a version") + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) +} + +// TestPluginCommands_RefuseTwoOutputFormats gives each command that prints a +// result two output flags at once, and expects it to refuse before it does +// anything. +func TestPluginCommands_RefuseTwoOutputFormats(t *testing.T) { + tests := []struct { + name string + command []string + flags []string + + // ReadOnly is true for a command that can be run with the first flag alone. + readOnly bool + + // Active is the version of the test plugin that is installed afterwards. + active string + }{ + {"search", []string{"plugin", "search"}, []string{"--json", "--csv"}, true, ""}, + {"info", []string{"plugin", "info", "checkmate"}, []string{"--json", "--json-compact"}, true, ""}, + {"install", []string{"plugin", "install", "checkmate"}, []string{"--json", "--json-compact"}, false, ""}, + {"update", []string{"plugin", "update", "checkmate"}, []string{"--json", "--json-compact"}, false, "1.0.0"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, _ := newInstallEnv(t) + + if test.active != "" { + e.install(test.active) + } + + if test.readOnly { + require.NoError(t, e.run(slices.Concat(test.command, test.flags[:1])...)) + } + + require.Error(t, e.run(slices.Concat(test.command, test.flags)...)) + assert.Equal(t, test.active, e.activeVersion(pluginTestID)) + }) + } +} + +// TestPluginCommands_AgentModePrintsOnlyJSON checks that the commands that print +// a result put JSON on standard output in agent mode, and nothing on standard +// error. +func TestPluginCommands_AgentModePrintsOnlyJSON(t *testing.T) { + for _, command := range [][]string{{"plugin", "search"}, {"plugin", "info", "checkmate"}} { + t.Run(command[1], func(t *testing.T) { + e, _ := newInstallEnv(t) + + require.NoError(t, e.run(append(command, "--agent-mode")...)) + + assert.True(t, json.Valid(e.stdout.Bytes()), e.stdout.String()) + assert.Empty(t, e.stderr.String()) + }) + } +} + +// TestPluginCommands_FailOnDamagedLockfile checks that every command that reads +// the lockfile fails when it is damaged, instead of treating it as empty. +func TestPluginCommands_FailOnDamagedLockfile(t *testing.T) { + for _, command := range [][]string{{"plugin", "search"}, {"plugin", "list"}, {"plugin", "info", "checkmate"}, {"plugin", "update", "--all"}} { + t.Run(strings.Join(command[1:], " "), func(t *testing.T) { + e, _ := newInstallEnv(t) + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(e.Dir, "plugins.lock.json"), []byte("{"), 0o600)) + + err := e.run(command...) + + require.ErrorIs(t, err, store.ErrLockfileDamaged) + assert.Empty(t, e.stdout.String()) + }) + } +} + +// TestPluginCommands_RecordNoApprovalWhenTheDownloadFails checks that a person +// who approved a plugin at the consent screen has no approval recorded when the +// download then fails, and that the installed version stays active. +func TestPluginCommands_RecordNoApprovalWhenTheDownloadFails(t *testing.T) { + tests := []struct { + name string + args []string + fails string + active string + }{ + {"install", []string{"plugin", "install", "checkmate"}, "1.0.0", ""}, + {"update", []string{"plugin", "update", "checkmate"}, "2.0.0", "1.0.0"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // An update starts from version 1.0.0, and needs new permissions to ask for. + e, _ := newInstallEnv(t) + if test.active != "" { + e, _, _ = newUpdateEnv(t, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + } + + e.server.Fail(pluginArtifactPath("checkmate", test.fails), 503) + e.answer("y\n") + + err := e.run(append(test.args, "--tenant", pluginTestTenant)...) + + require.ErrorIs(t, err, store.ErrDownloadFailed) + assert.Contains(t, e.stderr.String(), "Approve for") + assert.Equal(t, test.active, e.activeVersion(pluginTestID)) + assert.Empty(t, e.approvedHashes(pluginTestID)) + }) + } +} + +// TestPluginCommands_WorkWithoutTheRegistry checks that the commands that only +// need what is on the machine still work when the registry cannot be reached, +// and that they do not even try. Uninstall also works where plugins are turned +// off and the approval pins are wrong. +func TestPluginCommands_WorkWithoutTheRegistry(t *testing.T) { + tests := []struct { + name string + args []string + env map[string]string + answer string + check func(t *testing.T, e *pluginEnv) + }{ + { + name: "list", + args: []string{"plugin", "list"}, + check: func(t *testing.T, e *pluginEnv) { + assert.Regexp(t, `(?m)^\s*ID\s+VERSION\s+PINNED\s+PREVIOUS\s+SIZE\s*$`, e.stdout.String()) + assert.Regexp(t, `auth0/checkmate\s+1\.0\.0\s+yes\s+\d+(\.\d)? (B|kB|MB)\s*$`, e.stdout.String()) + }, + }, + { + name: "uninstall, with plugins turned off and bad approval pins", + args: []string{"plugin", "uninstall", "checkmate", "--force"}, + env: map[string]string{pluginsDisabledEnv: "true", approvals.PinsEnv: "checkmate"}, + check: func(t *testing.T, e *pluginEnv) { + assert.Empty(t, e.installedIDs()) + }, + }, + { + name: "approve", + args: []string{"plugin", "approve", "checkmate", "--tenant", pluginTestTenant}, + answer: "y\n", + check: func(t *testing.T, e *pluginEnv) { + assert.Len(t, e.approvedHashes(pluginTestID), 1) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, _ := newInstallEnv(t) + e.install("1.0.0") + + // The saved index is due for a refresh, so a command that reads it asks the + // registry. + e.Now = e.Now.Add(25 * time.Hour) + requests := e.server.Requests("/timestamp.json") + e.server.Fail("/timestamp.json", 503) + + for name, value := range test.env { + t.Setenv(name, value) + } + + if test.answer != "" { + e.answer(test.answer) + } + + require.NoError(t, e.run(test.args...)) + + assert.Equal(t, requests, e.server.Requests("/timestamp.json")) + test.check(t, e) + }) + } +} + +func TestCheckApprovals(t *testing.T) { + tests := []struct { + name string + tenant string + damaged bool + want string + }{ + {"no tenant", "", false, ""}, + {"no tenant and a damaged approvals file", "", true, ""}, + {"a tenant and no approvals file", pluginTestTenant, false, ""}, + {"a URL", "https://acme.us.auth0.com", false, `"https://acme.us.auth0.com" is not a tenant domain`}, + {"a trailing dot", "acme.us.auth0.com.", false, `"acme.us.auth0.com." is not a tenant domain`}, + {"a space", "acme us", false, `"acme us" is not a tenant domain`}, + {"a damaged approvals file", pluginTestTenant, true, "The plugin approvals file is damaged"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := newPluginEnv(t) + file := filepath.Join(e.Dir, "approvals.json") + + if test.damaged { + require.NoError(t, os.MkdirAll(e.Dir, 0o700)) + require.NoError(t, os.WriteFile(file, []byte("{"), 0o600)) + } + + err := e.svc.checkApprovals(test.tenant) + + if test.want == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + } + + if !test.damaged { + assert.NoFileExists(t, file, "checking the approvals creates nothing") + } + }) + } +} + +// TestPluginCommands_FailBeforeDownloading checks that a tenant that cannot be +// used and an approvals file that cannot be trusted stop install, update and +// approve before anything is downloaded, installed or shown, instead of after. +// TestCheckApprovals has the other tenants. +func TestPluginCommands_FailBeforeDownloading(t *testing.T) { + tests := []struct { + name string + tenant string + damaged bool + want string + }{ + {"a URL", "https://acme.us.auth0.com", false, `"https://acme.us.auth0.com" is not a tenant domain`}, + {"a damaged approvals file", pluginTestTenant, true, "The plugin approvals file is damaged"}, + } + + commands := [][]string{ + {"plugin", "install", "checkmate"}, + {"plugin", "update", "checkmate"}, + {"plugin", "update", "--all"}, + {"plugin", "approve", "checkmate"}, + } + + for _, test := range tests { + for _, command := range commands { + t.Run(test.name+" "+strings.Join(command[1:], " "), func(t *testing.T) { + // Version 1.0.0 is installed, and the registry has 2.0.0, which an install + // or an update would download. + e, _, _ := newUpdateEnv(t, withAsks, withAsks) + e.answer("y\n") + + if test.damaged { + require.NoError(t, os.WriteFile(filepath.Join(e.Dir, "approvals.json"), []byte("{"), 0o600)) + } + + requests := e.server.Requests("/timestamp.json") + + err := e.run(append(slices.Clone(command), "--tenant", test.tenant)...) + + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + assert.Equal(t, requests, e.server.Requests("/timestamp.json"), "the registry is not asked") + assert.Zero(t, e.server.Requests(pluginArtifactPath("checkmate", "2.0.0")), "nothing is downloaded") + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + assert.NotContains(t, e.stderr.String(), "Test plugin", "nothing is shown about the plugin") + assert.Empty(t, e.stdout.String()) + }) + } + } +} + +func TestInfoPluginCmd_ListsVersionsFromTheRegistry(t *testing.T) { + // The versions of a plugin that the index points to in a file are not in the + // index, so the saved index is not enough to show the plugin. + for _, ref := range []string{"checkmate", "checkmate@1.0.0"} { + t.Run(ref, func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.refIndex(e.release("1.0.0"), e.release("1.1.0"))) + require.NoError(t, e.run("plugin", "search")) + + e.server.Fail("/plugins/checkmate.json", 503) + + err := e.run("plugin", "info", ref) + + require.ErrorIs(t, err, registry.ErrOffline) + assert.True(t, strings.HasSuffix(err.Error(), "Reconnect and run: auth0 plugin info "+ref), err.Error()) + assert.Empty(t, e.stdout.String()) + }) + } +} + +func TestInfoPluginCmd_ApprovalsOfOtherPermissions(t *testing.T) { + e := newPluginEnv(t) + old, newer := e.release("1.0.0", withScopes("read:clients")), e.release("2.0.0", withScopes("read:clients", "read:logs")) + e.publish(e.index(old, newer)) + e.install("1.0.0") + + // One tenant approved the old permissions and another the new ones. + require.NoError(t, e.svc.approvals.Approve(context.Background(), pluginTestTenant, pluginTestID, old.PermissionsHash)) + require.NoError(t, e.svc.approvals.Approve(context.Background(), "other.us.auth0.com", pluginTestID, newer.PermissionsHash)) + + require.NoError(t, e.run("plugin", "info", "checkmate")) + assert.Regexp(t, `APPROVED TENANTS\s+acme\.us\.auth0\.com \(different permissions\), other\.us\.auth0\.com\s*\n`, e.stdout.String()) + + require.NoError(t, e.run("plugin", "info", "checkmate@1.0.0")) + assert.Regexp(t, `APPROVED TENANTS\s+acme\.us\.auth0\.com, other\.us\.auth0\.com \(different permissions\)\s*\n`, e.stdout.String()) + + // The JSON has the hashes, which say the same, and no label. + require.NoError(t, e.run("plugin", "info", "checkmate", "--json")) + + var shown struct{ ApprovedTenants []map[string]any } + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &shown)) + require.Len(t, shown.ApprovedTenants, 2) + assert.Equal(t, "acme.us.auth0.com", shown.ApprovedTenants[0]["tenant"]) + assert.Equal(t, old.PermissionsHash, shown.ApprovedTenants[0]["permissionsHash"]) +} + +const ( + // HostileText has a bidirectional override and a zero width joiner, which the + // schema of the registry allows in its text, and which could make a line read + // differently than it is written. The control characters and the line and + // paragraph separators are refused by the schema, and TestSafeText covers + // them. + hostileText = "a\u202eb\u200dc" + + // CleanText is what a terminal gets instead of the hostile text. + cleanText = "a\ufffdb\ufffdc" +) + +// assertNoHostileText fails when the output has any character of the hostile +// text. +func assertNoHostileText(t *testing.T, out, what string) { + t.Helper() + + for _, hostile := range []string{"\u202e", "\u200d"} { + assert.NotContains(t, out, hostile, what) + } +} + +func TestPluginCommands_PrintRegistryTextSafely(t *testing.T) { + // The registry has hostile text, and so has the manifest of the installed copy. + e := newPluginEnv(t) + e.ManifestDescription = hostileText + + plugin := registrytest.NewPlugin(pluginTestID, e.release("1.0.0", withAsks)) + plugin.Description = hostileText + plugin.Owner = plugins.Owner{Team: hostileText, Bugs: "https://example.com/" + hostileText} + plugin.Homepage = "https://example.com/" + hostileText + + e.publish(registrytest.NewIndexBuilder().AddPlugin(plugin).Deprecate(registry.Deprecation{ID: pluginTestID, Message: hostileText, Replacement: "auth0/other"})) + e.answer("y\ny\n") + + for _, args := range [][]string{ + {"search"}, + {"search", "--csv"}, + {"info", "checkmate"}, + {"install", "checkmate", "--tenant", pluginTestTenant}, + {"approve", "checkmate", "--tenant", "other.us.auth0.com"}, + } { + require.NoError(t, e.run(append([]string{"plugin"}, args...)...), args) + + out := e.stdout.String() + e.stderr.String() + assertNoHostileText(t, out, out) + assert.Contains(t, out, cleanText, args) + } + + require.NoError(t, e.run("plugin", "info", "checkmate")) + + for label, want := range map[string]string{ + "DESCRIPTION": cleanText, + "OWNER": cleanText + " https://example.com/" + cleanText, + "HOMEPAGE": "https://example.com/" + cleanText, + "DEPRECATED": cleanText + " (use auth0/other instead)", + } { + assert.Regexp(t, label+`\s+`+regexp.QuoteMeta(want), e.stdout.String()) + } + + // JSON is data, so it keeps the text as the registry has it. + require.NoError(t, e.run("plugin", "info", "checkmate", "--json")) + + var shown struct { + Description, Homepage string + Owner struct{ Team string } + Deprecated struct{ Message string } + } + require.NoError(t, json.Unmarshal(e.stdout.Bytes(), &shown)) + assert.Equal(t, []string{hostileText, hostileText, "https://example.com/" + hostileText, hostileText}, + []string{shown.Description, shown.Owner.Team, shown.Homepage, shown.Deprecated.Message}) +} + +// TestPluginCommands_EnvAuthModeFixesTheTenant checks that in env auth mode a +// --tenant that names another tenant than AUTH0_DOMAIN is refused, like the +// commands that call the API do, before anything happens. +func TestPluginCommands_EnvAuthModeFixesTheTenant(t *testing.T) { + setup := func(t *testing.T) *pluginEnv { + t.Helper() + + e, _ := newInstallEnv(t) + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, pluginTestTenant) + e.answer("y\n") + + return e + } + + commands := [][]string{ + {"plugin", "install", "checkmate"}, + {"plugin", "update", "checkmate"}, + {"plugin", "update", "--all"}, + {"plugin", "approve", "checkmate"}, + } + + for _, command := range commands { + t.Run("refuses another tenant: "+strings.Join(command[1:], " "), func(t *testing.T) { + e := setup(t) + + err := e.run(append(slices.Clone(command), "--tenant", "other.us.auth0.com")...) + + require.Error(t, err) + assert.Equal(t, "auth", errorClass(err)) + assert.Equal(t, "env_auth_tenant_conflict", errorReason(err)) + assert.Contains(t, err.Error(), `--tenant "other.us.auth0.com" conflicts with AUTH0_DOMAIN="acme.us.auth0.com"`) + assert.Zero(t, e.server.Requests("/timestamp.json")) + assert.Empty(t, e.installedIDs()) + }) + } + + for _, args := range [][]string{nil, {"--tenant", "ACME.us.auth0.com"}} { + t.Run("accepts the tenant of AUTH0_DOMAIN: "+strings.Join(args, " "), func(t *testing.T) { + e := setup(t) + + require.NoError(t, e.run(append([]string{"plugin", "install", "checkmate"}, args...)...)) + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index df9b11203..90655e546 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -499,6 +499,13 @@ func commandRequiresAuthentication(invokedCommandName string) bool { } } + // The plugin commands work from the registry, the plugins directory and the + // tenant name, so none of them needs a session. The path is built from the + // command name, so the plugins alias takes this branch too. + if invokedCommandName == pluginCommandPath || strings.HasPrefix(invokedCommandName, pluginCommandPath+" ") { + return false + } + return true } @@ -626,6 +633,7 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(refreshTokensCmd(cli)) rootCmd.AddCommand(guardianCmd(cli)) rootCmd.AddCommand(experimentationCmd(cli)) + registerPluginCmd(rootCmd, cli, buildinfo.Version) rootCmd.AddCommand(docsCmd(cli)) rootCmd.AddCommand(commandsCmd(cli)) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index b91403ebc..3ae072947 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -53,6 +53,11 @@ func TestCommandRequiresAuthentication(t *testing.T) { {"auth0 logout", false}, {"auth0 tenants use", false}, {"auth0 tenants list", false}, + {"auth0 plugin list", false}, + {"auth0 plugin search", false}, + {"auth0 plugin info", false}, + {"auth0 plugin install", false}, + {"auth0 pluginx", true}, } for index, testCase := range testCases { @@ -98,6 +103,27 @@ func TestPersistentPreRunSkipsAuthForSchema(t *testing.T) { } } +// TestPersistentPreRunSkipsAuthForPluginCommands runs the real pre-run hook with +// an empty HOME, so it only passes when no login is asked for. The plugins alias +// must take the same path as the plugin command. +func TestPersistentPreRunSkipsAuthForPluginCommands(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + for _, path := range [][]string{{"plugin", "list"}, {"plugins", "list"}, {"plugins", "search"}, {"plugins", "info"}} { + t.Run(strings.Join(path, " "), func(t *testing.T) { + testCLI := &cli{renderer: display.NewRenderer()} + root := buildRootCmd(testCLI) + addSubCommands(root, testCLI) + + cmd, _, err := root.Find(path) + require.NoError(t, err) + require.Equal(t, "auth0 plugin "+path[1], cmd.CommandPath()) + + assert.NoError(t, root.PersistentPreRunE(cmd, nil)) + }) + } +} + func TestEnforceUnknownSubcommand(t *testing.T) { newTree := func() *cobra.Command { root := &cobra.Command{Use: "auth0"} diff --git a/internal/display/plugins.go b/internal/display/plugins.go new file mode 100644 index 000000000..c4836a584 --- /dev/null +++ b/internal/display/plugins.go @@ -0,0 +1,402 @@ +package display + +import ( + "fmt" + "strings" + "time" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/plugins" +) + +// The plugin data types below are what the plugin commands hand to the renderer. +// They are exported because the commands build them from the registry, the +// store and the approvals, which the display package does not read, and their +// JSON tags are the JSON output of the commands. They hold the text as the +// registry supplied it. The views that wrap them print it as plain text. + +// PluginDeprecation says why a plugin should no longer be used. +type PluginDeprecation struct { + Message string `json:"message"` + Replacement string `json:"replacement,omitempty"` +} + +// text returns the deprecation as one sentence for a person. +func (d *PluginDeprecation) text() string { + if d.Replacement == "" { + return d.Message + } + + return fmt.Sprintf("%s (use %s instead)", d.Message, d.Replacement) +} + +// PluginSearchResult is one plugin found in the registry. +type PluginSearchResult struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Installed bool `json:"installed"` + Description string `json:"description"` + Deprecated *PluginDeprecation `json:"deprecated"` +} + +// PluginListEntry is one installed plugin. +type PluginListEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Pinned bool `json:"pinned"` + Previous string `json:"previous"` + SizeBytes int64 `json:"sizeBytes"` +} + +// PluginChangeResult is the result of an install or an update of one plugin. +// ApproveCommand is the command that approves the plugin, and is only set when +// ApprovalRequired is true and there is a tenant. Available and Error are only +// set for a plugin that update --all skipped or could not update. +type PluginChangeResult struct { + ID string `json:"id"` + Version string `json:"version"` + PreviousVersion string `json:"previousVersion"` + Action string `json:"action"` + Pinned bool `json:"pinned"` + ApprovalRequired bool `json:"approvalRequired"` + ApproveCommand string `json:"approveCommand,omitempty"` + Available string `json:"available,omitempty"` + Error string `json:"error,omitempty"` +} + +// PluginPermissions counts what a plugin version asks to be allowed. The +// consent screen of the install lists the details. +type PluginPermissions struct { + Scopes int `json:"scopes"` + Hosts int `json:"hosts"` + Files int `json:"files"` + Env int `json:"env"` +} + +func (p PluginPermissions) text() string { + return strings.Join([]string{ + pluralize(p.Scopes, "scope", "scopes"), + pluralize(p.Hosts, "host", "hosts"), + pluralize(p.Files, "file path", "file paths"), + pluralize(p.Env, "environment variable", "environment variables"), + }, ", ") +} + +// PluginVersion is one version of a plugin in the registry. +type PluginVersion struct { + Version string `json:"version"` + Latest bool `json:"latest"` + Revoked bool `json:"revoked"` +} + +// PluginInstalled is the installed copy of a plugin. When it failed +// verification, Error says why and nothing else about it is vouched for. +type PluginInstalled struct { + Version string `json:"version"` + Verified bool `json:"verified"` + PermissionsHash string `json:"permissionsHash,omitempty"` + Error string `json:"error,omitempty"` +} + +// PluginApproval is a tenant where a person approved the plugin. +type PluginApproval struct { + Tenant string `json:"tenant"` + PermissionsHash string `json:"permissionsHash"` + ApprovedAt time.Time `json:"approvedAt"` +} + +// PluginDetails is the detail of one version of a plugin. +type PluginDetails struct { + ID string `json:"id"` + Version string `json:"version"` + Description string `json:"description"` + Owner plugins.Owner `json:"owner"` + Homepage string `json:"homepage"` + Requires string `json:"requires"` + Platforms []plugins.Platform `json:"platforms"` + Permissions PluginPermissions `json:"permissions"` + PermissionsHash string `json:"permissionsHash"` + Versions []PluginVersion `json:"versions"` + Installed *PluginInstalled `json:"installed"` + Deprecated *PluginDeprecation `json:"deprecated"` + ApprovedTenants []PluginApproval `json:"approvedTenants"` +} + +type pluginSearchView struct { + PluginSearchResult +} + +func (v *pluginSearchView) AsTableHeader() []string { + return []string{"ID", "Version", "Installed", "Description"} +} + +func (v *pluginSearchView) AsTableRow() []string { + description := v.Description + if v.Deprecated != nil { + description = "(deprecated) " + description + } + + return []string{plugins.SafeText(v.ID), plugins.SafeText(v.Version), yesNo(v.Installed), plugins.SafeText(description)} +} + +func (v *pluginSearchView) Object() any { + return &v.PluginSearchResult +} + +type pluginListView struct { + PluginListEntry +} + +func (v *pluginListView) AsTableHeader() []string { + return []string{"ID", "Version", "Pinned", "Previous", "Size"} +} + +func (v *pluginListView) AsTableRow() []string { + return []string{plugins.SafeText(v.ID), plugins.SafeText(v.Version), yesNo(v.Pinned), plugins.SafeText(v.Previous), formatSize(v.SizeBytes)} +} + +func (v *pluginListView) Object() any { + return &v.PluginListEntry +} + +type pluginChangeView struct { + PluginChangeResult +} + +func (v *pluginChangeView) AsTableHeader() []string { + return []string{"Name", "Version", "Action"} +} + +func (v *pluginChangeView) AsTableRow() []string { + return []string{v.ID, v.Version, v.Action} +} + +func (v *pluginChangeView) Object() any { + return &v.PluginChangeResult +} + +type pluginInfoView struct { + PluginDetails +} + +func (v *pluginInfoView) AsTableHeader() []string { + return []string{"Name", "Version", "Description"} +} + +func (v *pluginInfoView) AsTableRow() []string { + return []string{plugins.SafeText(v.ID), plugins.SafeText(v.Version), plugins.SafeText(v.Description)} +} + +func (v *pluginInfoView) KeyValues() [][]string { + platforms := make([]string, 0, len(v.Platforms)) + for _, platform := range v.Platforms { + platforms = append(platforms, string(platform)) + } + + versions := make([]string, 0, len(v.Versions)) + + for _, version := range v.Versions { + entry := version.Version + + switch { + case version.Latest && version.Revoked: + entry += " (latest, revoked)" + case version.Latest: + entry += " (latest)" + case version.Revoked: + entry += " (revoked)" + } + + versions = append(versions, entry) + } + + requires := "none" + if v.Requires != "" { + requires = "auth0-cli " + v.Requires + } + + installed := "no" + + if v.Installed != nil { + installed = v.Installed.Version + if !v.Installed.Verified { + installed += " (failed verification)" + } + } + + rows := [][]string{ + {"ID", ansi.Faint(plugins.SafeText(v.ID))}, + {"VERSION", v.Version}, + {"DESCRIPTION", v.Description}, + {"OWNER", v.Owner.Team + " " + v.Owner.Bugs}, + } + + if v.Homepage != "" { + rows = append(rows, []string{"HOMEPAGE", v.Homepage}) + } + + rows = append(rows, + []string{"REQUIRES", requires}, + []string{"PLATFORMS", strings.Join(platforms, ", ")}, + []string{"PERMISSIONS", v.Permissions.text()}, + []string{"PERMISSIONS HASH", v.PermissionsHash}, + []string{"VERSIONS", strings.Join(versions, ", ")}, + []string{"INSTALLED", installed}, + ) + + if v.Deprecated != nil { + rows = append(rows, []string{"DEPRECATED", v.Deprecated.text()}) + } + + approved := "none" + + if len(v.ApprovedTenants) > 0 { + tenants := make([]string, 0, len(v.ApprovedTenants)) + + for _, approval := range v.ApprovedTenants { + entry := approval.Tenant + + // An approval holds for the permissions it was given for. A tenant that + // approved other permissions than the ones shown is not approved for them. + if approval.PermissionsHash != v.PermissionsHash { + entry += " (different permissions)" + } + + tenants = append(tenants, entry) + } + + approved = strings.Join(tenants, ", ") + } + + rows = append(rows, []string{"APPROVED TENANTS", approved}) + + // Everything the registry supplied is printed as plain text. The ID row is + // already plain, and its color is not text. + for _, row := range rows[1:] { + row[1] = plugins.SafeText(row[1]) + } + + return rows +} + +func (v *pluginInfoView) Object() any { + return &v.PluginDetails +} + +// PluginSearchList prints the plugins found in the registry. +func (r *Renderer) PluginSearchList(found []PluginSearchResult) { + if len(found) == 0 { + r.EmptyState("plugins", "Try a different search, or run: auth0 plugin search") + return + } + + results := make([]View, 0, len(found)) + for _, plugin := range found { + results = append(results, &pluginSearchView{plugin}) + } + + r.Results(results) +} + +// PluginList prints the installed plugins. +func (r *Renderer) PluginList(installed []PluginListEntry) { + if len(installed) == 0 { + r.EmptyState("installed plugins", "Find one with: auth0 plugin search") + return + } + + results := make([]View, 0, len(installed)) + for _, plugin := range installed { + results = append(results, &pluginListView{plugin}) + } + + r.Results(results) +} + +// PluginInfo prints the detail of one version of a plugin. +func (r *Renderer) PluginInfo(details PluginDetails) { + r.Result(&pluginInfoView{details}) +} + +// PluginChange prints the result of an install or an update of one plugin as +// JSON. In the other formats it prints nothing, because the command already told +// the person what it did. +func (r *Renderer) PluginChange(change PluginChangeResult) { + if !r.jsonFormat() { + return + } + + r.Result(&pluginChangeView{change}) +} + +// PluginChanges prints the results of update --all, one entry for each plugin, as +// JSON. In the other formats it prints nothing, for the reason of PluginChange. +func (r *Renderer) PluginChanges(changes []PluginChangeResult) { + if !r.jsonFormat() { + return + } + + results := make([]View, 0, len(changes)) + for _, change := range changes { + results = append(results, &pluginChangeView{change}) + } + + r.Results(results) +} + +func (r *Renderer) jsonFormat() bool { + return r.Format == OutputFormatJSON || r.Format == OutputFormatJSONCompact +} + +// PluginSummary prints the line that names a plugin: its ID, version and +// description. +func (r *Renderer) PluginSummary(id, version, description string) { + r.Infof("%s %s %s", plugins.SafeText(id), plugins.SafeText(version), plugins.SafeText(description)) +} + +// PluginDownload prints the size of the download of a plugin for a platform. +func (r *Renderer) PluginDownload(bytes int64, platform plugins.Platform) { + r.Infof("Download: %s (%s)", formatSize(bytes), platform) +} + +// PluginUninstalled prints that a plugin was uninstalled and how much space that +// freed. +func (r *Renderer) PluginUninstalled(id string, freedBytes int64) { + r.Infof("Uninstalled %s and freed %s.", id, formatSize(freedBytes)) +} + +func yesNo(value bool) string { + if value { + return "yes" + } + + return "no" +} + +// formatSize writes a number of bytes the way a person reads a file size. +func formatSize(bytes int64) string { + const unit = 1000 + + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + + size, suffix := float64(bytes), 0 + + for ; size >= unit && suffix < 3; suffix++ { + size /= unit + } + + return fmt.Sprintf("%.1f %s", size, []string{"B", "kB", "MB", "GB"}[suffix]) +} + +// pluralize writes a count with the singular or the plural of what is counted. +func pluralize(n int, singular, plural string) string { + if n == 1 { + return fmt.Sprintf("1 %s", singular) + } + + return fmt.Sprintf("%d %s", n, plural) +} diff --git a/internal/display/plugins_test.go b/internal/display/plugins_test.go new file mode 100644 index 000000000..7c6b642df --- /dev/null +++ b/internal/display/plugins_test.go @@ -0,0 +1,484 @@ +package display + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/plugins" +) + +const ( + // A right to left override, a zero width space and a line separator, which + // could hide text or end a line in a terminal. + hostilePluginText = "a\u202eb\u200bc\u2028d" + + // What a terminal gets instead of the hostile text. + cleanPluginText = "a\ufffdb\ufffdc\ufffdd" +) + +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + want string + }{ + {0, "0 B"}, + {999, "999 B"}, + {1000, "1.0 kB"}, + {1536, "1.5 kB"}, + {1_000_000, "1.0 MB"}, + {90_500_000, "90.5 MB"}, + {1_000_000_000, "1.0 GB"}, + } + + for _, test := range tests { + assert.Equal(t, test.want, formatSize(test.bytes), test.bytes) + } +} + +func TestPluginPermissions_Text(t *testing.T) { + tests := []struct { + name string + permissions PluginPermissions + want string + }{ + {"none", PluginPermissions{}, "0 scopes, 0 hosts, 0 file paths, 0 environment variables"}, + {"one of each", PluginPermissions{Scopes: 1, Hosts: 1, Files: 1, Env: 1}, "1 scope, 1 host, 1 file path, 1 environment variable"}, + {"several", PluginPermissions{Scopes: 0, Hosts: 2, Files: 3, Env: 4}, "0 scopes, 2 hosts, 3 file paths, 4 environment variables"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, test.permissions.text()) + }) + } +} + +func TestPluginDeprecation_Text(t *testing.T) { + assert.Equal(t, "Unmaintained", (&PluginDeprecation{Message: "Unmaintained"}).text()) + assert.Equal(t, "Unmaintained (use auth0/other instead)", (&PluginDeprecation{Message: "Unmaintained", Replacement: "auth0/other"}).text()) +} + +// jsonOf returns the JSON of the object that a view gives for the JSON output. +func jsonOf(t *testing.T, view View) string { + t.Helper() + + b, err := json.Marshal(view.Object()) + require.NoError(t, err) + + return string(b) +} + +func TestPluginSearchView(t *testing.T) { + tests := []struct { + name string + result PluginSearchResult + wantRow []string + wantJSON string + }{ + { + name: "empty", + result: PluginSearchResult{}, + wantRow: []string{"", "", "no", ""}, + wantJSON: `{"id":"","name":"","version":"","installed":false,"description":"","deprecated":null}`, + }, + { + name: "installed", + result: PluginSearchResult{ID: "auth0/checkmate", Name: "checkmate", Version: "1.2.0", Installed: true, Description: "Audits a tenant"}, + wantRow: []string{"auth0/checkmate", "1.2.0", "yes", "Audits a tenant"}, + wantJSON: `{"id":"auth0/checkmate","name":"checkmate","version":"1.2.0","installed":true,"description":"Audits a tenant","deprecated":null}`, + }, + { + name: "deprecated with and without a replacement", + result: PluginSearchResult{ID: "auth0/old", Name: "old", Version: "1.0.0", Description: "Old", Deprecated: &PluginDeprecation{Message: "Gone", Replacement: "auth0/new"}}, + wantRow: []string{"auth0/old", "1.0.0", "no", "(deprecated) Old"}, + wantJSON: `{"id":"auth0/old","name":"old","version":"1.0.0","installed":false,"description":"Old","deprecated":{"message":"Gone","replacement":"auth0/new"}}`, + }, + { + name: "deprecated without a replacement", + result: PluginSearchResult{ID: "auth0/old", Name: "old", Version: "1.0.0", Deprecated: &PluginDeprecation{Message: "Gone"}}, + wantRow: []string{"auth0/old", "1.0.0", "no", "(deprecated) "}, + wantJSON: `{"id":"auth0/old","name":"old","version":"1.0.0","installed":false,"description":"","deprecated":{"message":"Gone"}}`, + }, + { + name: "hostile text is cleaned in the row", + result: PluginSearchResult{ID: "auth0/" + hostilePluginText, Version: hostilePluginText, Description: hostilePluginText}, + wantRow: []string{"auth0/" + cleanPluginText, cleanPluginText, "no", cleanPluginText}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + view := &pluginSearchView{test.result} + + assert.Equal(t, []string{"ID", "Version", "Installed", "Description"}, view.AsTableHeader()) + assert.Equal(t, test.wantRow, view.AsTableRow()) + + if test.wantJSON != "" { + assert.JSONEq(t, test.wantJSON, jsonOf(t, view)) + } + + // JSON is data, so it keeps the text as the registry has it. + var decoded PluginSearchResult + require.NoError(t, json.Unmarshal([]byte(jsonOf(t, view)), &decoded)) + assert.Equal(t, test.result, decoded) + }) + } +} + +func TestPluginListView(t *testing.T) { + tests := []struct { + name string + entry PluginListEntry + wantRow []string + wantJSON string + }{ + { + name: "empty", + entry: PluginListEntry{}, + wantRow: []string{"", "", "no", "", "0 B"}, + wantJSON: `{"id":"","name":"","version":"","pinned":false,"previous":"","sizeBytes":0}`, + }, + { + name: "pinned with a previous version", + entry: PluginListEntry{ID: "auth0/checkmate", Name: "checkmate", Version: "2.0.0", Pinned: true, Previous: "1.0.0", SizeBytes: 90_500_000}, + wantRow: []string{"auth0/checkmate", "2.0.0", "yes", "1.0.0", "90.5 MB"}, + wantJSON: `{"id":"auth0/checkmate","name":"checkmate","version":"2.0.0","pinned":true,"previous":"1.0.0","sizeBytes":90500000}`, + }, + { + name: "hostile text is cleaned in the row", + entry: PluginListEntry{ID: "auth0/" + hostilePluginText, Version: hostilePluginText, Previous: hostilePluginText, SizeBytes: 1536}, + wantRow: []string{"auth0/" + cleanPluginText, cleanPluginText, "no", cleanPluginText, "1.5 kB"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + view := &pluginListView{test.entry} + + assert.Equal(t, []string{"ID", "Version", "Pinned", "Previous", "Size"}, view.AsTableHeader()) + assert.Equal(t, test.wantRow, view.AsTableRow()) + + if test.wantJSON != "" { + assert.JSONEq(t, test.wantJSON, jsonOf(t, view)) + } + + var decoded PluginListEntry + require.NoError(t, json.Unmarshal([]byte(jsonOf(t, view)), &decoded)) + assert.Equal(t, test.entry, decoded) + }) + } +} + +func TestPluginChangeView(t *testing.T) { + tests := []struct { + name string + change PluginChangeResult + wantRow []string + wantJSON string + }{ + { + name: "empty", + change: PluginChangeResult{}, + wantRow: []string{"", "", ""}, + wantJSON: `{"id":"","version":"","previousVersion":"","action":"","pinned":false,"approvalRequired":false}`, + }, + { + name: "an update that needs an approval", + change: PluginChangeResult{ + ID: "auth0/checkmate", Version: "2.0.0", PreviousVersion: "1.0.0", Action: "installed", ApprovalRequired: true, + ApproveCommand: "auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com", + }, + wantRow: []string{"auth0/checkmate", "2.0.0", "installed"}, + wantJSON: `{"id":"auth0/checkmate","version":"2.0.0","previousVersion":"1.0.0","action":"installed","pinned":false,"approvalRequired":true, + "approveCommand":"auth0 plugin approve auth0/checkmate --tenant acme.us.auth0.com"}`, + }, + { + name: "a skipped plugin and a failed one", + change: PluginChangeResult{ID: "auth0/checkmate", Version: "1.0.0", Action: "skipped", Pinned: true, Available: "2.0.0", Error: "boom"}, + wantRow: []string{"auth0/checkmate", "1.0.0", "skipped"}, + wantJSON: `{"id":"auth0/checkmate","version":"1.0.0","previousVersion":"","action":"skipped","pinned":true,"approvalRequired":false,"available":"2.0.0","error":"boom"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + view := &pluginChangeView{test.change} + + assert.Equal(t, []string{"Name", "Version", "Action"}, view.AsTableHeader()) + assert.Equal(t, test.wantRow, view.AsTableRow()) + assert.JSONEq(t, test.wantJSON, jsonOf(t, view)) + }) + } +} + +func TestPluginInfoView(t *testing.T) { + approvedAt := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC) + + full := PluginDetails{ + ID: "auth0/checkmate", + Version: "2.0.0", + Description: "Audits a tenant", + Owner: plugins.Owner{Team: "Tools", Bugs: "https://example.com/bugs"}, + Homepage: "https://example.com/checkmate", + Requires: ">=1.38.0", + Platforms: []plugins.Platform{plugins.PlatformDarwinArm64, plugins.PlatformLinuxX64}, + Permissions: PluginPermissions{Scopes: 2, Hosts: 1}, + PermissionsHash: "sha256:aaa", + Versions: []PluginVersion{{Version: "2.0.0", Latest: true}, {Version: "1.1.0", Revoked: true}, {Version: "1.0.0"}}, + Installed: &PluginInstalled{Version: "1.0.0", Verified: true, PermissionsHash: "sha256:bbb"}, + Deprecated: &PluginDeprecation{Message: "Unmaintained", Replacement: "auth0/other"}, + ApprovedTenants: []PluginApproval{ + {Tenant: "acme.us.auth0.com", PermissionsHash: "sha256:aaa", ApprovedAt: approvedAt}, + {Tenant: "other.us.auth0.com", PermissionsHash: "sha256:bbb", ApprovedAt: approvedAt}, + }, + } + + tests := []struct { + name string + mutate func(*PluginDetails) + want [][]string + }{ + { + name: "everything is known", + want: [][]string{ + {"ID", ansi.Faint("auth0/checkmate")}, + {"VERSION", "2.0.0"}, + {"DESCRIPTION", "Audits a tenant"}, + {"OWNER", "Tools https://example.com/bugs"}, + {"HOMEPAGE", "https://example.com/checkmate"}, + {"REQUIRES", "auth0-cli >=1.38.0"}, + {"PLATFORMS", "darwin-arm64, linux-x64"}, + {"PERMISSIONS", "2 scopes, 1 host, 0 file paths, 0 environment variables"}, + {"PERMISSIONS HASH", "sha256:aaa"}, + {"VERSIONS", "2.0.0 (latest), 1.1.0 (revoked), 1.0.0"}, + {"INSTALLED", "1.0.0"}, + {"DEPRECATED", "Unmaintained (use auth0/other instead)"}, + {"APPROVED TENANTS", "acme.us.auth0.com, other.us.auth0.com (different permissions)"}, + }, + }, + { + name: "nothing is known", + mutate: func(d *PluginDetails) { *d = PluginDetails{} }, + want: [][]string{ + {"ID", ansi.Faint("")}, + {"VERSION", ""}, + {"DESCRIPTION", ""}, + {"OWNER", " "}, + {"REQUIRES", "none"}, + {"PLATFORMS", ""}, + {"PERMISSIONS", "0 scopes, 0 hosts, 0 file paths, 0 environment variables"}, + {"PERMISSIONS HASH", ""}, + {"VERSIONS", ""}, + {"INSTALLED", "no"}, + {"APPROVED TENANTS", "none"}, + }, + }, + { + name: "the latest version is revoked, and the installed copy failed verification", + mutate: func(d *PluginDetails) { + d.Versions = []PluginVersion{{Version: "2.0.0", Latest: true, Revoked: true}} + d.Installed = &PluginInstalled{Version: "2.0.0", Error: "changed"} + d.Deprecated = &PluginDeprecation{Message: "Unmaintained"} + d.ApprovedTenants = nil + }, + want: [][]string{ + {"ID", ansi.Faint("auth0/checkmate")}, + {"VERSION", "2.0.0"}, + {"DESCRIPTION", "Audits a tenant"}, + {"OWNER", "Tools https://example.com/bugs"}, + {"HOMEPAGE", "https://example.com/checkmate"}, + {"REQUIRES", "auth0-cli >=1.38.0"}, + {"PLATFORMS", "darwin-arm64, linux-x64"}, + {"PERMISSIONS", "2 scopes, 1 host, 0 file paths, 0 environment variables"}, + {"PERMISSIONS HASH", "sha256:aaa"}, + {"VERSIONS", "2.0.0 (latest, revoked)"}, + {"INSTALLED", "2.0.0 (failed verification)"}, + {"DEPRECATED", "Unmaintained"}, + {"APPROVED TENANTS", "none"}, + }, + }, + { + name: "text from the registry is cleaned", + mutate: func(d *PluginDetails) { + d.Description = hostilePluginText + d.Owner = plugins.Owner{Team: hostilePluginText, Bugs: "https://example.com/" + hostilePluginText} + d.Homepage = "https://example.com/" + hostilePluginText + d.Requires = hostilePluginText + d.Deprecated = &PluginDeprecation{Message: hostilePluginText, Replacement: hostilePluginText} + d.ApprovedTenants = []PluginApproval{{Tenant: hostilePluginText, PermissionsHash: d.PermissionsHash}} + d.Installed = &PluginInstalled{Version: hostilePluginText, Verified: true} + }, + want: [][]string{ + {"ID", ansi.Faint("auth0/checkmate")}, + {"VERSION", "2.0.0"}, + {"DESCRIPTION", cleanPluginText}, + {"OWNER", cleanPluginText + " https://example.com/" + cleanPluginText}, + {"HOMEPAGE", "https://example.com/" + cleanPluginText}, + {"REQUIRES", "auth0-cli " + cleanPluginText}, + {"PLATFORMS", "darwin-arm64, linux-x64"}, + {"PERMISSIONS", "2 scopes, 1 host, 0 file paths, 0 environment variables"}, + {"PERMISSIONS HASH", "sha256:aaa"}, + {"VERSIONS", "2.0.0 (latest), 1.1.0 (revoked), 1.0.0"}, + {"INSTALLED", cleanPluginText}, + {"DEPRECATED", cleanPluginText + " (use " + cleanPluginText + " instead)"}, + {"APPROVED TENANTS", cleanPluginText}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + details := full + if test.mutate != nil { + test.mutate(&details) + } + + view := &pluginInfoView{details} + + assert.Equal(t, test.want, view.KeyValues()) + assert.Equal(t, []string{"Name", "Version", "Description"}, view.AsTableHeader()) + assert.Equal(t, []string{details.ID, details.Version, plugins.SafeText(details.Description)}, view.AsTableRow()) + }) + } + + t.Run("the JSON object keeps the text and has every field", func(t *testing.T) { + details := full + details.Description = hostilePluginText + details.Deprecated = &PluginDeprecation{Message: hostilePluginText} + + view := &pluginInfoView{details} + + var decoded PluginDetails + require.NoError(t, json.Unmarshal([]byte(jsonOf(t, view)), &decoded)) + assert.Equal(t, details, decoded) + }) + + t.Run("the JSON object of an empty view has null and empty lists as the commands print them", func(t *testing.T) { + view := &pluginInfoView{PluginDetails{Versions: []PluginVersion{}, ApprovedTenants: []PluginApproval{}}} + + assert.JSONEq(t, `{"id":"","version":"","description":"","owner":{"team":"","bugs":""},"homepage":"","requires":"","platforms":null, + "permissions":{"scopes":0,"hosts":0,"files":0,"env":0},"permissionsHash":"","versions":[],"installed":null,"deprecated":null,"approvedTenants":[]}`, jsonOf(t, view)) + }) + + t.Run("the JSON object of an approval has its time", func(t *testing.T) { + view := &pluginInfoView{PluginDetails{ApprovedTenants: []PluginApproval{{Tenant: "acme.us.auth0.com", PermissionsHash: "sha256:aaa", ApprovedAt: approvedAt}}}} + + assert.Contains(t, jsonOf(t, view), `"approvedTenants":[{"tenant":"acme.us.auth0.com","permissionsHash":"sha256:aaa","approvedAt":"2026-10-01T12:00:00Z"}]`) + }) +} + +// pluginRenderer returns a renderer that writes to buffers. +func pluginRenderer(format OutputFormat) (*Renderer, *bytes.Buffer, *bytes.Buffer) { + var stdout, stderr bytes.Buffer + + return &Renderer{MessageWriter: &stderr, ResultWriter: &stdout, Format: format}, &stdout, &stderr +} + +func TestRenderer_PluginSearchList(t *testing.T) { + found := []PluginSearchResult{ + {ID: "auth0/checkmate", Name: "checkmate", Version: "1.2.0", Installed: true, Description: "Audits a tenant"}, + {ID: "auth0/old", Name: "old", Version: "1.0.0", Description: hostilePluginText, Deprecated: &PluginDeprecation{Message: "Gone"}}, + } + + t.Run("prints csv", func(t *testing.T) { + r, stdout, _ := pluginRenderer(OutputFormatCSV) + r.PluginSearchList(found) + + assert.Equal(t, "ID,Version,Installed,Description\nauth0/checkmate,1.2.0,yes,Audits a tenant\nauth0/old,1.0.0,no,(deprecated) "+cleanPluginText+"\n", stdout.String()) + }) + + t.Run("says when there is nothing", func(t *testing.T) { + r, stdout, stderr := pluginRenderer("") + r.PluginSearchList(nil) + + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "No plugins available. Try a different search, or run: auth0 plugin search") + }) + + t.Run("prints an empty list as JSON", func(t *testing.T) { + r, stdout, stderr := pluginRenderer(OutputFormatJSONCompact) + r.PluginSearchList(nil) + + assert.JSONEq(t, `[]`, stdout.String()) + assert.Empty(t, stderr.String()) + }) +} + +func TestRenderer_PluginList(t *testing.T) { + installed := []PluginListEntry{{ID: "auth0/checkmate", Name: "checkmate", Version: "2.0.0", Pinned: true, Previous: "1.0.0", SizeBytes: 1536}} + + t.Run("prints csv", func(t *testing.T) { + r, stdout, _ := pluginRenderer(OutputFormatCSV) + r.PluginList(installed) + + assert.Equal(t, "ID,Version,Pinned,Previous,Size\nauth0/checkmate,2.0.0,yes,1.0.0,1.5 kB\n", stdout.String()) + }) + + t.Run("says when nothing is installed", func(t *testing.T) { + r, stdout, stderr := pluginRenderer("") + r.PluginList(nil) + + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "No installed plugins available. Find one with: auth0 plugin search") + }) +} + +func TestRenderer_PluginInfo(t *testing.T) { + details := PluginDetails{ID: "auth0/checkmate", Version: "1.0.0", Description: hostilePluginText, Versions: []PluginVersion{}, ApprovedTenants: []PluginApproval{}} + + r, stdout, _ := pluginRenderer("") + r.PluginInfo(details) + + assert.Contains(t, stdout.String(), "DESCRIPTION") + assert.Contains(t, stdout.String(), cleanPluginText) + assert.NotContains(t, stdout.String(), "\u202e") +} + +func TestRenderer_PluginChange(t *testing.T) { + change := PluginChangeResult{ID: "auth0/checkmate", Version: "1.0.0", Action: "installed"} + + r, stdout, _ := pluginRenderer(OutputFormatJSONCompact) + r.PluginChange(change) + assert.JSONEq(t, `{"id":"auth0/checkmate","version":"1.0.0","previousVersion":"","action":"installed","pinned":false,"approvalRequired":false}`, stdout.String()) + + r, stdout, _ = pluginRenderer(OutputFormatJSONCompact) + r.PluginChanges([]PluginChangeResult{change, {ID: "auth0/other", Action: "skipped", Pinned: true, Available: "2.0.0"}}) + assert.JSONEq(t, `[ + {"id":"auth0/checkmate","version":"1.0.0","previousVersion":"","action":"installed","pinned":false,"approvalRequired":false}, + {"id":"auth0/other","version":"","previousVersion":"","action":"skipped","pinned":true,"approvalRequired":false,"available":"2.0.0"} + ]`, stdout.String()) + + // A person gets the lines that the command printed for each plugin, not a table + // or a CSV file. + for _, format := range []OutputFormat{"", OutputFormatCSV} { + r, stdout, stderr := pluginRenderer(format) + r.PluginChange(change) + r.PluginChanges([]PluginChangeResult{change}) + + assert.Empty(t, stdout.String(), format) + assert.Empty(t, stderr.String(), format) + } +} + +func TestRenderer_PluginLines(t *testing.T) { + r, stdout, stderr := pluginRenderer("") + + r.PluginSummary("auth0/"+hostilePluginText, "1.0.0", hostilePluginText) + r.PluginDownload(90_500_000, plugins.PlatformLinuxX64) + r.PluginUninstalled("auth0/checkmate", 1536) + + assert.Empty(t, stdout.String()) + + lines := bytes.Split(bytes.TrimSpace(stderr.Bytes()), []byte("\n")) + require.Len(t, lines, 3) + assert.Contains(t, string(lines[0]), "auth0/"+cleanPluginText+" 1.0.0 "+cleanPluginText) + assert.NotContains(t, stderr.String(), "\u202e") + assert.Contains(t, string(lines[1]), "Download: 90.5 MB (linux-x64)") + assert.Contains(t, string(lines[2]), "Uninstalled auth0/checkmate and freed 1.5 kB.") +} diff --git a/internal/plugins/version.go b/internal/plugins/version.go index 88289d117..b76c6cad5 100644 --- a/internal/plugins/version.go +++ b/internal/plugins/version.go @@ -29,6 +29,35 @@ func ParseVersion(s string) (Version, bool) { return parseCore(s) } +// isPrerelease reports whether s is a semantic version with a prerelease suffix, +// such as 1.39.0-beta.1 or v1.39.0-rc.2+build. Build metadata alone, as in +// 1.39.0+build, is not a prerelease. Anything that does not start with +// MAJOR.MINOR.PATCH is not a version, so it is not a prerelease either. +func isPrerelease(s string) bool { + s = strings.TrimPrefix(s, "v") + + end := strings.IndexAny(s, "-+") + if end < 0 || s[end] != '-' { + return false + } + + if _, ok := parseCore(s[:end]); !ok { + return false + } + + prerelease, _, _ := strings.Cut(s[end+1:], "+") + + return prerelease != "" +} + +// FeatureEnabled reports whether the plugin commands are available in a CLI +// build with the given version. They are for builds made from source, which have +// an empty version or "dev", and for prerelease builds. A stable release carries +// the code but not the feature. +func FeatureEnabled(version string) bool { + return version == "" || version == "dev" || isPrerelease(version) +} + // parseCore reads exactly MAJOR.MINOR.PATCH, with no leading zeros. func parseCore(s string) (Version, bool) { parts := strings.Split(s, ".") diff --git a/internal/plugins/version_test.go b/internal/plugins/version_test.go index d9b55856d..44e467573 100644 --- a/internal/plugins/version_test.go +++ b/internal/plugins/version_test.go @@ -71,6 +71,36 @@ func TestVersion_Compare(t *testing.T) { } } +func TestIsPrereleaseAndFeatureEnabled(t *testing.T) { + tests := []struct { + version string + prerelease bool + enabled bool + }{ + {"", false, true}, + {"dev", false, true}, + {"1.39.0", false, false}, + {"v1.39.0", false, false}, + {"1.39.0-beta.1", true, true}, + {"v1.39.0-beta.1", true, true}, + {"1.39.0-rc.2+build", true, true}, + {"1.39.0+build", false, false}, + {"1.39.0-", false, false}, + {"1.39.0-+build", false, false}, + {"1.39-beta.1", false, false}, + {"-beta.1", false, false}, + {"Dev", false, false}, + {"garbage", false, false}, + } + + for _, test := range tests { + t.Run(test.version, func(t *testing.T) { + assert.Equal(t, test.prerelease, isPrerelease(test.version)) + assert.Equal(t, test.enabled, FeatureEnabled(test.version)) + }) + } +} + func TestEnginesSatisfied(t *testing.T) { tests := []struct { name string