diff --git a/cmd/api/test_data/test_create.json b/cmd/api/test_data/test_create.json index 7a5f5dde..e46b63bd 100644 --- a/cmd/api/test_data/test_create.json +++ b/cmd/api/test_data/test_create.json @@ -1 +1 @@ -{"attributes":{"accountFilter":"!(nativeIdentity.startsWith(\"*DELETED*\"))","accountPropertyFilter":"(groups.containsAll({'Admin'}) || location == 'Austin')","accountReturnFirstLink":false,"accountSortAttribute":"created","accountSortDescending":false,"attributeName":"DEPARTMENT","input":{"attributes":{"attributeName":"first_name","sourceName":"Source"},"type":"accountAttribute"},"requiresPeriodicRefresh":false,"sourceName":"Workday"},"name":"FoxQIBxKCiWeKzaG","type":"dateFormat"} \ No newline at end of file +{"attributes":{"accountFilter":"!(nativeIdentity.startsWith(\"*DELETED*\"))","accountPropertyFilter":"(groups.containsAll({'Admin'}) || location == 'Austin')","accountReturnFirstLink":false,"accountSortAttribute":"created","accountSortDescending":false,"attributeName":"DEPARTMENT","input":{"attributes":{"attributeName":"first_name","sourceName":"Source"},"type":"accountAttribute"},"requiresPeriodicRefresh":false,"sourceName":"Workday"},"name":"QvQSzjlnSLetrnpf","type":"dateFormat"} \ No newline at end of file diff --git a/cmd/api/test_data/test_update.json b/cmd/api/test_data/test_update.json index 3bef9720..6783176c 100644 --- a/cmd/api/test_data/test_update.json +++ b/cmd/api/test_data/test_update.json @@ -1 +1 @@ -{"attributes":{"accountFilter":"!(nativeIdentity.startsWith(\"*DELETED*\"))","accountPropertyFilter":"(groups.containsAll({'Admin'}) || location == 'Austin')","accountReturnFirstLink":false,"accountSortAttribute":"created","accountSortDescending":false,"attributeName":"UPDATED_DEPARTMENT","input":{"attributes":{"attributeName":"first_name","sourceName":"Source"},"type":"accountAttribute"},"requiresPeriodicRefresh":false,"sourceName":"Workday"},"name":"FoxQIBxKCiWeKzaG","type":"dateFormat"} \ No newline at end of file +{"attributes":{"accountFilter":"!(nativeIdentity.startsWith(\"*DELETED*\"))","accountPropertyFilter":"(groups.containsAll({'Admin'}) || location == 'Austin')","accountReturnFirstLink":false,"accountSortAttribute":"created","accountSortDescending":false,"attributeName":"UPDATED_DEPARTMENT","input":{"attributes":{"attributeName":"first_name","sourceName":"Source"},"type":"accountAttribute"},"requiresPeriodicRefresh":false,"sourceName":"Workday"},"name":"QvQSzjlnSLetrnpf","type":"dateFormat"} \ No newline at end of file diff --git a/cmd/ui_plugins/rename.go b/cmd/ui_plugins/rename.go new file mode 100644 index 00000000..e7f7f0be --- /dev/null +++ b/cmd/ui_plugins/rename.go @@ -0,0 +1,358 @@ +package ui_plugins + +import ( + "bufio" + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + "os" + "strings" + + "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/util" + "github.com/spf13/cobra" +) + +//go:embed rename.md +var renameHelp string + +// renameNoChangesMsg is printed when neither the name nor the alias actually +// changes, so the command is a no-op that never touches the file or UMS. +const renameNoChangesMsg = "No changes: the name and alias are unchanged. Nothing to update." + +// renameOptions holds the resolved flag values for the rename command. The *Set +// fields record whether the flag was explicitly passed, which drives both +// whether to prompt and whether an alias rejection re-prompts or fails fast. +type renameOptions struct { + name string + alias string + nameSet bool + aliasSet bool +} + +// renameDeps carries the command's client and I/O so the orchestration is +// testable with a fake client and buffers. +type renameDeps struct { + client client.Client + in io.Reader + out io.Writer + errOut io.Writer +} + +func newRenameCommand() *cobra.Command { + var opts renameOptions + + help := util.ParseHelp(renameHelp) + cmd := &cobra.Command{ + Use: "rename", + Short: "Rename a UI plugin's display name and/or alias", + Long: help.Long, + Example: help.Example, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opts.nameSet = cmd.Flags().Changed("name") + opts.aliasSet = cmd.Flags().Changed("alias") + + spClient, err := newPluginClient() + if err != nil { + return err + } + + deps := renameDeps{ + client: spClient, + in: cmd.InOrStdin(), + out: cmd.OutOrStdout(), + errOut: cmd.ErrOrStderr(), + } + return runRename(context.Background(), deps, manifestFileName, opts) + }, + } + + cmd.Flags().StringVar(&opts.name, "name", "", "New plugin display name (updates manifest.name.en)") + cmd.Flags().StringVar(&opts.alias, "alias", "", "New tenant-unique plugin alias (stored lowercase)") + + return cmd +} + +// runRename resolves the new name/alias (from flags or interactive prompts that +// default to the current values), short-circuits when nothing changed, then +// reconciles the workspace manifest with the plugin instance in the tenant. +// +// The current alias is resolved first. If the plugin does not exist yet (404) +// the file is written and the author is pointed at `create`; if it exists (200) +// a changed alias is validated, the instance is PATCHed, and only then is the +// file written — so a failed PATCH leaves the workspace untouched. +func runRename(ctx context.Context, deps renameDeps, manifestPath string, opts renameOptions) error { + cfg, err := loadAndValidateWorkspaceManifest(manifestPath) + if err != nil { + return err + } + + r := bufio.NewReader(deps.in) + currentAlias := strings.TrimSpace(cfg.Manifest.Alias) + currentName := strings.TrimSpace(localizedName(cfg.Manifest.Name)) + + newName, err := resolveRenameName(r, deps, opts, currentName) + if err != nil { + return err + } + newAlias, err := resolveRenameAlias(r, deps, opts, currentAlias) + if err != nil { + return err + } + + nameChanged := newName != currentName + aliasChanged := newAlias != normalizeAliasInput(currentAlias) + + if !nameChanged && !aliasChanged { + fmt.Fprintln(deps.out, renameNoChangesMsg) + return nil + } + + instance, notFound, err := resolvePluginForRename(ctx, deps.client, currentAlias) + if err != nil { + // 409 (ambiguous), any other non-200, or a transport error: stop before + // writing anything. + return err + } + + if aliasChanged { + finalAlias, stillChanged, verr := validateAliasForRename(ctx, deps, r, currentAlias, newAlias, opts.aliasSet) + if verr != nil { + return verr + } + newAlias, aliasChanged = finalAlias, stillChanged + } + + // Re-prompting an alias back to the current value cancels the alias change, + // which can leave nothing to do. + if !nameChanged && !aliasChanged { + fmt.Fprintln(deps.out, renameNoChangesMsg) + return nil + } + + if notFound { + if err := writeRenamedManifest(manifestPath, newName, nameChanged, newAlias, aliasChanged); err != nil { + return err + } + fmt.Fprintf(deps.out, "Updated %s. The plugin has not been created in this tenant yet.\n", manifestFileName) + fmt.Fprint(deps.out, "\nNext steps:\n sail ui-plugins create\n") + return nil + } + + effectiveAlias := instance.Alias + if aliasChanged { + effectiveAlias = newAlias + } + + if err := patchRename(ctx, deps.client, instance, newName, nameChanged, newAlias, aliasChanged, effectiveAlias); err != nil { + return err + } + if err := writeRenamedManifest(manifestPath, newName, nameChanged, newAlias, aliasChanged); err != nil { + return fmt.Errorf("the plugin instance was updated in the tenant, but writing %s failed: %w", manifestFileName, err) + } + + fmt.Fprintf(deps.out, "Renamed plugin instance %s (alias: %s)\nUpdated %s\n", instance.PluginInstanceID, effectiveAlias, manifestFileName) + if viewURL := pluginViewURL(config.GetTenantUrl(), instance.PluginInstanceID); viewURL != "" { + fmt.Fprintf(deps.out, "The plugin can be viewed at:\n%s\n", viewURL) + } + if aliasChanged { + fmt.Fprint(deps.out, "\nNext steps:\n sail ui-plugins link # run again if you already linked, to get the new dev URL\n") + } + return nil +} + +// resolveRenameName returns the new display name: the --name value when passed, +// otherwise an interactive prompt defaulting to the current name (Enter keeps +// it). An explicitly empty --name is rejected. +func resolveRenameName(r *bufio.Reader, deps renameDeps, opts renameOptions, currentName string) (string, error) { + if opts.nameSet { + name := strings.TrimSpace(opts.name) + if name == "" { + return "", fmt.Errorf("plugin name cannot be empty") + } + return name, nil + } + v, err := promptLine(r, deps.out, "Plugin Name", currentName) + if err != nil { + return "", err + } + return strings.TrimSpace(v), nil +} + +// resolveRenameAlias returns the new alias (trimmed + lowercased): the --alias +// value when passed, otherwise an interactive prompt defaulting to the current +// alias (Enter keeps it). An explicitly empty --alias is rejected. +func resolveRenameAlias(r *bufio.Reader, deps renameDeps, opts renameOptions, currentAlias string) (string, error) { + if opts.aliasSet { + alias := normalizeAliasInput(opts.alias) + if alias == "" { + return "", fmt.Errorf("plugin alias cannot be empty") + } + return alias, nil + } + v, err := promptLine(r, deps.out, "Plugin Alias", currentAlias) + if err != nil { + return "", err + } + return normalizeAliasInput(v), nil +} + +// resolvePluginForRename resolves the current alias to its plugin instance, +// distinguishing the states the rename flow branches on: notFound (404, not yet +// created), a resolved instance (2xx), or an error for 409 (ambiguous alias, +// reported with the conflicting ids) and any other non-2xx or transport failure. +func resolvePluginForRename(ctx context.Context, c client.Client, alias string) (inst *pluginInstance, notFound bool, err error) { + url := resolveAliasEndpoint + "?alias=" + neturl.QueryEscape(alias) + resp, err := c.Get(ctx, url, uiPluginRequestHeaders()) + if err != nil { + return nil, false, fmt.Errorf("failed to resolve alias %q: %w", alias, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, fmt.Errorf("failed to read response: %w", err) + } + + switch { + case resp.StatusCode == http.StatusNotFound: + return nil, true, nil + case resp.StatusCode == http.StatusConflict: + return nil, false, ambiguousAliasError(alias, body) + case resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices: + var pi pluginInstance + if err := json.Unmarshal(body, &pi); err != nil { + return nil, false, fmt.Errorf("failed to parse plugin instance: %w", err) + } + return &pi, false, nil + default: + return nil, false, mapUMSLookupError(resp.StatusCode, body, alias) + } +} + +// validateAliasForRename validates a changed alias against UMS. A definitive +// rejection — invalid (400) or already taken (409) — re-prompts for a +// replacement when the alias came from an interactive prompt, up to +// maxAliasPrompts; an alias supplied via --alias fails immediately. Entering the +// current alias at a re-prompt cancels the alias change (returns changed=false). +// Inconclusive checks are surfaced as warnings by checkAlias and accepted. +func validateAliasForRename(ctx context.Context, deps renameDeps, r *bufio.Reader, currentAlias, alias string, aliasFromFlag bool) (string, bool, error) { + currentNorm := normalizeAliasInput(currentAlias) + for attempt := 0; attempt < maxAliasPrompts; attempt++ { + err := checkAlias(ctx, deps.client, deps.errOut, alias) + if err == nil { + return alias, true, nil + } + if aliasFromFlag { + return "", false, err + } + fmt.Fprintf(deps.out, "%v — please choose a different alias.\n", err) + v, perr := promptLine(r, deps.out, "Plugin Alias", currentAlias) + if perr != nil { + return "", false, perr + } + next := normalizeAliasInput(v) + if next == currentNorm { + return currentAlias, false, nil + } + alias = next + } + return "", false, fmt.Errorf("no valid alias provided after %d attempts", maxAliasPrompts) +} + +// patchRename sends only the changed fields to UMS. The name payload starts from +// the resolved instance's name map so other locales survive — UMS replaces the +// whole map — with the English entry set to the new name. +func patchRename(ctx context.Context, c client.Client, instance *pluginInstance, newName string, nameChanged bool, newAlias string, aliasChanged bool, aliasForMsg string) error { + payload := map[string]any{} + if aliasChanged { + payload["alias"] = newAlias + } + if nameChanged { + nameMap := make(map[string]string, len(instance.Name)+1) + for k, v := range instance.Name { + nameMap[k] = v + } + nameMap["en"] = newName + payload["name"] = nameMap + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode rename payload: %w", err) + } + + // Patch does not default a Content-Type, so the caller must set it or UMS + // receives an empty body and silently applies no changes. + headers := uiPluginRequestHeaders() + headers["Content-Type"] = "application/json" + + url := pluginInstancesEndpoint + "/" + neturl.PathEscape(instance.PluginInstanceID) + resp, err := c.Patch(ctx, url, bytes.NewReader(data), headers) + if err != nil { + return fmt.Errorf("failed to update plugin instance: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return mapUMSUpdateError(resp.StatusCode, body, aliasForMsg) + } + return nil +} + +// writeRenamedManifest edits only manifest.alias and manifest.name.en in place, +// leaving every other field untouched. It operates on the generic JSON tree +// (not the typed model) so fields the typed model would normalize — an omitted +// state, apiScopes ordering — are preserved verbatim in value. +func writeRenamedManifest(path, newName string, nameChanged bool, newAlias string, aliasChanged bool) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("unable to read %s: %w", path, err) + } + + var root map[string]any + if err := json.Unmarshal(raw, &root); err != nil { + return fmt.Errorf("invalid %s: %w", path, err) + } + + manifest, ok := root["manifest"].(map[string]any) + if !ok { + return fmt.Errorf("invalid %s: manifest section is missing or malformed", path) + } + + if aliasChanged { + manifest["alias"] = newAlias + } + if nameChanged { + nameMap, ok := manifest["name"].(map[string]any) + if !ok { + nameMap = map[string]any{} + manifest["name"] = nameMap + } + nameMap["en"] = newName + } + + data, err := json.MarshalIndent(root, "", " ") + if err != nil { + return fmt.Errorf("failed to encode %s: %w", manifestFileName, err) + } + data = append(data, '\n') + return writeFileAtomic(path, data) +} + +// normalizeAliasInput canonicalizes alias input to the form UMS stores: trimmed +// and lowercased. Comparisons and the persisted value both use this form. +func normalizeAliasInput(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} diff --git a/cmd/ui_plugins/rename.md b/cmd/ui_plugins/rename.md new file mode 100644 index 00000000..81d264a0 --- /dev/null +++ b/cmd/ui_plugins/rename.md @@ -0,0 +1,42 @@ +==Long== +# Rename + +Renames a UI plugin's display name and/or alias, keeping your local workspace and the plugin instance in your tenant in sync. Without it, a rename means editing `sp-ui-plugin.json` by hand and remembering to push the change. + +Run from a plugin workspace — the directory that contains `./sp-ui-plugin.json`. + +## What it changes + +- `--name` updates the English display name (`manifest.name.en`). +- `--alias` updates the tenant-unique alias (`manifest.alias`), stored lowercase. + +Only those two fields are touched. Your description, other locales, the local `build` section, and every other project file are left unchanged. Static assets are not uploaded — use `upload` for that. + +## Prompts + +Omit a flag and you are prompted for it, defaulting to the current value — press Enter to keep it. A value passed as a flag is not prompted for. If neither the name nor the alias actually changes, nothing is written and the tenant is not called. + +## How a changed alias is applied + +The current alias is resolved against your tenant first: + +- If the plugin has not been created yet, the file is updated and you are pointed at `create`. +- If it already exists, a changed alias is checked for availability, the plugin instance is updated, and only then is the file written — so a failed update leaves your workspace untouched. + +Changing the alias changes your local development URL, so re-run `link` afterwards if you had already linked. + +Requires the `idn:plugins-ui:update` right. +==== + +==Example== +```bash +# Prompt for the new name and alias, each defaulting to the current value +sail ui-plugins rename + +# Rename non-interactively +sail ui-plugins rename --name "Access Requests" --alias access-requests + +# Change only the alias +sail ui-plugins rename --alias access-requests +``` +==== diff --git a/cmd/ui_plugins/rename_test.go b/cmd/ui_plugins/rename_test.go new file mode 100644 index 00000000..c0328a58 --- /dev/null +++ b/cmd/ui_plugins/rename_test.go @@ -0,0 +1,253 @@ +package ui_plugins + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "strings" + "testing" +) + +// resolveRenameBody is a resolve-alias 200 response for the testManifestJSON +// fixture, carrying a name map so the name-change PATCH path can preserve other +// locales. +const resolveRenameBody = `{"pluginInstanceId":"pi-123","alias":"access-request-plugin","name":{"en-US":"Access Request"},"slots":[{"slotId":"full-page"}]}` + +// readManifest reads the manifest file back and returns its manifest subsection +// as a generic map for field-level assertions. +func readManifest(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var root map[string]any + if err := json.Unmarshal(raw, &root); err != nil { + t.Fatalf("parse manifest: %v", err) + } + m, ok := root["manifest"].(map[string]any) + if !ok { + t.Fatalf("manifest section missing in %s", path) + } + return m +} + +func renameTestDeps(c *seqClient, stdin string, out, errOut *bytes.Buffer) renameDeps { + return renameDeps{client: c, in: strings.NewReader(stdin), out: out, errOut: errOut} +} + +func TestRunRename_NoChange_DoesNotTouchUMSOrFile(t *testing.T) { + c := &seqClient{} + var out bytes.Buffer + path := tempManifestPath(t, testManifestJSON) + + opts := renameOptions{name: "Access Request", nameSet: true, alias: "access-request-plugin", aliasSet: true} + if err := runRename(context.Background(), renameTestDeps(c, "", &out, &bytes.Buffer{}), path, opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(c.getURLs) != 0 { + t.Fatalf("expected no UMS calls on a no-op rename, got Gets: %v", c.getURLs) + } + if c.patchCalls != 0 { + t.Fatalf("expected no PATCH on a no-op rename, got %d", c.patchCalls) + } + if !strings.Contains(out.String(), "No changes") { + t.Fatalf("expected a no-change message, got: %s", out.String()) + } +} + +func TestRunRename_NameOnly_ExistingPlugin(t *testing.T) { + c := &seqClient{ + getQueue: []stubResp{{status: http.StatusOK, body: resolveRenameBody}}, + patchResp: stubResp{status: http.StatusOK, body: `{}`}, + } + var out bytes.Buffer + path := tempManifestPath(t, testManifestJSON) + + opts := renameOptions{name: "New Name", nameSet: true} + if err := runRename(context.Background(), renameTestDeps(c, "", &out, &bytes.Buffer{}), path, opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if c.patchCalls != 1 { + t.Fatalf("expected one PATCH, got %d", c.patchCalls) + } + body := string(c.patchBody) + if !strings.Contains(body, `"New Name"`) || !strings.Contains(body, "en-US") { + t.Fatalf("expected name map with new en and preserved en-US, got: %s", body) + } + if strings.Contains(body, `"alias"`) { + t.Fatalf("alias must not be sent when only the name changed, got: %s", body) + } + if c.patchHeaders["Content-Type"] != "application/json" { + t.Fatalf("expected Content-Type application/json, got: %v", c.patchHeaders) + } + + m := readManifest(t, path) + name := m["name"].(map[string]any) + if name["en"] != "New Name" { + t.Fatalf("expected file name.en updated, got: %v", name) + } + if name["en-US"] != "Access Request" { + t.Fatalf("expected file name.en-US preserved, got: %v", name) + } + if m["alias"] != "access-request-plugin" { + t.Fatalf("expected file alias unchanged, got: %v", m["alias"]) + } + if strings.Contains(out.String(), "ui-plugins link") { + t.Fatalf("link next-step should only print on an alias change, got: %s", out.String()) + } +} + +func TestRunRename_AliasChange_ExistingPlugin_NormalizesAndLinksHint(t *testing.T) { + c := &seqClient{ + getQueue: []stubResp{ + {status: http.StatusOK, body: resolveRenameBody}, // resolve current alias + {status: http.StatusOK, body: ``}, // validate-alias (available) + }, + patchResp: stubResp{status: http.StatusOK, body: `{}`}, + } + var out bytes.Buffer + path := tempManifestPath(t, testManifestJSON) + withTenantURL(t, "https://tenant.example.com") + + // Mixed-case input must be normalized to lowercase. + opts := renameOptions{alias: "New-Alias", aliasSet: true} + if err := runRename(context.Background(), renameTestDeps(c, "", &out, &bytes.Buffer{}), path, opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(c.getURLs) != 2 { + t.Fatalf("expected resolve + validate-alias Gets, got: %v", c.getURLs) + } + if !strings.Contains(c.getURLs[1], "validate-alias") || !strings.Contains(c.getURLs[1], "new-alias") { + t.Fatalf("expected validate-alias on normalized new alias, got: %s", c.getURLs[1]) + } + body := string(c.patchBody) + if !strings.Contains(body, `"alias":"new-alias"`) { + t.Fatalf("expected lowercased alias in PATCH, got: %s", body) + } + if strings.Contains(body, `"name"`) { + t.Fatalf("name must not be sent when only the alias changed, got: %s", body) + } + if m := readManifest(t, path); m["alias"] != "new-alias" { + t.Fatalf("expected file alias normalized to new-alias, got: %v", m["alias"]) + } + if !strings.Contains(out.String(), "ui-plugins link") { + t.Fatalf("expected link next-step after an alias change, got: %s", out.String()) + } + if !strings.Contains(out.String(), "The plugin can be viewed at:\nhttps://tenant.example.com/ui/plugin/pi-123") { + t.Fatalf("expected the plugin view URL (as upload renders it), got: %s", out.String()) + } +} + +func TestRunRename_AliasChange_NotCreated_WritesFileNoPatch(t *testing.T) { + c := &seqClient{ + getQueue: []stubResp{ + {status: http.StatusNotFound, body: `{"message":"not found"}`}, // resolve -> 404 + {status: http.StatusOK, body: ``}, // validate-alias + }, + } + var out bytes.Buffer + path := tempManifestPath(t, testManifestJSON) + + opts := renameOptions{alias: "fresh-alias", aliasSet: true} + if err := runRename(context.Background(), renameTestDeps(c, "", &out, &bytes.Buffer{}), path, opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if c.patchCalls != 0 { + t.Fatalf("expected no PATCH when the plugin does not exist, got %d", c.patchCalls) + } + if m := readManifest(t, path); m["alias"] != "fresh-alias" { + t.Fatalf("expected file alias written, got: %v", m["alias"]) + } + got := out.String() + if !strings.Contains(got, "not been created") || !strings.Contains(got, "ui-plugins create") { + t.Fatalf("expected create next-step for an uncreated plugin, got: %s", got) + } + if strings.Contains(got, "ui-plugins link") { + t.Fatalf("link next-step must be omitted when the plugin does not exist, got: %s", got) + } +} + +func TestRunRename_AmbiguousResolve_StopsBeforeWriting(t *testing.T) { + c := &seqClient{ + getQueue: []stubResp{{status: http.StatusConflict, body: `{"conflicts":[{"pluginInstanceId":"pi-1"},{"pluginInstanceId":"pi-2"}]}`}}, + } + var out bytes.Buffer + path := tempManifestPath(t, testManifestJSON) + + opts := renameOptions{alias: "whatever", aliasSet: true} + err := runRename(context.Background(), renameTestDeps(c, "", &out, &bytes.Buffer{}), path, opts) + if err == nil { + t.Fatalf("expected an error on an ambiguous resolve") + } + if !strings.Contains(err.Error(), "pi-1") || !strings.Contains(err.Error(), "pi-2") { + t.Fatalf("expected conflicting ids in the error, got: %v", err) + } + if c.patchCalls != 0 { + t.Fatalf("expected no PATCH, got %d", c.patchCalls) + } + if m := readManifest(t, path); m["alias"] != "access-request-plugin" { + t.Fatalf("file must be untouched on an ambiguous resolve, got alias: %v", m["alias"]) + } +} + +func TestRunRename_FlaggedAliasTaken_FailsFast(t *testing.T) { + c := &seqClient{ + getQueue: []stubResp{ + {status: http.StatusOK, body: resolveRenameBody}, // resolve -> exists + {status: http.StatusConflict, body: `{"message":"in use"}`}, // validate-alias -> taken + }, + } + var out bytes.Buffer + path := tempManifestPath(t, testManifestJSON) + + opts := renameOptions{alias: "taken-alias", aliasSet: true} + err := runRename(context.Background(), renameTestDeps(c, "", &out, &bytes.Buffer{}), path, opts) + if err == nil { + t.Fatalf("expected an error when a flagged alias is already taken") + } + if c.patchCalls != 0 { + t.Fatalf("expected no PATCH on a taken flagged alias, got %d", c.patchCalls) + } + if m := readManifest(t, path); m["alias"] != "access-request-plugin" { + t.Fatalf("file must be untouched on validation failure, got alias: %v", m["alias"]) + } +} + +func TestRunRename_InteractiveAliasReprompt(t *testing.T) { + c := &seqClient{ + getQueue: []stubResp{ + {status: http.StatusOK, body: resolveRenameBody}, // resolve -> exists + {status: http.StatusConflict, body: `{"message":"in use"}`}, // validate taken-alias + {status: http.StatusOK, body: ``}, // validate good-alias + }, + patchResp: stubResp{status: http.StatusOK, body: `{}`}, + } + var out bytes.Buffer + // Name prompt: Enter (keep). Alias prompt: taken-alias, then good-alias. + stdin := "\ntaken-alias\ngood-alias\n" + path := tempManifestPath(t, testManifestJSON) + + if err := runRename(context.Background(), renameTestDeps(c, stdin, &out, &bytes.Buffer{}), path, renameOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if c.patchCalls != 1 { + t.Fatalf("expected one PATCH after a successful re-prompt, got %d", c.patchCalls) + } + if !strings.Contains(string(c.patchBody), `"alias":"good-alias"`) { + t.Fatalf("expected the re-prompted alias in PATCH, got: %s", c.patchBody) + } + if !strings.Contains(out.String(), "please choose a different alias") { + t.Fatalf("expected a re-prompt message, got: %s", out.String()) + } + if m := readManifest(t, path); m["alias"] != "good-alias" { + t.Fatalf("expected file alias set to the re-prompted value, got: %v", m["alias"]) + } +} diff --git a/cmd/ui_plugins/ui_plugins.go b/cmd/ui_plugins/ui_plugins.go index 05be3ec2..563fb682 100644 --- a/cmd/ui_plugins/ui_plugins.go +++ b/cmd/ui_plugins/ui_plugins.go @@ -29,6 +29,7 @@ func NewUIPluginsCommand() *cobra.Command { newLinkCommand(), newUnlinkCommand(), newUpdateCommand(), + newRenameCommand(), newUploadCommand(), newListCommand(), newDeleteCommand(), diff --git a/cmd/ui_plugins/ui_plugins.md b/cmd/ui_plugins/ui_plugins.md index 08b12677..bea59575 100644 --- a/cmd/ui_plugins/ui_plugins.md +++ b/cmd/ui_plugins/ui_plugins.md @@ -18,6 +18,8 @@ Manage UI Plugin workflows in Identity Security Cloud. Run any command with `--help` for details. `validate-manifest` (alias: `validate`) performs offline structural validation of `./sp-ui-plugin.json` without calling UMS. +`rename` changes a plugin's display name or alias after creation, updating +`./sp-ui-plugin.json` and the plugin instance together. ==== ==Example== diff --git a/cmd/ui_plugins/ui_plugins_test.go b/cmd/ui_plugins/ui_plugins_test.go index 8f75d4ae..1fa0df6e 100644 --- a/cmd/ui_plugins/ui_plugins_test.go +++ b/cmd/ui_plugins/ui_plugins_test.go @@ -17,11 +17,11 @@ func TestNewUIPluginsCommandStructure(t *testing.T) { t.Fatal("expected ui-plugins command to be visible") } - if len(cmd.Commands()) != 11 { - t.Fatalf("expected 11 subcommands, got %d", len(cmd.Commands())) + if len(cmd.Commands()) != 12 { + t.Fatalf("expected 12 subcommands, got %d", len(cmd.Commands())) } - for _, name := range []string{"disable", "enable"} { + for _, name := range []string{"disable", "enable", "rename"} { if !hasSubcommand(cmd, name) { t.Fatalf("expected %q subcommand to be registered", name) }