From a5361b36dcf8fe030f9d652336c31c27bfcebda2 Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Thu, 7 May 2026 23:22:16 +0800 Subject: [PATCH 1/4] feat(auth): add scope hint for missing authorization errors --- cmd/build.go | 1 + cmd/error_auth_hint.go | 167 ++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + cmd/root_test.go | 92 ++++++++++++++++++++ internal/cmdutil/factory.go | 1 + 5 files changed, 262 insertions(+) create mode 100644 cmd/error_auth_hint.go diff --git a/cmd/build.go b/cmd/build.go index c830f8f9a2..6b5d1e5c14 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -109,6 +109,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals) rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { cmd.SilenceUsage = true + f.CurrentCommand = cmd } rootCmd.AddCommand(cmdconfig.NewCmdConfig(f)) diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go new file mode 100644 index 0000000000..7a4305107a --- /dev/null +++ b/cmd/error_auth_hint.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + "fmt" + "strings" + + internalauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" + shortcutcommon "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// enrichMissingScopeError preserves the original need_user_authorization +// message and appends a scope hint when the current command declares the +// required scopes locally. +func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) { + if exitErr == nil || exitErr.Detail == nil { + return + } + if !isNeedUserAuthorizationError(exitErr) { + return + } + + scopes := resolveDeclaredScopesForCurrentCommand(f) + if len(scopes) == 0 { + return + } + + exitErr.Detail.Hint = fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", ")) +} + +func isNeedUserAuthorizationError(err error) bool { + var needAuthErr *internalauth.NeedAuthorizationError + if errors.As(err, &needAuthErr) { + return true + } + + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + return strings.Contains(exitErr.Detail.Message, "need_user_authorization") + } + return strings.Contains(err.Error(), "need_user_authorization") +} + +func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string { + if f == nil || f.CurrentCommand == nil { + return nil + } + + identity := string(f.ResolvedIdentity) + if identity == "" { + identity = string(core.AsUser) + } + if identity != string(core.AsUser) && identity != string(core.AsBot) { + return nil + } + + if scopes := resolveDeclaredShortcutScopes(f.CurrentCommand, identity); len(scopes) > 0 { + return scopes + } + return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity) +} + +func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string { + if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") { + return nil + } + + service := cmd.Parent().Name() + for _, sc := range shortcuts.AllShortcuts() { + if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) { + continue + } + scopes := sc.ScopesForIdentity(identity) + if len(scopes) == 0 { + return nil + } + return append([]string(nil), scopes...) + } + return nil +} + +func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string { + if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil { + return nil + } + if strings.HasPrefix(cmd.Name(), "+") { + return nil + } + + service := cmd.Parent().Parent().Name() + resource := cmd.Parent().Name() + method := cmd.Name() + + spec := registry.LoadFromMeta(service) + if spec == nil { + return nil + } + resources, _ := spec["resources"].(map[string]interface{}) + resMap, _ := resources[resource].(map[string]interface{}) + if resMap == nil { + return nil + } + methods, _ := resMap["methods"].(map[string]interface{}) + methodMap, _ := methods[method].(map[string]interface{}) + if methodMap == nil { + return nil + } + return declaredScopesForMethod(methodMap, identity) +} + +func declaredScopesForMethod(method map[string]interface{}, identity string) []string { + if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { + return interfaceStrings(requiredRaw) + } + + rawScopes, _ := method["scopes"].([]interface{}) + if len(rawScopes) == 0 { + return nil + } + recommended := registry.SelectRecommendedScope(rawScopes, identity) + if recommended == "" { + for _, raw := range rawScopes { + if scope, ok := raw.(string); ok && scope != "" { + recommended = scope + break + } + } + } + if recommended == "" { + return nil + } + return []string{recommended} +} + +func interfaceStrings(values []interface{}) []string { + scopes := make([]string, 0, len(values)) + for _, value := range values { + scope, ok := value.(string) + if !ok || scope == "" { + continue + } + scopes = append(scopes, scope) + } + return scopes +} + +func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool { + authTypes := sc.AuthTypes + if len(authTypes) == 0 { + authTypes = []string{string(core.AsUser)} + } + for _, authType := range authTypes { + if authType == identity { + return true + } + } + return false +} diff --git a/cmd/root.go b/cmd/root.go index 34bf559b46..aaced60c36 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -192,6 +192,7 @@ func handleRootError(f *cmdutil.Factory, err error) int { if !exitErr.Raw { // Raw errors (e.g. from `api` command) preserve the original API // error detail; skip enrichment which would clear it. + enrichMissingScopeError(f, exitErr) enrichPermissionError(f, exitErr) } output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity)) diff --git a/cmd/root_test.go b/cmd/root_test.go index cbf3ccf386..a5a3fd6c53 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -11,9 +11,12 @@ import ( "github.com/larksuite/cli/cmd/auth" cmdconfig "github.com/larksuite/cli/cmd/config" "github.com/larksuite/cli/cmd/schema" + internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/spf13/cobra" ) // TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that @@ -188,6 +191,95 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) { } } +func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + f.ResolvedIdentity = core.AsUser + + var target registry.CommandEntry + for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") { + if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" { + target = entry + break + } + } + if target.Command == "" { + t.Fatal("failed to locate a calendar create command in local registry metadata") + } + parts := strings.Split(target.Command, " ") + if len(parts) != 2 { + t.Fatalf("expected resource/method command, got %q", target.Command) + } + + root := &cobra.Command{Use: "lark-cli"} + serviceCmd := &cobra.Command{Use: "calendar"} + resourceCmd := &cobra.Command{Use: parts[0]} + methodCmd := &cobra.Command{Use: parts[1]} + root.AddCommand(serviceCmd) + serviceCmd.AddCommand(resourceCmd) + resourceCmd.AddCommand(methodCmd) + f.CurrentCommand = methodCmd + + exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{}) + enrichMissingScopeError(f, exitErr) + + if exitErr.Code != output.ExitAPI { + t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" { + t.Fatalf("expected api_error detail, got %+v", exitErr.Detail) + } + if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") { + t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") { + t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint) + } + if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") { + t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint) + } + if exitErr.Detail.Detail != nil { + t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail) + } +} + +func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + f.ResolvedIdentity = core.AsUser + + root := &cobra.Command{Use: "lark-cli"} + serviceCmd := &cobra.Command{Use: "docs"} + shortcutCmd := &cobra.Command{Use: "+create"} + root.AddCommand(serviceCmd) + serviceCmd.AddCommand(shortcutCmd) + f.CurrentCommand = shortcutCmd + + exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{}) + enrichMissingScopeError(f, exitErr) + + if exitErr.Code != output.ExitNetwork { + t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "network" { + t.Fatalf("expected network detail, got %+v", exitErr.Detail) + } + if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") { + t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") { + t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint) + } + if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") { + t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint) + } + if exitErr.Detail.Detail != nil { + t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail) + } +} + func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") { t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong) diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index e0669fc7aa..1ccfee4402 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -39,6 +39,7 @@ type Factory struct { Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests) IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call + CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun Credential *credential.CredentialProvider From bf24a3151a9711ba3ed4b86fbf3b12bc30e1746e Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Fri, 8 May 2026 10:42:46 +0800 Subject: [PATCH 2/4] fix(auth): handle existing hints in missing scope error --- cmd/error_auth_hint.go | 22 +++++++++++++++++++++- cmd/root_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 7a4305107a..027de2573c 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -34,9 +34,16 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) { return } - exitErr.Detail.Hint = fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", ")) + scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", ")) + if exitErr.Detail.Hint == "" { + exitErr.Detail.Hint = scopeHint + return + } + exitErr.Detail.Hint += "\n" + scopeHint } +// isNeedUserAuthorizationError reports whether err represents a missing-UAT +// failure, either as the original auth error or as a wrapped ExitError. func isNeedUserAuthorizationError(err error) bool { var needAuthErr *internalauth.NeedAuthorizationError if errors.As(err, &needAuthErr) { @@ -50,6 +57,9 @@ func isNeedUserAuthorizationError(err error) bool { return strings.Contains(err.Error(), "need_user_authorization") } +// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the +// current command for the resolved identity, checking shortcuts first and then +// service methods from local registry metadata. func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string { if f == nil || f.CurrentCommand == nil { return nil @@ -69,6 +79,8 @@ func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string { return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity) } +// resolveDeclaredShortcutScopes returns the scopes declared by a mounted +// shortcut command for the given identity. func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string { if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") { return nil @@ -88,6 +100,8 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string return nil } +// resolveDeclaredServiceMethodScopes returns the scopes declared by a +// service/resource/method command from the embedded from_meta registry. func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string { if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil { return nil @@ -117,6 +131,8 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s return declaredScopesForMethod(methodMap, identity) } +// declaredScopesForMethod returns all requiredScopes when present; otherwise it +// resolves the single recommended scope from the method's scopes list. func declaredScopesForMethod(method map[string]interface{}, identity string) []string { if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { return interfaceStrings(requiredRaw) @@ -141,6 +157,8 @@ func declaredScopesForMethod(method map[string]interface{}, identity string) []s return []string{recommended} } +// interfaceStrings converts a []interface{} containing strings into a compact +// []string, skipping empty or non-string values. func interfaceStrings(values []interface{}) []string { scopes := make([]string, 0, len(values)) for _, value := range values { @@ -153,6 +171,8 @@ func interfaceStrings(values []interface{}) []string { return scopes } +// shortcutSupportsIdentity reports whether a shortcut supports the requested +// identity, applying the default user-only behavior when AuthTypes is empty. func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool { authTypes := sc.AuthTypes if len(authTypes) == 0 { diff --git a/cmd/root_test.go b/cmd/root_test.go index a5a3fd6c53..0718898819 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -192,6 +192,8 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) { } func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) @@ -245,6 +247,8 @@ func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testin } func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) @@ -280,6 +284,31 @@ func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing. } } +func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + f.ResolvedIdentity = core.AsUser + + root := &cobra.Command{Use: "lark-cli"} + serviceCmd := &cobra.Command{Use: "docs"} + shortcutCmd := &cobra.Command{Use: "+create"} + root.AddCommand(serviceCmd) + serviceCmd.AddCommand(shortcutCmd) + f.CurrentCommand = shortcutCmd + + exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{}) + exitErr.Detail.Hint = "existing hint" + enrichMissingScopeError(f, exitErr) + + want := "existing hint\ncurrent command requires scope(s): docx:document:create" + if exitErr.Detail.Hint != want { + t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint) + } +} + func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") { t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong) From 9e65a0de8fe1381ced04c43bbdc3aeccfa5f61fa Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Fri, 8 May 2026 11:00:21 +0800 Subject: [PATCH 3/4] refactor(auth): centralize user authorization error detection --- cmd/error_auth_hint.go | 22 +++++----------------- internal/auth/errors.go | 20 +++++++++++++++++++- internal/auth/errors_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 internal/auth/errors_test.go diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 027de2573c..9218afa5d2 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -4,7 +4,6 @@ package cmd import ( - "errors" "fmt" "strings" @@ -25,7 +24,7 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) { if exitErr == nil || exitErr.Detail == nil { return } - if !isNeedUserAuthorizationError(exitErr) { + if !internalauth.IsNeedUserAuthorizationError(exitErr) { return } @@ -42,21 +41,6 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) { exitErr.Detail.Hint += "\n" + scopeHint } -// isNeedUserAuthorizationError reports whether err represents a missing-UAT -// failure, either as the original auth error or as a wrapped ExitError. -func isNeedUserAuthorizationError(err error) bool { - var needAuthErr *internalauth.NeedAuthorizationError - if errors.As(err, &needAuthErr) { - return true - } - - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - return strings.Contains(exitErr.Detail.Message, "need_user_authorization") - } - return strings.Contains(err.Error(), "need_user_authorization") -} - // resolveDeclaredScopesForCurrentCommand returns the scopes declared by the // current command for the resolved identity, checking shortcuts first and then // service methods from local registry metadata. @@ -103,6 +87,10 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string // resolveDeclaredServiceMethodScopes returns the scopes declared by a // service/resource/method command from the embedded from_meta registry. func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string { + // Service-method scope lookup only applies to commands mounted as + // root -> service -> resource -> method. Non-resource/method commands + // intentionally return no scopes here so auth-hint enrichment does not + // change runtime semantics for other command shapes. if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil { return nil } diff --git a/internal/auth/errors.go b/internal/auth/errors.go index 2a61eeab22..83afdddeb3 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -4,7 +4,9 @@ package auth import ( + "errors" "fmt" + "strings" "github.com/larksuite/cli/internal/output" ) @@ -12,6 +14,7 @@ import ( const ( LarkErrBlockByPolicy = 21001 // access denied by access control policy LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access + needUserAuthorizationMarker = "need_user_authorization" ) // RefreshTokenRetryable contains error codes that allow one immediate retry. @@ -33,7 +36,22 @@ type NeedAuthorizationError struct { // Error returns the error message for NeedAuthorizationError. func (e *NeedAuthorizationError) Error() string { - return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId) + return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId) +} + +// IsNeedUserAuthorizationError reports whether err represents a missing-UAT +// failure, either as the original auth error or as a wrapped ExitError. +func IsNeedUserAuthorizationError(err error) bool { + var needAuthErr *NeedAuthorizationError + if errors.As(err, &needAuthErr) { + return true + } + + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker) + } + return strings.Contains(err.Error(), needUserAuthorizationMarker) } // SecurityPolicyError is returned when a request is blocked by access control policies. diff --git a/internal/auth/errors_test.go b/internal/auth/errors_test.go new file mode 100644 index 0000000000..fba89f40be --- /dev/null +++ b/internal/auth/errors_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "github.com/larksuite/cli/internal/output" +) + +func TestIsNeedUserAuthorizationError(t *testing.T) { + t.Run("direct auth error", func(t *testing.T) { + if !IsNeedUserAuthorizationError(&NeedAuthorizationError{UserOpenId: "u_1"}) { + t.Fatal("expected direct NeedAuthorizationError to match") + } + }) + + t.Run("wrapped exit error", func(t *testing.T) { + err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{}) + if !IsNeedUserAuthorizationError(err) { + t.Fatal("expected wrapped ExitError to match") + } + }) + + t.Run("other error", func(t *testing.T) { + err := output.ErrNetwork("API call failed: timeout") + if IsNeedUserAuthorizationError(err) { + t.Fatal("expected unrelated error not to match") + } + }) +} From 40155028e867fe8c850baafc74c7333c99fc2083 Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Fri, 8 May 2026 11:06:37 +0800 Subject: [PATCH 4/4] fix(auth): handle nil error case in IsNeedUserAuthorizationError --- internal/auth/errors.go | 4 ++++ internal/auth/errors_test.go | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/internal/auth/errors.go b/internal/auth/errors.go index 83afdddeb3..b5186f72c4 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -42,6 +42,10 @@ func (e *NeedAuthorizationError) Error() string { // IsNeedUserAuthorizationError reports whether err represents a missing-UAT // failure, either as the original auth error or as a wrapped ExitError. func IsNeedUserAuthorizationError(err error) bool { + if err == nil { + return false + } + var needAuthErr *NeedAuthorizationError if errors.As(err, &needAuthErr) { return true diff --git a/internal/auth/errors_test.go b/internal/auth/errors_test.go index fba89f40be..bb66e37934 100644 --- a/internal/auth/errors_test.go +++ b/internal/auth/errors_test.go @@ -10,6 +10,12 @@ import ( ) func TestIsNeedUserAuthorizationError(t *testing.T) { + t.Run("nil error", func(t *testing.T) { + if IsNeedUserAuthorizationError(nil) { + t.Fatal("expected nil error not to match") + } + }) + t.Run("direct auth error", func(t *testing.T) { if !IsNeedUserAuthorizationError(&NeedAuthorizationError{UserOpenId: "u_1"}) { t.Fatal("expected direct NeedAuthorizationError to match")