From 54dd619253f62a916067d03138dc9db392e5fcf7 Mon Sep 17 00:00:00 2001 From: Jeremy Gooch Date: Mon, 22 Jun 2026 13:39:24 -0500 Subject: [PATCH 1/5] CSTM-191: Adding manifest validation for ui-plugin workspaces. Additionally, adding command with alias to just run validation for plugin manifest. --- cmd/ui_plugins/manifest_model.go | 28 ++++ cmd/ui_plugins/manifest_validation.go | 93 +++++++++++++ cmd/ui_plugins/manifest_validation_test.go | 144 +++++++++++++++++++++ cmd/ui_plugins/ui_plugins.go | 1 + cmd/ui_plugins/ui_plugins_test.go | 4 +- cmd/ui_plugins/validate_manifest.go | 26 ++++ cmd/ui_plugins/validate_manifest_test.go | 109 ++++++++++++++++ 7 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 cmd/ui_plugins/manifest_model.go create mode 100644 cmd/ui_plugins/manifest_validation.go create mode 100644 cmd/ui_plugins/manifest_validation_test.go create mode 100644 cmd/ui_plugins/validate_manifest.go create mode 100644 cmd/ui_plugins/validate_manifest_test.go diff --git a/cmd/ui_plugins/manifest_model.go b/cmd/ui_plugins/manifest_model.go new file mode 100644 index 00000000..9405e91a --- /dev/null +++ b/cmd/ui_plugins/manifest_model.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. +package ui_plugins + +// uiPluginWorkspaceConfig represents the sp-ui-plugin.json workspace contract. +type uiPluginWorkspaceConfig struct { + Version int `json:"version"` + Manifest uiPluginManifest `json:"manifest"` + Build *uiPluginBuildConfig `json:"build,omitempty"` +} + +// uiPluginManifest is the backend-facing payload section. +type uiPluginManifest struct { + Alias string `json:"alias"` + Name map[string]string `json:"name"` + Description map[string]string `json:"description"` + APIScopes []string `json:"apiScopes,omitempty"` + ContentSecurityPolicies map[string][]string `json:"contentSecurityPolicies,omitempty"` + PermissionPolicy map[string][]string `json:"permissionPolicy,omitempty"` + IframeAllow string `json:"iframeAllow,omitempty"` + Slots []string `json:"slots"` +} + +// uiPluginBuildConfig is local CLI-only config and never sent to backend. +type uiPluginBuildConfig struct { + OutDir string `json:"outDir,omitempty"` + Port *int `json:"port,omitempty"` +} + diff --git a/cmd/ui_plugins/manifest_validation.go b/cmd/ui_plugins/manifest_validation.go new file mode 100644 index 00000000..4cd278b2 --- /dev/null +++ b/cmd/ui_plugins/manifest_validation.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. +package ui_plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +const ( + manifestFileName = "sp-ui-plugin.json" + supportedVersion1 = 1 +) + +// loadAndValidateWorkspaceManifest is the shared full-validation entrypoint for +// ui-plugins commands. It performs strict parse + semantic validation. +func loadAndValidateWorkspaceManifest(path string) (*uiPluginWorkspaceConfig, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("unable to read %s: %w", path, err) + } + + cfg, err := parseWorkspaceManifestStrict(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", path, err) + } + + if err := validateWorkspaceManifest(cfg); err != nil { + return nil, fmt.Errorf("invalid %s: %w", path, err) + } + + return cfg, nil +} + +func parseWorkspaceManifestStrict(raw []byte) (*uiPluginWorkspaceConfig, error) { + var cfg uiPluginWorkspaceConfig + + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return nil, err + } + + // Enforce a single JSON object in file. + if err := dec.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("manifest must contain a single JSON object") + } + + return &cfg, nil +} + +func validateWorkspaceManifest(cfg *uiPluginWorkspaceConfig) error { + if cfg.Version == 0 { + return fmt.Errorf("version is required") + } + if cfg.Version != supportedVersion1 { + return fmt.Errorf("unsupported version %d (supported: %d)", cfg.Version, supportedVersion1) + } + + manifest := cfg.Manifest + if strings.TrimSpace(manifest.Alias) == "" { + return fmt.Errorf("manifest.alias is required") + } + if len(manifest.Name) == 0 { + return fmt.Errorf("manifest.name is required and must contain at least one locale entry") + } + if len(manifest.Description) == 0 { + return fmt.Errorf("manifest.description is required and must contain at least one locale entry") + } + if len(manifest.Slots) == 0 { + return fmt.Errorf("manifest.slots is required and must contain at least one slot") + } + for i, slot := range manifest.Slots { + if strings.TrimSpace(slot) == "" { + return fmt.Errorf("manifest.slots[%d] must not be empty", i) + } + } + + if cfg.Build != nil { + if cfg.Build.Port != nil && *cfg.Build.Port <= 0 { + return fmt.Errorf("build.port must be greater than 0") + } + if strings.TrimSpace(cfg.Build.OutDir) == "" && cfg.Build.OutDir != "" { + return fmt.Errorf("build.outDir must not be empty when provided") + } + } + + return nil +} + diff --git a/cmd/ui_plugins/manifest_validation_test.go b/cmd/ui_plugins/manifest_validation_test.go new file mode 100644 index 00000000..4c695095 --- /dev/null +++ b/cmd/ui_plugins/manifest_validation_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. +package ui_plugins + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadAndValidateWorkspaceManifest_Valid(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + }, + "build": { + "outDir": "./dist", + "port": 4200 + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + if cfg.Version != 1 { + t.Fatalf("expected version 1, got %d", cfg.Version) + } +} + +func TestLoadAndValidateWorkspaceManifest_MissingVersion(t *testing.T) { + path := writeManifestFixture(t, `{ + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected missing version to fail") + } + if !strings.Contains(err.Error(), "version is required") { + t.Fatalf("expected version error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_UnknownField(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"], + "unexpectedField": true + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected unknown field to fail") + } + if !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("expected unknown field error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_TypeMismatch(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": 123, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected type mismatch to fail") + } + if !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected unmarshal type error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_MissingRequiredManifestField(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected missing alias to fail") + } + if !strings.Contains(err.Error(), "manifest.alias is required") { + t.Fatalf("expected alias required error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_UnsupportedVersion(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 2, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected unsupported version to fail") + } + if !strings.Contains(err.Error(), "unsupported version") { + t.Fatalf("expected unsupported version error, got: %v", err) + } +} + +func writeManifestFixture(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, manifestFileName) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write manifest fixture: %v", err) + } + return path +} + diff --git a/cmd/ui_plugins/ui_plugins.go b/cmd/ui_plugins/ui_plugins.go index 00f71133..9dba4ab2 100644 --- a/cmd/ui_plugins/ui_plugins.go +++ b/cmd/ui_plugins/ui_plugins.go @@ -42,6 +42,7 @@ func NewUIPluginsCommand() *cobra.Command { newUploadCommand(), newListCommand(), newDeleteCommand(), + newValidateManifestCommand(), ) return cmd diff --git a/cmd/ui_plugins/ui_plugins_test.go b/cmd/ui_plugins/ui_plugins_test.go index 4c88e4e6..7ab539ad 100644 --- a/cmd/ui_plugins/ui_plugins_test.go +++ b/cmd/ui_plugins/ui_plugins_test.go @@ -17,8 +17,8 @@ func TestNewUIPluginsCommandStructure(t *testing.T) { t.Fatal("expected ui-plugins command to be hidden") } - if len(cmd.Commands()) != 7 { - t.Fatalf("expected 7 subcommands, got %d", len(cmd.Commands())) + if len(cmd.Commands()) != 8 { + t.Fatalf("expected 8 subcommands, got %d", len(cmd.Commands())) } } diff --git a/cmd/ui_plugins/validate_manifest.go b/cmd/ui_plugins/validate_manifest.go new file mode 100644 index 00000000..a8296cef --- /dev/null +++ b/cmd/ui_plugins/validate_manifest.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. +package ui_plugins + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newValidateManifestCommand() *cobra.Command { + return &cobra.Command{ + Use: "validate-manifest", + Aliases: []string{"validate"}, + Short: "Validate the plugin workspace manifest", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := loadAndValidateWorkspaceManifest(manifestFileName) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Manifest is valid.") + return nil + }, + } +} + diff --git a/cmd/ui_plugins/validate_manifest_test.go b/cmd/ui_plugins/validate_manifest_test.go new file mode 100644 index 00000000..dd0cfb8c --- /dev/null +++ b/cmd/ui_plugins/validate_manifest_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. +package ui_plugins + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateManifestCommand_Success(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"validate-manifest"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("expected validate-manifest to succeed, got: %v", err) + } + if !strings.Contains(out.String(), "Manifest is valid.") { + t.Fatalf("expected success message, got: %s", out.String()) + } +} + +func TestValidateManifestCommand_AliasSuccess(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + cmd.SetArgs([]string{"validate"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("expected validate alias to succeed, got: %v", err) + } +} + +func TestValidateManifestCommand_Failure(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + cmd.SetArgs([]string{"validate-manifest"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected validation failure") + } + if !strings.Contains(err.Error(), "manifest.alias is required") { + t.Fatalf("expected actionable alias error, got: %v", err) + } +} + +func chdirForTest(t *testing.T, target string) func() { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + if err := os.Chdir(target); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + return func() { + _ = os.Chdir(orig) + } +} + +func writeManifestAtPath(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write manifest fixture: %v", err) + } +} + From 9257de53049d4392579c47163dc85502e40980d5 Mon Sep 17 00:00:00 2001 From: Jeremy Gooch Date: Mon, 22 Jun 2026 14:22:48 -0500 Subject: [PATCH 2/5] CSTM-191: Updating slots to expect the full slot structure rather than just slotId as a list of strings --- cmd/ui_plugins/manifest_model.go | 9 +- cmd/ui_plugins/manifest_validation.go | 4 +- cmd/ui_plugins/manifest_validation_test.go | 106 +++++++++++++++++++-- cmd/ui_plugins/validate_manifest_test.go | 6 +- 4 files changed, 113 insertions(+), 12 deletions(-) diff --git a/cmd/ui_plugins/manifest_model.go b/cmd/ui_plugins/manifest_model.go index 9405e91a..3685f4c7 100644 --- a/cmd/ui_plugins/manifest_model.go +++ b/cmd/ui_plugins/manifest_model.go @@ -8,6 +8,13 @@ type uiPluginWorkspaceConfig struct { Build *uiPluginBuildConfig `json:"build,omitempty"` } +// uiPluginSlot declares a UI extension point the plugin occupies. +type uiPluginSlot struct { + SlotID string `json:"slotId"` + RequiredCapabilities []string `json:"requiredCapabilities,omitempty"` + RestrictToUsers []string `json:"restrictToUsers,omitempty"` +} + // uiPluginManifest is the backend-facing payload section. type uiPluginManifest struct { Alias string `json:"alias"` @@ -17,7 +24,7 @@ type uiPluginManifest struct { ContentSecurityPolicies map[string][]string `json:"contentSecurityPolicies,omitempty"` PermissionPolicy map[string][]string `json:"permissionPolicy,omitempty"` IframeAllow string `json:"iframeAllow,omitempty"` - Slots []string `json:"slots"` + Slots []uiPluginSlot `json:"slots"` } // uiPluginBuildConfig is local CLI-only config and never sent to backend. diff --git a/cmd/ui_plugins/manifest_validation.go b/cmd/ui_plugins/manifest_validation.go index 4cd278b2..52998c72 100644 --- a/cmd/ui_plugins/manifest_validation.go +++ b/cmd/ui_plugins/manifest_validation.go @@ -74,8 +74,8 @@ func validateWorkspaceManifest(cfg *uiPluginWorkspaceConfig) error { return fmt.Errorf("manifest.slots is required and must contain at least one slot") } for i, slot := range manifest.Slots { - if strings.TrimSpace(slot) == "" { - return fmt.Errorf("manifest.slots[%d] must not be empty", i) + if strings.TrimSpace(slot.SlotID) == "" { + return fmt.Errorf("manifest.slots[%d].slotId is required", i) } } diff --git a/cmd/ui_plugins/manifest_validation_test.go b/cmd/ui_plugins/manifest_validation_test.go index 4c695095..ab32b0e6 100644 --- a/cmd/ui_plugins/manifest_validation_test.go +++ b/cmd/ui_plugins/manifest_validation_test.go @@ -15,7 +15,7 @@ func TestLoadAndValidateWorkspaceManifest_Valid(t *testing.T) { "alias": "access-request-plugin", "name": {"en-US": "Access Request"}, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"] + "slots": [{"slotId": "full-page"}] }, "build": { "outDir": "./dist", @@ -30,10 +30,45 @@ func TestLoadAndValidateWorkspaceManifest_Valid(t *testing.T) { if cfg.Version != 1 { t.Fatalf("expected version 1, got %d", cfg.Version) } + if len(cfg.Manifest.Slots) != 1 || cfg.Manifest.Slots[0].SlotID != "full-page" { + t.Fatalf("expected one full-page slot, got: %+v", cfg.Manifest.Slots) + } } -func TestLoadAndValidateWorkspaceManifest_MissingVersion(t *testing.T) { +func TestLoadAndValidateWorkspaceManifest_ValidSlotWithOptionalFields(t *testing.T) { path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{ + "slotId": "full-page", + "requiredCapabilities": ["ORG_ADMIN"], + "restrictToUsers": ["2c9180827f9b911e017f9b9122340000"] + }] + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + slot := cfg.Manifest.Slots[0] + if slot.SlotID != "full-page" { + t.Fatalf("expected slotId full-page, got %q", slot.SlotID) + } + if len(slot.RequiredCapabilities) != 1 || slot.RequiredCapabilities[0] != "ORG_ADMIN" { + t.Fatalf("unexpected requiredCapabilities: %+v", slot.RequiredCapabilities) + } + if len(slot.RestrictToUsers) != 1 { + t.Fatalf("unexpected restrictToUsers: %+v", slot.RestrictToUsers) + } +} + +func TestLoadAndValidateWorkspaceManifest_LegacyStringSlotsRejected(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, "manifest": { "alias": "access-request-plugin", "name": {"en-US": "Access Request"}, @@ -42,6 +77,65 @@ func TestLoadAndValidateWorkspaceManifest_MissingVersion(t *testing.T) { } }`) + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected legacy string slots to fail") + } + if !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected unmarshal type error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_MissingSlotID(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected missing slotId to fail") + } + if !strings.Contains(err.Error(), "manifest.slots[0].slotId is required") { + t.Fatalf("expected slotId required error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_UnknownSlotField(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page", "unexpectedField": true}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected unknown slot field to fail") + } + if !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("expected unknown field error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_MissingVersion(t *testing.T) { + path := writeManifestFixture(t, `{ + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + _, err := loadAndValidateWorkspaceManifest(path) if err == nil { t.Fatal("expected missing version to fail") @@ -58,7 +152,7 @@ func TestLoadAndValidateWorkspaceManifest_UnknownField(t *testing.T) { "alias": "access-request-plugin", "name": {"en-US": "Access Request"}, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"], + "slots": [{"slotId": "full-page"}], "unexpectedField": true } }`) @@ -79,7 +173,7 @@ func TestLoadAndValidateWorkspaceManifest_TypeMismatch(t *testing.T) { "alias": "access-request-plugin", "name": 123, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"] + "slots": [{"slotId": "full-page"}] } }`) @@ -98,7 +192,7 @@ func TestLoadAndValidateWorkspaceManifest_MissingRequiredManifestField(t *testin "manifest": { "name": {"en-US": "Access Request"}, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"] + "slots": [{"slotId": "full-page"}] } }`) @@ -118,7 +212,7 @@ func TestLoadAndValidateWorkspaceManifest_UnsupportedVersion(t *testing.T) { "alias": "access-request-plugin", "name": {"en-US": "Access Request"}, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"] + "slots": [{"slotId": "full-page"}] } }`) diff --git a/cmd/ui_plugins/validate_manifest_test.go b/cmd/ui_plugins/validate_manifest_test.go index dd0cfb8c..4533e015 100644 --- a/cmd/ui_plugins/validate_manifest_test.go +++ b/cmd/ui_plugins/validate_manifest_test.go @@ -18,7 +18,7 @@ func TestValidateManifestCommand_Success(t *testing.T) { "alias": "access-request-plugin", "name": {"en-US": "Access Request"}, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"] + "slots": [{"slotId": "full-page"}] } }`) @@ -46,7 +46,7 @@ func TestValidateManifestCommand_AliasSuccess(t *testing.T) { "alias": "access-request-plugin", "name": {"en-US": "Access Request"}, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"] + "slots": [{"slotId": "full-page"}] } }`) @@ -68,7 +68,7 @@ func TestValidateManifestCommand_Failure(t *testing.T) { "manifest": { "name": {"en-US": "Access Request"}, "description": {"en-US": "Plugin description"}, - "slots": ["full-page"] + "slots": [{"slotId": "full-page"}] } }`) From 9206505008ddd9cf75529119f23192b269bef535 Mon Sep 17 00:00:00 2001 From: Jeremy Gooch Date: Mon, 22 Jun 2026 14:57:47 -0500 Subject: [PATCH 3/5] CSTM-191: Updating iframeAllow to expect a map of a slice of strings to match what UMS expects --- cmd/ui_plugins/manifest_model.go | 2 +- cmd/ui_plugins/manifest_validation_test.go | 66 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/cmd/ui_plugins/manifest_model.go b/cmd/ui_plugins/manifest_model.go index 3685f4c7..c0622be9 100644 --- a/cmd/ui_plugins/manifest_model.go +++ b/cmd/ui_plugins/manifest_model.go @@ -23,7 +23,7 @@ type uiPluginManifest struct { APIScopes []string `json:"apiScopes,omitempty"` ContentSecurityPolicies map[string][]string `json:"contentSecurityPolicies,omitempty"` PermissionPolicy map[string][]string `json:"permissionPolicy,omitempty"` - IframeAllow string `json:"iframeAllow,omitempty"` + IframeAllow map[string][]string `json:"iframeAllow,omitempty"` Slots []uiPluginSlot `json:"slots"` } diff --git a/cmd/ui_plugins/manifest_validation_test.go b/cmd/ui_plugins/manifest_validation_test.go index ab32b0e6..7dd518f1 100644 --- a/cmd/ui_plugins/manifest_validation_test.go +++ b/cmd/ui_plugins/manifest_validation_test.go @@ -35,6 +35,72 @@ func TestLoadAndValidateWorkspaceManifest_Valid(t *testing.T) { } } +func TestLoadAndValidateWorkspaceManifest_ValidIframeAllow(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}], + "iframeAllow": {} + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + if cfg.Manifest.IframeAllow == nil { + t.Fatal("expected iframeAllow map to be present") + } +} + +func TestLoadAndValidateWorkspaceManifest_ValidIframeAllowWithDirectives(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}], + "iframeAllow": {"camera": ["'self'"]} + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + if len(cfg.Manifest.IframeAllow) != 1 { + t.Fatalf("expected one iframeAllow directive, got: %+v", cfg.Manifest.IframeAllow) + } + if got := cfg.Manifest.IframeAllow["camera"]; len(got) != 1 || got[0] != "'self'" { + t.Fatalf("unexpected camera directive: %+v", got) + } +} + +func TestLoadAndValidateWorkspaceManifest_StringIframeAllowRejected(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}], + "iframeAllow": "camera 'self'" + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected string iframeAllow to fail") + } + if !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected unmarshal type error, got: %v", err) + } +} + func TestLoadAndValidateWorkspaceManifest_ValidSlotWithOptionalFields(t *testing.T) { path := writeManifestFixture(t, `{ "version": 1, From 25e959b376d2e9b341385ba73a03a0285011871d Mon Sep 17 00:00:00 2001 From: Jeremy Gooch Date: Mon, 22 Jun 2026 15:04:39 -0500 Subject: [PATCH 4/5] CSTM-191: Updating the validate documentation to reflect that it is for structural validation only --- cmd/ui_plugins/ui_plugins.md | 1 + cmd/ui_plugins/validate_manifest.go | 14 ++++++++--- cmd/ui_plugins/validate_manifest.md | 32 ++++++++++++++++++++++++ cmd/ui_plugins/validate_manifest_test.go | 29 ++++++++++++++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 cmd/ui_plugins/validate_manifest.md diff --git a/cmd/ui_plugins/ui_plugins.md b/cmd/ui_plugins/ui_plugins.md index 062f0f2a..9037cd83 100644 --- a/cmd/ui_plugins/ui_plugins.md +++ b/cmd/ui_plugins/ui_plugins.md @@ -17,6 +17,7 @@ Planned workflow commands: - upload - list - delete +- validate-manifest (alias: validate) — offline structural validation of `./sp-ui-plugin.json`; does not call UMS ==== ==Example== diff --git a/cmd/ui_plugins/validate_manifest.go b/cmd/ui_plugins/validate_manifest.go index a8296cef..651c6523 100644 --- a/cmd/ui_plugins/validate_manifest.go +++ b/cmd/ui_plugins/validate_manifest.go @@ -2,25 +2,33 @@ package ui_plugins import ( + _ "embed" "fmt" + "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" ) +//go:embed validate_manifest.md +var validateManifestHelp string + func newValidateManifestCommand() *cobra.Command { + help := util.ParseHelp(validateManifestHelp) return &cobra.Command{ Use: "validate-manifest", Aliases: []string{"validate"}, - Short: "Validate the plugin workspace manifest", + Short: "Validate sp-ui-plugin.json structure (offline; does not call UMS)", + Long: help.Long, + Example: help.Example, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { _, err := loadAndValidateWorkspaceManifest(manifestFileName) if err != nil { return err } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Manifest is valid.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Manifest structure is valid (offline check only).") return nil }, } } - diff --git a/cmd/ui_plugins/validate_manifest.md b/cmd/ui_plugins/validate_manifest.md new file mode 100644 index 00000000..0c9dd732 --- /dev/null +++ b/cmd/ui_plugins/validate_manifest.md @@ -0,0 +1,32 @@ +==Long== +# Validate Manifest + +Performs an offline structural validation of `./sp-ui-plugin.json` in the current working directory. + +## What is checked + +- JSON shape, required fields, and known field types +- Rejection of unknown fields (strict schema) +- CLI config schema version support + +## What is not checked + +This command does not contact UMS or validate tenant-specific rules, including: + +- Alias availability or format rules enforced by the backend +- Slot registry membership and occupancy limits +- Security policy baselines (CSP, permission policy, iframe allow) +- Capability lists, user GUID restrictions, and related business rules + +Use `sail ui-plugins create` or `update` for full backend validation. + +==== + +==Example== + +```bash +SAIL_EXPERIMENTAL_UI_PLUGINS=1 sail ui-plugins validate-manifest +SAIL_EXPERIMENTAL_UI_PLUGINS=1 sail ui-plugins validate +``` + +==== diff --git a/cmd/ui_plugins/validate_manifest_test.go b/cmd/ui_plugins/validate_manifest_test.go index 4533e015..ae22f9a8 100644 --- a/cmd/ui_plugins/validate_manifest_test.go +++ b/cmd/ui_plugins/validate_manifest_test.go @@ -32,11 +32,38 @@ func TestValidateManifestCommand_Success(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("expected validate-manifest to succeed, got: %v", err) } - if !strings.Contains(out.String(), "Manifest is valid.") { + if !strings.Contains(out.String(), "Manifest structure is valid (offline check only).") { t.Fatalf("expected success message, got: %s", out.String()) } } +func TestValidateManifestCommand_RejectsExtraArgs(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + cmd.SetArgs([]string{"validate-manifest", "./other.json"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected extra args to fail") + } + if !strings.Contains(err.Error(), "unknown command") && !strings.Contains(err.Error(), "accepts 0 arg") { + t.Fatalf("expected no-args error, got: %v", err) + } +} + func TestValidateManifestCommand_AliasSuccess(t *testing.T) { t.Setenv(experimentalUIPluginsEnvVar, "1") cwd := t.TempDir() From d45edffaab5a191c0870937f14b20243022a6b1b Mon Sep 17 00:00:00 2001 From: Jeremy Gooch Date: Thu, 25 Jun 2026 13:07:39 -0500 Subject: [PATCH 5/5] CSTM-191: Dropping the copyright headers from the ui-plugins source files --- cmd/ui_plugins/create.go | 1 - cmd/ui_plugins/delete.go | 1 - cmd/ui_plugins/init.go | 1 - cmd/ui_plugins/link.go | 1 - cmd/ui_plugins/list.go | 1 - cmd/ui_plugins/manifest_model.go | 1 - cmd/ui_plugins/manifest_validation.go | 1 - cmd/ui_plugins/manifest_validation_test.go | 1 - cmd/ui_plugins/ui_plugins.go | 1 - cmd/ui_plugins/ui_plugins_test.go | 1 - cmd/ui_plugins/update.go | 1 - cmd/ui_plugins/upload.go | 1 - cmd/ui_plugins/validate_manifest.go | 1 - cmd/ui_plugins/validate_manifest_test.go | 1 - 14 files changed, 14 deletions(-) diff --git a/cmd/ui_plugins/create.go b/cmd/ui_plugins/create.go index 2064cfaf..717e5894 100644 --- a/cmd/ui_plugins/create.go +++ b/cmd/ui_plugins/create.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/delete.go b/cmd/ui_plugins/delete.go index 14fb3997..c6ed985d 100644 --- a/cmd/ui_plugins/delete.go +++ b/cmd/ui_plugins/delete.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/init.go b/cmd/ui_plugins/init.go index b9d078c9..291ba37c 100644 --- a/cmd/ui_plugins/init.go +++ b/cmd/ui_plugins/init.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/link.go b/cmd/ui_plugins/link.go index 691a68f5..4b237f5a 100644 --- a/cmd/ui_plugins/link.go +++ b/cmd/ui_plugins/link.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/list.go b/cmd/ui_plugins/list.go index 9bae55c1..0c8371fd 100644 --- a/cmd/ui_plugins/list.go +++ b/cmd/ui_plugins/list.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/manifest_model.go b/cmd/ui_plugins/manifest_model.go index c0622be9..8fa6a705 100644 --- a/cmd/ui_plugins/manifest_model.go +++ b/cmd/ui_plugins/manifest_model.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins // uiPluginWorkspaceConfig represents the sp-ui-plugin.json workspace contract. diff --git a/cmd/ui_plugins/manifest_validation.go b/cmd/ui_plugins/manifest_validation.go index 52998c72..fd2976b5 100644 --- a/cmd/ui_plugins/manifest_validation.go +++ b/cmd/ui_plugins/manifest_validation.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/manifest_validation_test.go b/cmd/ui_plugins/manifest_validation_test.go index 7dd518f1..5b5a3713 100644 --- a/cmd/ui_plugins/manifest_validation_test.go +++ b/cmd/ui_plugins/manifest_validation_test.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/ui_plugins.go b/cmd/ui_plugins/ui_plugins.go index 9dba4ab2..4cbf4f41 100644 --- a/cmd/ui_plugins/ui_plugins.go +++ b/cmd/ui_plugins/ui_plugins.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/ui_plugins_test.go b/cmd/ui_plugins/ui_plugins_test.go index 7ab539ad..bd801fa7 100644 --- a/cmd/ui_plugins/ui_plugins_test.go +++ b/cmd/ui_plugins/ui_plugins_test.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/update.go b/cmd/ui_plugins/update.go index aa7f8ffd..860ad689 100644 --- a/cmd/ui_plugins/update.go +++ b/cmd/ui_plugins/update.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/upload.go b/cmd/ui_plugins/upload.go index 6988b8af..4ee5ec64 100644 --- a/cmd/ui_plugins/upload.go +++ b/cmd/ui_plugins/upload.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/validate_manifest.go b/cmd/ui_plugins/validate_manifest.go index 651c6523..2d7cde91 100644 --- a/cmd/ui_plugins/validate_manifest.go +++ b/cmd/ui_plugins/validate_manifest.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/validate_manifest_test.go b/cmd/ui_plugins/validate_manifest_test.go index ae22f9a8..3a5d2f38 100644 --- a/cmd/ui_plugins/validate_manifest_test.go +++ b/cmd/ui_plugins/validate_manifest_test.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import (