diff --git a/cmd/config/plugins/add.go b/cmd/config/plugins/add.go index f9c7b6a..7947bc5 100644 --- a/cmd/config/plugins/add.go +++ b/cmd/config/plugins/add.go @@ -2,24 +2,167 @@ package plugins import ( "fmt" + "maps" + "slices" + "strings" "github.com/spf13/cobra" - "github.com/mozilla-ai/mcpd/v2/internal/cmd" - "github.com/mozilla-ai/mcpd/v2/internal/cmd/options" + internalcmd "github.com/mozilla-ai/mcpd/v2/internal/cmd" + cmdopts "github.com/mozilla-ai/mcpd/v2/internal/cmd/options" + "github.com/mozilla-ai/mcpd/v2/internal/config" ) -// NewAddCmd creates the add command for plugins. -// TODO: Implement in a future PR. -func NewAddCmd(baseCmd *cmd.BaseCmd, opt ...options.CmdOption) (*cobra.Command, error) { +const ( + flagFlow = "flow" + flagRequired = "required" + flagCommitHash = "commit-hash" +) + +// AddCmd represents the command for adding a new plugin entry. +// NOTE: Use NewAddCmd to create instances of AddCmd. +type AddCmd struct { + *internalcmd.BaseCmd + + // cfgLoader is used to load the configuration. + cfgLoader config.Loader + + // category is the category to add the plugin to. + category config.Category + + // flows is the list of flows. + flows []string + + // required indicates if the plugin is required. + required bool + + // commitHash is the optional commit hash for version validation. + commitHash string +} + +// NewAddCmd creates a new add command for plugin entries. +func NewAddCmd(baseCmd *internalcmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error) { + opts, err := cmdopts.NewOptions(opt...) + if err != nil { + return nil, err + } + + c := &AddCmd{ + BaseCmd: baseCmd, + cfgLoader: opts.ConfigLoader, + } + cobraCmd := &cobra.Command{ - Use: "add", + Use: "add ", Short: "Add a new plugin entry to a category", - Long: "Add a new plugin entry to a category. The configuration is saved automatically.", - RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not yet implemented") - }, + Long: `Add a new plugin entry to a category. The configuration is saved automatically. + +The plugin name must exactly match the name of the plugin binary file. + +This command creates new plugin entries only. If a plugin with the same name already exists +in the category, the command fails with an error. To update an existing plugin, use the 'set' command.`, + Example: ` # Add new plugin with all fields + mcpd config plugins add jwt-auth --category=authentication --flow=request --required + + # Add plugin with multiple flows + mcpd config plugins add metrics --category=observability --flow=request --flow=response --commit-hash=abc123 + + # Add without required flag (defaults to false) + mcpd config plugins add rbac --category=authorization --flow=response`, + RunE: c.run, + Args: cobra.ExactArgs(1), // plugin-name } + allowedCategories := config.OrderedCategories() + cobraCmd.Flags().Var( + &c.category, + flagCategory, + fmt.Sprintf("Specify the category (one of: %s)", allowedCategories.String()), + ) + _ = cobraCmd.MarkFlagRequired(flagCategory) + + cobraCmd.Flags().StringArrayVar( + &c.flows, + flagFlow, + nil, + fmt.Sprintf( + "Flow during which, the plugin should execute (%s) (can be repeated)", + strings.Join(config.OrderedFlowNames(), ", "), + ), + ) + _ = cobraCmd.MarkFlagRequired(flagFlow) + + cobraCmd.Flags().BoolVar( + &c.required, + flagRequired, + false, + "Optional, mark plugin as required", + ) + + cobraCmd.Flags().StringVar( + &c.commitHash, + flagCommitHash, + "", + "Optional, commit hash for runtime version validation", + ) + return cobraCmd, nil } + +func (c *AddCmd) run(cmd *cobra.Command, args []string) error { + pluginName := strings.TrimSpace(args[0]) + if pluginName == "" { + return fmt.Errorf("plugin name cannot be empty") + } + + cfg, err := c.LoadConfig(c.cfgLoader) + if err != nil { + return err + } + + if _, exists := cfg.Plugin(c.category, pluginName); exists { + return fmt.Errorf( + "plugin '%s' already exists in category '%s'\n\n"+ + "To update an existing plugin, use: mcpd config plugins set %s --category=%s [flags]", + pluginName, + c.category, + pluginName, + c.category, + ) + } + + flows := config.ParseFlowsDistinct(c.flows) + if len(flows) == 0 { + return fmt.Errorf( + "at least one valid flow is required (%s)", + strings.Join(config.OrderedFlowNames(), ", "), + ) + } + + entry := config.PluginEntry{ + Name: pluginName, + Flows: slices.Sorted(maps.Keys(flows)), + } + + // Set optional fields only if they were provided. + if cmd.Flags().Changed(flagRequired) { + entry.Required = &c.required + } + + if c.commitHash != "" { + entry.CommitHash = &c.commitHash + } + + if _, err := cfg.UpsertPlugin(c.category, entry); err != nil { + return err + } + + _, _ = fmt.Fprintf( + cmd.OutOrStdout(), + "✓ Plugin '%s' added to category '%s'\n", + pluginName, + c.category, + ) + + return nil +} diff --git a/cmd/config/plugins/add_test.go b/cmd/config/plugins/add_test.go new file mode 100644 index 0000000..fa1e543 --- /dev/null +++ b/cmd/config/plugins/add_test.go @@ -0,0 +1,360 @@ +package plugins + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mozilla-ai/mcpd/v2/internal/cmd" + cmdopts "github.com/mozilla-ai/mcpd/v2/internal/cmd/options" + "github.com/mozilla-ai/mcpd/v2/internal/config" +) + +// createTempConfigFile creates a temporary config file for testing. +func createTempConfigFile(t *testing.T) string { + t.Helper() + + tempFile, err := os.CreateTemp(t.TempDir(), ".mcpd.toml") + require.NoError(t, err) + + // Write minimal valid config. + content := "servers = []\n" + require.NoError(t, os.WriteFile(tempFile.Name(), []byte(content), 0o644)) + + return tempFile.Name() +} + +// mockLoaderFromFile creates a mock loader that loads from a real temp file. +// This allows the config to be saved properly during tests. +type mockLoaderFromFile struct { + filePath string + loader *config.DefaultLoader +} + +func newMockLoaderFromFile(t *testing.T) *mockLoaderFromFile { + t.Helper() + + return &mockLoaderFromFile{ + filePath: createTempConfigFile(t), + loader: &config.DefaultLoader{}, + } +} + +func (m *mockLoaderFromFile) Load(_ string) (config.Modifier, error) { + return m.loader.Load(m.filePath) +} + +func TestNewAddCmd(t *testing.T) { + t.Parallel() + + base := &cmd.BaseCmd{} + c, err := NewAddCmd(base) + require.NoError(t, err) + require.NotNil(t, c) + + require.Equal(t, "add ", c.Use) + require.Contains(t, c.Short, "Add a new plugin entry") + require.NotNil(t, c.RunE) +} + +func TestAddCmd_Success(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"jwt-auth"}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + require.Contains(t, stdout.String(), "✓ Plugin 'jwt-auth' added to category 'authentication'") + + // Verify plugin was added by reloading the config. + cfg, err := loader.Load("") + require.NoError(t, err) + + authPlugins := cfg.(*config.Config).Plugins.ListPlugins(config.CategoryAuthentication) + require.Len(t, authPlugins, 1) + require.Equal(t, "jwt-auth", authPlugins[0].Name) + require.Equal(t, []config.Flow{config.FlowRequest}, authPlugins[0].Flows) +} + +func TestAddCmd_WithAllFields(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "observability") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "response") + require.NoError(t, err) + err = addCmd.Flags().Set(flagRequired, "true") + require.NoError(t, err) + err = addCmd.Flags().Set(flagCommitHash, "abc123") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"metrics"}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + require.Contains(t, stdout.String(), "✓ Plugin 'metrics' added to category 'observability'") + + // Verify plugin was added with all fields by reloading the config. + cfg, err := loader.Load("") + require.NoError(t, err) + + obsPlugins := cfg.(*config.Config).Plugins.ListPlugins(config.CategoryObservability) + require.Len(t, obsPlugins, 1) + require.Equal(t, "metrics", obsPlugins[0].Name) + require.Equal(t, []config.Flow{config.FlowRequest, config.FlowResponse}, obsPlugins[0].Flows) + require.NotNil(t, obsPlugins[0].Required) + require.True(t, *obsPlugins[0].Required) + require.NotNil(t, obsPlugins[0].CommitHash) + require.Equal(t, "abc123", *obsPlugins[0].CommitHash) +} + +func TestAddCmd_PluginAlreadyExists(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + Dir: "/path/to/plugins", + Authentication: []config.PluginEntry{{Name: "jwt-auth", Flows: []config.Flow{config.FlowRequest}}}, + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"jwt-auth"}) + require.Error(t, err) + require.Contains(t, err.Error(), "plugin 'jwt-auth' already exists in category 'authentication'") + require.Contains(t, err.Error(), "To update an existing plugin, use: mcpd config plugins set") +} + +func TestAddCmd_InvalidFlows(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "invalid") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"jwt"}) + require.Error(t, err) + require.Contains(t, err.Error(), "at least one valid flow is required") +} + +func TestAddCmd_DuplicateFlows(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"jwt"}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + // Verify duplicates were deduplicated - should only have one flow. + cfg, err := loader.Load("") + require.NoError(t, err) + + authPlugins := cfg.(*config.Config).Plugins.ListPlugins(config.CategoryAuthentication) + require.Len(t, authPlugins, 1) + require.Equal(t, "jwt", authPlugins[0].Name) + require.Equal(t, []config.Flow{config.FlowRequest}, authPlugins[0].Flows) +} + +func TestAddCmd_EmptyName(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{" "}) + require.Error(t, err) + require.Contains(t, err.Error(), "plugin name cannot be empty") +} + +func TestAddCmd_InvalidCategory(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "invalid-category") + require.Error(t, err) + require.ErrorContains(t, err, "invalid category 'invalid-category'") +} + +func TestAddCmd_CaseInsensitiveFlows(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "rate_limiting") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "REQUEST") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "RESPONSE") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"rate-limiter"}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + // Verify plugin was added by reloading the config. + cfg, err := loader.Load("") + require.NoError(t, err) + + rateLimitPlugins := cfg.(*config.Config).Plugins.ListPlugins(config.CategoryRateLimiting) + require.Len(t, rateLimitPlugins, 1) + require.Equal(t, "rate-limiter", rateLimitPlugins[0].Name) + require.Equal(t, []config.Flow{config.FlowRequest, config.FlowResponse}, rateLimitPlugins[0].Flows) +} + +func TestAddCmd_RequiredFalseNotSet(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"jwt-auth"}) + require.NoError(t, err) + + // Verify Required field is nil (not set) by reloading the config. + cfg, err := loader.Load("") + require.NoError(t, err) + + authPlugins := cfg.(*config.Config).Plugins.ListPlugins(config.CategoryAuthentication) + require.Len(t, authPlugins, 1) + require.Nil(t, authPlugins[0].Required) +} + +func TestAddCmd_RequiredFalseExplicitlySet(t *testing.T) { + t.Parallel() + + loader := newMockLoaderFromFile(t) + base := &cmd.BaseCmd{} + addCmd, err := NewAddCmd(base, cmdopts.WithConfigLoader(loader)) + require.NoError(t, err) + + err = addCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = addCmd.Flags().Set(flagFlow, "request") + require.NoError(t, err) + err = addCmd.Flags().Set(flagRequired, "false") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + addCmd.SetOut(&stdout) + addCmd.SetErr(&stderr) + + err = addCmd.RunE(addCmd, []string{"jwt-auth"}) + require.NoError(t, err) + + // Verify Required field is set to false by reloading the config. + cfg, err := loader.Load("") + require.NoError(t, err) + + authPlugins := cfg.(*config.Config).Plugins.ListPlugins(config.CategoryAuthentication) + require.Len(t, authPlugins, 1) + require.NotNil(t, authPlugins[0].Required) + require.False(t, *authPlugins[0].Required) +} diff --git a/cmd/config/plugins/cmd_test.go b/cmd/config/plugins/cmd_test.go index 727f298..ee7e031 100644 --- a/cmd/config/plugins/cmd_test.go +++ b/cmd/config/plugins/cmd_test.go @@ -1,6 +1,7 @@ package plugins_test import ( + "strings" "testing" "github.com/stretchr/testify/require" @@ -35,8 +36,10 @@ func TestPlugins_NewCmd_Success(t *testing.T) { } for _, command := range commands { - require.True(t, expectedCmds[command.Use], "unexpected command: %s", command.Use) - delete(expectedCmds, command.Use) + // Extract command name (first word) from Use field to handle cases like "add ". + cmdName := strings.Fields(command.Use)[0] + require.True(t, expectedCmds[cmdName], "unexpected command: %s", command.Use) + delete(expectedCmds, cmdName) } require.Empty(t, expectedCmds, "missing expected commands") diff --git a/internal/config/plugin_config.go b/internal/config/plugin_config.go index a3ce304..280232c 100644 --- a/internal/config/plugin_config.go +++ b/internal/config/plugin_config.go @@ -3,11 +3,13 @@ package config import ( "errors" "fmt" + "maps" "slices" "strings" "github.com/mozilla-ai/mcpd/v2/internal/context" "github.com/mozilla-ai/mcpd/v2/internal/files" + "github.com/mozilla-ai/mcpd/v2/internal/filter" ) const ( @@ -54,6 +56,13 @@ var orderedCategories = Categories{ CategoryAudit, // Last. } +// flows defines the set of valid flow types. +// NOTE: This variable should not be modified in other parts of the codebase. +var flows = map[Flow]struct{}{ + FlowRequest: {}, + FlowResponse: {}, +} + // PluginModifier defines operations for managing plugin configuration. type PluginModifier interface { // Plugin retrieves a plugin by category and name. @@ -78,6 +87,34 @@ type Category string // Flow represents the execution phase for a plugin. type Flow string +// Flows returns the canonical set of allowed flows. +// Returns a clone to prevent modification of the internal map. +func Flows() map[Flow]struct{} { + return maps.Clone(flows) +} + +// IsValid returns true if the Flow is a recognized value. +func (f Flow) IsValid() bool { + _, ok := flows[f] + return ok +} + +// ParseFlowsDistinct validates and reduces flow strings to a distinct set. +// Flow strings are normalized before validation. +// Invalid flows are silently ignored. Returns an empty map if no valid flows are found. +func ParseFlowsDistinct(flags []string) map[Flow]struct{} { + valid := make(map[Flow]struct{}, len(flows)) + + for _, s := range flags { + f := Flow(filter.NormalizeString(s)) + if _, ok := flows[f]; ok { + valid[f] = struct{}{} + } + } + + return valid +} + // PluginConfig represents the top-level plugin configuration. // // NOTE: if you add/remove fields you must review the associated validation implementation. @@ -207,12 +244,10 @@ func (e *PluginEntry) Validate() error { } else { seen := make(map[Flow]struct{}) for _, flow := range e.Flows { - // Check for valid flow values. - if flow != FlowRequest && flow != FlowResponse { - validationErrors = append( - validationErrors, - fmt.Errorf("invalid flow '%s', must be '%s' or '%s'", flow, FlowRequest, FlowResponse), - ) + if !flow.IsValid() { + allowedFlows := strings.Join(OrderedFlowNames(), ", ") + err := fmt.Errorf("invalid flow '%s' (allowed: %s)", flow, allowedFlows) + validationErrors = append(validationErrors, err) } // Check for duplicates. @@ -349,7 +384,9 @@ func (p *PluginConfig) plugin(category Category, name string) (PluginEntry, bool // upsertPlugin creates or updates a plugin entry. func (p *PluginConfig) upsertPlugin(category Category, entry PluginEntry) (context.UpsertResult, error) { - if strings.TrimSpace(entry.Name) == "" { + // Handle sanitizing the plugin name. + entry.Name = strings.TrimSpace(entry.Name) + if entry.Name == "" { return context.Noop, fmt.Errorf("plugin name cannot be empty") } @@ -362,11 +399,9 @@ func (p *PluginConfig) upsertPlugin(category Category, entry PluginEntry) (conte return context.Noop, err } - name := strings.TrimSpace(entry.Name) - // Check if plugin already exists. for i, existing := range *slice { - if existing.Name != name { + if existing.Name != entry.Name { continue } @@ -489,10 +524,22 @@ func OrderedCategories() Categories { return slices.Clone(orderedCategories) } +// OrderedFlowNames returns the names of allowed flows in order. +func OrderedFlowNames() []string { + sortedFlows := slices.Sorted(maps.Keys(flows)) + + flowNames := make([]string, len(sortedFlows)) + for i, f := range sortedFlows { + flowNames[i] = string(f) + } + + return flowNames +} + // Set is used by Cobra to set the category value from a string. // NOTE: This is also required by Cobra as part of implementing flag.Value. func (c *Category) Set(v string) error { - v = strings.ToLower(strings.TrimSpace(v)) + v = filter.NormalizeString(v) allowed := OrderedCategories() for _, a := range allowed { diff --git a/internal/config/plugin_config_test.go b/internal/config/plugin_config_test.go index ed1a856..8e50382 100644 --- a/internal/config/plugin_config_test.go +++ b/internal/config/plugin_config_test.go @@ -8,6 +8,16 @@ import ( "github.com/mozilla-ai/mcpd/v2/internal/context" ) +func testPluginStringPtr(t *testing.T, s string) *string { + t.Helper() + return &s +} + +func testPluginBoolPtr(t *testing.T, b bool) *bool { + t.Helper() + return &b +} + func TestPluginEntry_Validate(t *testing.T) { t.Parallel() @@ -395,6 +405,7 @@ func TestPluginConfig_upsertPlugin(t *testing.T) { entry PluginEntry wantResult context.UpsertResult wantErr bool + wantName string }{ { name: "create new plugin", @@ -404,6 +415,7 @@ func TestPluginConfig_upsertPlugin(t *testing.T) { Name: "jwt-auth", Flows: []Flow{FlowRequest}, }, + wantName: "jwt-auth", wantResult: context.Created, wantErr: false, }, @@ -419,6 +431,7 @@ func TestPluginConfig_upsertPlugin(t *testing.T) { Name: "jwt-auth", Flows: []Flow{FlowRequest, FlowResponse}, }, + wantName: "jwt-auth", wantResult: context.Updated, wantErr: false, }, @@ -434,6 +447,7 @@ func TestPluginConfig_upsertPlugin(t *testing.T) { Name: "jwt-auth", Flows: []Flow{FlowRequest}, }, + wantName: "jwt-auth", wantResult: context.Noop, wantErr: false, }, @@ -459,6 +473,18 @@ func TestPluginConfig_upsertPlugin(t *testing.T) { wantResult: context.Noop, wantErr: true, }, + { + name: "trim whitespace", + initial: &PluginConfig{}, + category: CategoryAuthentication, + entry: PluginEntry{ + Name: " jwt-auth ", + Flows: []Flow{FlowRequest}, + }, + wantName: "jwt-auth", + wantResult: context.Created, + wantErr: false, + }, } for _, tc := range tests { @@ -471,6 +497,10 @@ func TestPluginConfig_upsertPlugin(t *testing.T) { require.Error(t, err) } else { require.NoError(t, err) + + updated, found := tc.initial.plugin(tc.category, tc.wantName) + require.True(t, found) + require.Equal(t, tc.wantName, updated.Name) } require.Equal(t, tc.wantResult, result) @@ -724,12 +754,157 @@ func TestPluginConfig_Validate_errorMessages(t *testing.T) { }) } -func testPluginStringPtr(t *testing.T, s string) *string { - t.Helper() - return &s +func TestFlows(t *testing.T) { + t.Parallel() + + flows := Flows() + + // Should contain exactly request and response. + require.Len(t, flows, 2) + require.Contains(t, flows, FlowRequest) + require.Contains(t, flows, FlowResponse) + + // Verify that modifications don't affect subsequent calls (clone behavior). + delete(flows, FlowRequest) + require.Len(t, flows, 1) + + // Get a fresh copy - should still have both flows. + freshFlows := Flows() + require.Len(t, freshFlows, 2) + require.Contains(t, freshFlows, FlowRequest) + require.Contains(t, freshFlows, FlowResponse) } -func testPluginBoolPtr(t *testing.T, b bool) *bool { - t.Helper() - return &b +func TestFlow_IsValid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flow Flow + valid bool + }{ + { + name: "valid request flow", + flow: FlowRequest, + valid: true, + }, + { + name: "valid response flow", + flow: FlowResponse, + valid: true, + }, + { + name: "invalid empty flow", + flow: Flow(""), + valid: false, + }, + { + name: "invalid unknown flow", + flow: Flow("unknown"), + valid: false, + }, + { + name: "invalid uppercase", + flow: Flow("REQUEST"), + valid: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := tc.flow.IsValid() + require.Equal(t, tc.valid, result) + }) + } +} + +func TestParseFlowsDistinct(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []string + expected map[Flow]struct{} + }{ + { + name: "single valid flow", + input: []string{"request"}, + expected: map[Flow]struct{}{ + FlowRequest: {}, + }, + }, + { + name: "two valid flows", + input: []string{"request", "response"}, + expected: map[Flow]struct{}{ + FlowRequest: {}, + FlowResponse: {}, + }, + }, + { + name: "duplicates are deduplicated", + input: []string{"request", "request", "response", "response"}, + expected: map[Flow]struct{}{ + FlowRequest: {}, + FlowResponse: {}, + }, + }, + { + name: "invalid flows are ignored", + input: []string{"invalid", "foo", "bar"}, + expected: map[Flow]struct{}{}, + }, + { + name: "mixed valid and invalid", + input: []string{"request", "invalid", "response", "foo"}, + expected: map[Flow]struct{}{ + FlowRequest: {}, + FlowResponse: {}, + }, + }, + { + name: "empty input", + input: []string{}, + expected: map[Flow]struct{}{}, + }, + { + name: "nil input", + input: nil, + expected: map[Flow]struct{}{}, + }, + { + name: "case insensitive", + input: []string{"REQUEST", "Response", "REQUEST"}, + expected: map[Flow]struct{}{ + FlowRequest: {}, + FlowResponse: {}, + }, + }, + { + name: "with whitespace", + input: []string{" request ", " response "}, + expected: map[Flow]struct{}{ + FlowRequest: {}, + FlowResponse: {}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := ParseFlowsDistinct(tc.input) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestAddCmd_OrderedFlowNames(t *testing.T) { + flows := OrderedFlowNames() + require.Len(t, flows, 2) + require.Equal(t, "request", flows[0]) + require.Equal(t, "response", flows[1]) }