From c01cb3f808ba0e5a73ef3e73f221b1b47e0c1477 Mon Sep 17 00:00:00 2001 From: David Adams <187183+davidadas@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:57 -0400 Subject: [PATCH 1/7] [APIE-1362] Fix misleading --inclusive flag description on role-binding list The flag's help text said "If scopes are unspecified, list only organization-scoped role bindings," but the command's own documented example ("for all scopes") and actual behavior return role bindings across all scopes (org, environment, cluster) in that case. Corrected the description to match, and updated the 4 golden fixtures that pinned the old text. Co-Authored-By: Claude Sonnet 5 --- internal/iam/command_rbac_role_binding_list.go | 2 +- .../output/iam/rbac/role-binding/list-failure-help-cloud.golden | 2 +- .../iam/rbac/role-binding/list-failure-help-onprem.golden | 2 +- .../output/iam/rbac/role-binding/list-help-onprem.golden | 2 +- test/fixtures/output/iam/rbac/role-binding/list-help.golden | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/iam/command_rbac_role_binding_list.go b/internal/iam/command_rbac_role_binding_list.go index be7ff740e0..1b7653325e 100644 --- a/internal/iam/command_rbac_role_binding_list.go +++ b/internal/iam/command_rbac_role_binding_list.go @@ -114,7 +114,7 @@ func (c *roleBindingCommand) newListCommand() *cobra.Command { } cmd.Flags().String("resource", "", `Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings.`) - cmd.Flags().Bool("inclusive", false, "List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list only organization-scoped role bindings.") + cmd.Flags().Bool("inclusive", false, "List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes.") pcmd.AddOutputFlag(cmd) return cmd diff --git a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden index b3f8a39e1a..1a443dc518 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden @@ -39,7 +39,7 @@ Flags: --ksql-cluster string ksqlDB cluster name, which specifies the ksqlDB cluster scope. --flink-region string Flink region for the role binding, formatted as "cloud.region". --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list only organization-scoped role bindings. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: diff --git a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden index 3297fc0539..260ef7bef3 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden @@ -38,7 +38,7 @@ Flags: --context string CLI context name. --cluster-name string Cluster name, which specifies the cluster scope. --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list only organization-scoped role bindings. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: diff --git a/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden b/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden index e2b2adffb0..3871091301 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden @@ -39,7 +39,7 @@ Flags: --context string CLI context name. --cluster-name string Cluster name, which specifies the cluster scope. --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list only organization-scoped role bindings. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: diff --git a/test/fixtures/output/iam/rbac/role-binding/list-help.golden b/test/fixtures/output/iam/rbac/role-binding/list-help.golden index 28a1de56b8..c18b402070 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-help.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-help.golden @@ -40,7 +40,7 @@ Flags: --ksql-cluster string ksqlDB cluster name, which specifies the ksqlDB cluster scope. --flink-region string Flink region for the role binding, formatted as "cloud.region". --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list only organization-scoped role bindings. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: From 8eb99e26e5d1202926057393ff71b1aa6d5cb794 Mon Sep 17 00:00:00 2001 From: David Adams <187183+davidadas@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:09:21 -0400 Subject: [PATCH 2/7] [APIE-1286] Allow boolean flags to use "=" in examples without failing the linter RequireValidExamples() flagged any --flag=value in an example, but boolean flags legitimately need "=" to set the non-default value (e.g. --flag=false). Added getBoolFlags() and excluded them from the "=" check. Co-Authored-By: Claude Sonnet 5 --- pkg/linter/command_rules.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/linter/command_rules.go b/pkg/linter/command_rules.go index 5a8b84fe96..381e432578 100644 --- a/pkg/linter/command_rules.go +++ b/pkg/linter/command_rules.go @@ -221,6 +221,7 @@ func RequireValidExamples() CommandRule { return func(cmd *cobra.Command) error { requiredFlags := getRequiredFlags(cmd.Flags()) allFlags := getAllFlags(cmd.Flags()) + boolFlags := getBoolFlags(cmd.Flags()) errs := new(multierror.Error) @@ -238,7 +239,12 @@ func RequireValidExamples() CommandRule { } for _, match := range regexp.MustCompile(`--[a-z\-]+=`).FindAllString(example, -1) { - errs = multierror.Append(errs, fmt.Errorf("%s: flag `%s` must not use \"=\" in example %d", cmd.CommandPath(), strings.TrimSuffix(match, "="), i+1)) + flag := strings.TrimSuffix(match, "=") + // Boolean flags legitimately need "=" to set the non-default value, e.g. --flag=false. + if slices.Contains(boolFlags, flag) { + continue + } + errs = multierror.Append(errs, fmt.Errorf("%s: flag `%s` must not use \"=\" in example %d", cmd.CommandPath(), flag, i+1)) } } @@ -274,6 +280,16 @@ func getAllFlags(flags *pflag.FlagSet) []string { return all } +func getBoolFlags(flags *pflag.FlagSet) []string { + var boolFlags []string + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Value.Type() == "bool" { + boolFlags = append(boolFlags, "--"+flag.Name) + } + }) + return boolFlags +} + func getValueByName(obj any, name string) string { return reflect.Indirect(reflect.ValueOf(obj)).FieldByName(name).String() } From bca05bcfc585ffbc791f76872dd77550a05ca4aa Mon Sep 17 00:00:00 2001 From: David Adams <187183+davidadas@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:09:34 -0400 Subject: [PATCH 3/7] [APIE-1314] `confluent logout` should be a no-op when already logged out logout used NewAuthenticatedCLICommand, which required being logged in (erroring "not logged in" with no context) and would auto-login via env-var credentials only to immediately log back out. Switched to NewAnonymousCLICommand (same pattern as `login`) and return immediately when there's no active context, instead of erroring or auto-authenticating. The ccloud client used to revoke the refresh token is now constructed directly in revokeCCloudRefreshToken rather than relying on the Authenticated PreRun to populate it. Co-Authored-By: Claude Sonnet 5 --- internal/logout/command.go | 41 +++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/internal/logout/command.go b/internal/logout/command.go index e30fdd6dec..f6565caad7 100644 --- a/internal/logout/command.go +++ b/internal/logout/command.go @@ -1,6 +1,7 @@ package logout import ( + "context" "fmt" "github.com/spf13/cobra" @@ -12,11 +13,12 @@ import ( "github.com/confluentinc/cli/v4/pkg/ccloudv2" pcmd "github.com/confluentinc/cli/v4/pkg/cmd" "github.com/confluentinc/cli/v4/pkg/config" + "github.com/confluentinc/cli/v4/pkg/log" "github.com/confluentinc/cli/v4/pkg/output" ) type command struct { - *pcmd.AuthenticatedCLICommand + *pcmd.CLICommand cfg *config.Config authTokenHandler pauth.AuthTokenHandler } @@ -28,16 +30,18 @@ func New(cfg *config.Config, prerunner pcmd.PreRunner, authTokenHandler pauth.Au } context := "Confluent Cloud or Confluent Platform" - c := &command{ - AuthenticatedCLICommand: pcmd.NewAuthenticatedCLICommand(cmd, prerunner), - cfg: cfg, - authTokenHandler: authTokenHandler, - } if cfg.IsCloudLogin() { context = "Confluent Cloud" } else if cfg.IsOnPremLogin() { context = "Confluent Platform" - c.AuthenticatedCLICommand = pcmd.NewAuthenticatedWithMDSCLICommand(cmd, prerunner) + } + + c := &command{ + // Anonymous (not Authenticated): logout must not require being logged in, and must not + // trigger an auto-login via env-var credentials only to immediately log back out. + CLICommand: pcmd.NewAnonymousCLICommand(cmd, prerunner), + cfg: cfg, + authTokenHandler: authTokenHandler, } cmd.Short = fmt.Sprintf("Log out of %s.", context) @@ -49,11 +53,14 @@ func New(cfg *config.Config, prerunner pcmd.PreRunner, authTokenHandler pauth.Au func (c *command) logout(_ *cobra.Command, _ []string) error { ctx := c.Config.Context() - if ctx != nil { - if ccloudv2.IsCCloudURL(ctx.Platform.Server, c.cfg.IsTest) { - if _, err := c.revokeCCloudRefreshToken(ctx); err != nil { - return err - } + if ctx == nil { + // Already logged out: do nothing. + return nil + } + + if ccloudv2.IsCCloudURL(ctx.Platform.Server, c.cfg.IsTest) { + if _, err := c.revokeCCloudRefreshToken(ctx); err != nil { + return err } } @@ -71,10 +78,16 @@ func (c *command) revokeCCloudRefreshToken(ctx *config.Context) (*ccloudv1.Authe return nil, err } + client := ccloudv1.NewClientWithJWT(context.Background(), contextState.AuthToken, &ccloudv1.Params{ + BaseURL: ctx.GetPlatformServer(), + Logger: log.CliLogger, + UserAgent: c.Version.UserAgent, + }) + req := &ccloudv1.AuthenticateRequest{IdToken: contextState.AuthToken} if sso.IsOkta(ctx.Platform.Server) { - return c.Client.Auth.OktaLogout(req) + return client.Auth.OktaLogout(req) } else { - return c.Client.Auth.Logout(req) + return client.Auth.Logout(req) } } From e62708183eb03381c22652941637b6718231d8a5 Mon Sep 17 00:00:00 2001 From: David Adams <187183+davidadas@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:22:30 -0400 Subject: [PATCH 4/7] Address Copilot review comment on PR #3421 (APIE-1362) --inclusive is only read by the Cloud-path functions (listMyRoleBindings, ccloudListRolePrincipals), both called exclusively from ccloudList. The on-prem path (confluentList) never reads it, even though the flag is registered unconditionally for both login modes. Clarified the flag description as Confluent Cloud-only so on-prem users don't think it does something. Updated all 4 golden fixtures that pin this text. Co-Authored-By: Claude Sonnet 5 --- internal/iam/command_rbac_role_binding_list.go | 2 +- .../output/iam/rbac/role-binding/list-failure-help-cloud.golden | 2 +- .../iam/rbac/role-binding/list-failure-help-onprem.golden | 2 +- .../output/iam/rbac/role-binding/list-help-onprem.golden | 2 +- test/fixtures/output/iam/rbac/role-binding/list-help.golden | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/iam/command_rbac_role_binding_list.go b/internal/iam/command_rbac_role_binding_list.go index 1b7653325e..7617d1d11b 100644 --- a/internal/iam/command_rbac_role_binding_list.go +++ b/internal/iam/command_rbac_role_binding_list.go @@ -114,7 +114,7 @@ func (c *roleBindingCommand) newListCommand() *cobra.Command { } cmd.Flags().String("resource", "", `Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings.`) - cmd.Flags().Bool("inclusive", false, "List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes.") + cmd.Flags().Bool("inclusive", false, "List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. Only applies to Confluent Cloud.") pcmd.AddOutputFlag(cmd) return cmd diff --git a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden index 1a443dc518..64027504e7 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-cloud.golden @@ -39,7 +39,7 @@ Flags: --ksql-cluster string ksqlDB cluster name, which specifies the ksqlDB cluster scope. --flink-region string Flink region for the role binding, formatted as "cloud.region". --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. Only applies to Confluent Cloud. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: diff --git a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden index 260ef7bef3..990fbebb3f 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-failure-help-onprem.golden @@ -38,7 +38,7 @@ Flags: --context string CLI context name. --cluster-name string Cluster name, which specifies the cluster scope. --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. Only applies to Confluent Cloud. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: diff --git a/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden b/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden index 3871091301..ddcc7284e3 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-help-onprem.golden @@ -39,7 +39,7 @@ Flags: --context string CLI context name. --cluster-name string Cluster name, which specifies the cluster scope. --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. Only applies to Confluent Cloud. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: diff --git a/test/fixtures/output/iam/rbac/role-binding/list-help.golden b/test/fixtures/output/iam/rbac/role-binding/list-help.golden index c18b402070..52febe6b8b 100644 --- a/test/fixtures/output/iam/rbac/role-binding/list-help.golden +++ b/test/fixtures/output/iam/rbac/role-binding/list-help.golden @@ -40,7 +40,7 @@ Flags: --ksql-cluster string ksqlDB cluster name, which specifies the ksqlDB cluster scope. --flink-region string Flink region for the role binding, formatted as "cloud.region". --resource string Resource type and identifier using "Prefix:ID" format. If specified with "--role" and no principals, list all principals and role bindings. - --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. + --inclusive List role bindings for specified scopes and nested scopes. Otherwise, list role bindings for the specified scopes. If scopes are unspecified, list role bindings across all scopes. Only applies to Confluent Cloud. -o, --output string Specify the output format as "human", "json", or "yaml". (default "human") Global Flags: From f396d3f311212287992941dfe15d4247fba9d596 Mon Sep 17 00:00:00 2001 From: David Adams <187183+davidadas@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:20:04 -0400 Subject: [PATCH 5/7] Fix nil pointer panic in logout when Version is unset (APIE-1314) revokeCCloudRefreshToken accessed c.Version.UserAgent directly, but some PreRunner mocks (e.g. internal/login's cross-package test via mock.Commander) never populate Version, causing a nil pointer panic. Confirmed via the full test suite: TestLoginWithExistingContext panicked in internal/login before this fix, passes after it. Guard with a nil check instead of relying on every PreRunner implementation to set Version. Co-Authored-By: Claude Sonnet 5 --- internal/logout/command.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/logout/command.go b/internal/logout/command.go index f6565caad7..f5250d93c2 100644 --- a/internal/logout/command.go +++ b/internal/logout/command.go @@ -78,10 +78,14 @@ func (c *command) revokeCCloudRefreshToken(ctx *config.Context) (*ccloudv1.Authe return nil, err } + var userAgent string + if c.Version != nil { + userAgent = c.Version.UserAgent + } client := ccloudv1.NewClientWithJWT(context.Background(), contextState.AuthToken, &ccloudv1.Params{ BaseURL: ctx.GetPlatformServer(), Logger: log.CliLogger, - UserAgent: c.Version.UserAgent, + UserAgent: userAgent, }) req := &ccloudv1.AuthenticateRequest{IdToken: contextState.AuthToken} From f57ebe0fb200f4ac646b262e281e58db4465573f Mon Sep 17 00:00:00 2001 From: David Adams <187183+davidadas@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:22:39 -0400 Subject: [PATCH 6/7] Fix SonarQube new-code coverage and complexity on PR #3423 Both the logout no-op branch and the new getBoolFlags/RequireValidExamples "=" exception had zero test coverage, and RequireValidExamples' cognitive complexity (23) exceeded the allowed threshold (15) once the boolFlags check was added. Add direct unit tests for the no-op logout path and the bool-flag example exception, and split RequireValidExamples' three validation loops into named helper functions to bring complexity back under the limit. --- internal/logout/command_test.go | 10 ++++++ pkg/linter/command_rules.go | 55 +++++++++++++++++++++------------ pkg/linter/rules_test.go | 25 +++++++++++++++ 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/internal/logout/command_test.go b/internal/logout/command_test.go index a4a865251f..5c8395dd4f 100644 --- a/internal/logout/command_test.go +++ b/internal/logout/command_test.go @@ -53,6 +53,16 @@ func TestLogout(t *testing.T) { verifyLoggedOutState(t, cfg, contextName) } +func TestLogoutNoopWhenAlreadyLoggedOut(t *testing.T) { + req := require.New(t) + cfg := config.New() + prerunner := climock.NewPreRunnerMock(nil, nil, nil, nil, cfg) + logoutCmd := New(cfg, prerunner, AuthTokenHandler) + + _, err := pcmd.ExecuteCommand(logoutCmd) + req.NoError(err) +} + func newLogoutCmd(auth *ccloudv1mock.Auth, userInterface *ccloudv1mock.UserInterface, isCloud bool, req *require.Assertions, authTokenHandler pauth.AuthTokenHandler, contextName string) (*cobra.Command, *config.Config) { config.SetTempHomeDir() cfg := config.AuthenticatedConfigMockWithContextName(contextName) diff --git a/pkg/linter/command_rules.go b/pkg/linter/command_rules.go index 381e432578..ae8be56eb7 100644 --- a/pkg/linter/command_rules.go +++ b/pkg/linter/command_rules.go @@ -226,30 +226,47 @@ func RequireValidExamples() CommandRule { errs := new(multierror.Error) for i, example := range getExampleCodeSnippets(cmd.Example) { - for _, flag := range requiredFlags { - if !strings.Contains(example, flag) { - errs = multierror.Append(errs, fmt.Errorf("%s: required flag `%s` not found in example %d", cmd.CommandPath(), flag, i+1)) - } - } + errs = multierror.Append(errs, requireExampleHasRequiredFlags(cmd, requiredFlags, example, i)...) + errs = multierror.Append(errs, requireExampleFlagsAreKnown(cmd, allFlags, example, i)...) + errs = multierror.Append(errs, requireExampleNoEqualsForNonBoolFlags(cmd, boolFlags, example, i)...) + } - for _, match := range regexp.MustCompile(`--[a-z\-]+`).FindAllString(example, -1) { - if !slices.Contains(allFlags, match) { - errs = multierror.Append(errs, fmt.Errorf("%s: unknown flag `%s` found in example %d", cmd.CommandPath(), match, i+1)) - } - } + return errs + } +} - for _, match := range regexp.MustCompile(`--[a-z\-]+=`).FindAllString(example, -1) { - flag := strings.TrimSuffix(match, "=") - // Boolean flags legitimately need "=" to set the non-default value, e.g. --flag=false. - if slices.Contains(boolFlags, flag) { - continue - } - errs = multierror.Append(errs, fmt.Errorf("%s: flag `%s` must not use \"=\" in example %d", cmd.CommandPath(), flag, i+1)) - } +func requireExampleHasRequiredFlags(cmd *cobra.Command, requiredFlags []string, example string, i int) []error { + var errs []error + for _, flag := range requiredFlags { + if !strings.Contains(example, flag) { + errs = append(errs, fmt.Errorf("%s: required flag `%s` not found in example %d", cmd.CommandPath(), flag, i+1)) } + } + return errs +} - return errs +func requireExampleFlagsAreKnown(cmd *cobra.Command, allFlags []string, example string, i int) []error { + var errs []error + for _, match := range regexp.MustCompile(`--[a-z\-]+`).FindAllString(example, -1) { + if !slices.Contains(allFlags, match) { + errs = append(errs, fmt.Errorf("%s: unknown flag `%s` found in example %d", cmd.CommandPath(), match, i+1)) + } + } + return errs +} + +func requireExampleNoEqualsForNonBoolFlags(cmd *cobra.Command, boolFlags []string, example string, i int) []error { + matches := regexp.MustCompile(`--[a-z\-]+=`).FindAllString(example, -1) + errs := make([]error, 0, len(matches)) + for _, match := range matches { + flag := strings.TrimSuffix(match, "=") + // Boolean flags legitimately need "=" to set the non-default value, e.g. --flag=false. + if slices.Contains(boolFlags, flag) { + continue + } + errs = append(errs, fmt.Errorf("%s: flag `%s` must not use \"=\" in example %d", cmd.CommandPath(), flag, i+1)) } + return errs } func getExampleCodeSnippets(example string) []string { diff --git a/pkg/linter/rules_test.go b/pkg/linter/rules_test.go index f788dc2b5f..03dab6182a 100644 --- a/pkg/linter/rules_test.go +++ b/pkg/linter/rules_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/client9/gospell" + "github.com/hashicorp/go-multierror" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -55,6 +56,30 @@ func TestFlagKebabCase(t *testing.T) { }) } +func TestRequireValidExamplesAllowsEqualsForBoolFlags(t *testing.T) { + rule := RequireValidExamples() + + t.Run("bool flag with \"=\" is allowed", func(t *testing.T) { + cmd := &cobra.Command{Run: func(cmd *cobra.Command, args []string) {}} + cmd.Flags().Bool("force", false, "a bool flag") + cmd.Example = " $ confluent example --force=false\n" + err := cmd.Execute() + require.NoError(t, err) + err = rule(cmd) + require.Nil(t, err.(*multierror.Error).ErrorOrNil()) + }) + + t.Run("non-bool flag with \"=\" is rejected", func(t *testing.T) { + cmd := &cobra.Command{Run: func(cmd *cobra.Command, args []string) {}} + cmd.Flags().String("cluster", "", "a string flag") + cmd.Example = " $ confluent example --cluster=lkc-123456\n" + err := cmd.Execute() + require.NoError(t, err) + err = rule(cmd) + require.Error(t, err) + }) +} + func TestFlagUsageRealWords(t *testing.T) { req := require.New(t) rule := RequireFlagUsageRealWords([]string{}) From 96495292051a3e24cb08c23d25bb41da1bcadf10 Mon Sep 17 00:00:00 2001 From: David Adams <187183+davidadas@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:37:06 -0400 Subject: [PATCH 7/7] Address Copilot review comments on PR #3423 - Guard against a missing/corrupt context_state entry in revokeCCloudRefreshToken, which previously panicked on a nil ContextState instead of letting logout succeed as a no-op revoke. - Add an integration test covering `confluent logout` run a second time with no active session, locking in the APIE-1314 no-op behavior end-to-end (not just at the unit level). --- internal/logout/command.go | 4 ++++ internal/logout/command_test.go | 14 ++++++++++++++ test/logout_test.go | 12 ++++++++++++ 3 files changed, 30 insertions(+) diff --git a/internal/logout/command.go b/internal/logout/command.go index f5250d93c2..c8b76bcf6f 100644 --- a/internal/logout/command.go +++ b/internal/logout/command.go @@ -74,6 +74,10 @@ func (c *command) logout(_ *cobra.Command, _ []string) error { func (c *command) revokeCCloudRefreshToken(ctx *config.Context) (*ccloudv1.AuthenticateReply, error) { contextState := c.Config.ContextStates[ctx.Name] + if contextState == nil { + // Missing or corrupt context state: nothing to revoke, but logout should still succeed. + return nil, nil + } if err := contextState.DecryptAuthToken(ctx.Name); err != nil { return nil, err } diff --git a/internal/logout/command_test.go b/internal/logout/command_test.go index 5c8395dd4f..54246d6d9f 100644 --- a/internal/logout/command_test.go +++ b/internal/logout/command_test.go @@ -53,6 +53,20 @@ func TestLogout(t *testing.T) { verifyLoggedOutState(t, cfg, contextName) } +func TestRevokeCCloudRefreshTokenNoopWhenContextStateMissing(t *testing.T) { + req := require.New(t) + cfg := config.AuthenticatedConfigMockWithContextName(config.MockContextName) + ctx := cfg.Context() + // Simulate a missing/corrupt context_state entry for an otherwise-valid context. + delete(cfg.ContextStates, ctx.Name) + + c := &command{CLICommand: &pcmd.CLICommand{Config: cfg}, cfg: cfg} + + reply, err := c.revokeCCloudRefreshToken(ctx) + req.NoError(err) + req.Nil(reply) +} + func TestLogoutNoopWhenAlreadyLoggedOut(t *testing.T) { req := require.New(t) cfg := config.New() diff --git a/test/logout_test.go b/test/logout_test.go index c3d580a667..0c7614f0f6 100644 --- a/test/logout_test.go +++ b/test/logout_test.go @@ -9,6 +9,18 @@ import ( "github.com/confluentinc/cli/v4/pkg/utils" ) +func (s *CLITestSuite) TestLogout_NoopWhenAlreadyLoggedOut() { + cloudUrl := s.TestBackend.GetCloudUrl() + env := []string{fmt.Sprintf("%s=good@user.com", auth.ConfluentCloudEmail), fmt.Sprintf("%s=pass1", auth.ConfluentCloudPassword)} + + runCommand(s.T(), testBin, env, "login -vvvv --save --url "+cloudUrl, 0, "") + runCommand(s.T(), testBin, env, "logout -vvvv", 0, "") + + // Logging out a second time, with no active session, must succeed as a no-op rather than error. + output := runCommand(s.T(), testBin, env, "logout -vvvv", 0, "") + s.NotContains(output, "You are now logged out.") +} + func (s *CLITestSuite) TestLogout_RemoveUsernamePassword() { type saveTest struct { isCloud bool