373 lines
14 KiB
Go
373 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
internalauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/recovery"
|
|
"github.com/larksuite/cli/internal/registry"
|
|
"github.com/larksuite/cli/internal/surface"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestRootErrorPresenterCompletesDirectPermissionRecoveryWithoutMutatingProducer(t *testing.T) {
|
|
cause := errors.New("permission cause")
|
|
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
|
|
WithMissingScopes("docx:document").
|
|
WithIdentity("user").
|
|
WithCause(cause)
|
|
|
|
visible := presentRootError(
|
|
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
|
source,
|
|
recovery.NewProjector(nil),
|
|
)
|
|
visibleProblem, ok := errs.ProblemOf(visible)
|
|
if !ok {
|
|
t.Fatalf("visible error = %T, want typed error", visible)
|
|
}
|
|
if visibleProblem.Category != errs.CategoryAuthorization {
|
|
t.Errorf("visible category = %q, want %q", visibleProblem.Category, errs.CategoryAuthorization)
|
|
}
|
|
if visibleProblem.Subtype != errs.SubtypeMissingScope {
|
|
t.Errorf("visible subtype = %q, want %q", visibleProblem.Subtype, errs.SubtypeMissingScope)
|
|
}
|
|
if !errors.Is(visible, cause) {
|
|
t.Errorf("visible error lost cause %v: %v", cause, visible)
|
|
}
|
|
const wantVisible = "run `lark-cli auth login --scope \"docx:document\" --no-wait --json` to get device_code and verification_url; present verification_url to the user exactly and end this turn; after the user confirms authorization, run `lark-cli auth login --device-code <device_code>` in a later turn to finish login"
|
|
if got, want := visibleProblem.Hint, wantVisible; got != want {
|
|
t.Fatalf("visible recovery = %q, want exact split-flow recovery %q", got, want)
|
|
}
|
|
if source.Hint != "" {
|
|
t.Fatalf("presenter mutated producer hint: %q", source.Hint)
|
|
}
|
|
|
|
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
|
surface.CommandAuthLogin: surface.CommandConcealed,
|
|
})
|
|
concealed := presentRootError(
|
|
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
|
source,
|
|
recovery.NewProjector(func() *surface.Plan { return plan }),
|
|
)
|
|
concealedProblem, _ := errs.ProblemOf(concealed)
|
|
if strings.Contains(concealedProblem.Hint, "auth login") ||
|
|
!strings.Contains(concealedProblem.Hint, "supported authorization flow") {
|
|
t.Fatalf("concealed recovery = %q, want target-free fallback", concealedProblem.Hint)
|
|
}
|
|
}
|
|
|
|
func TestRootErrorPresenterUsesDeclaredScopesForCanonicalPermissionRecovery(t *testing.T) {
|
|
const declaredScope = "calendar:calendar.event:read"
|
|
|
|
f := &cmdutil.Factory{ResolvedIdentity: core.AsUser}
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
calendar := &cobra.Command{Use: "calendar"}
|
|
agenda := &cobra.Command{Use: "+agenda"}
|
|
root.AddCommand(calendar)
|
|
calendar.AddCommand(agenda)
|
|
f.CurrentCommand = agenda
|
|
|
|
newSource := func(t *testing.T) (error, *errs.PermissionError) {
|
|
t.Helper()
|
|
err := errclass.BuildAPIError(
|
|
map[string]any{"code": 230027, "msg": "operation unauthorized"},
|
|
errclass.ClassifyContext{Identity: "user"},
|
|
)
|
|
typed, ok := errs.UnwrapTypedError(err)
|
|
if !ok {
|
|
t.Fatalf("source = %T, want typed error", err)
|
|
}
|
|
permission, ok := typed.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("source = %T, want *errs.PermissionError", err)
|
|
}
|
|
if len(permission.MissingScopes) != 0 || !strings.Contains(permission.Hint, "--recommend") {
|
|
t.Fatalf("source = %+v, want canonical generic recovery without server scope facts", permission)
|
|
}
|
|
return err, permission
|
|
}
|
|
|
|
source, sourcePermission := newSource(t)
|
|
sourceHint := sourcePermission.Hint
|
|
visible := presentRootError(f, source, recovery.NewProjector(nil))
|
|
presented, ok := visible.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("visible = %T, want *errs.PermissionError", visible)
|
|
}
|
|
wantVisible := errclass.PermissionRecovery(
|
|
[]string{declaredScope},
|
|
"user",
|
|
errs.SubtypeUserUnauthorized,
|
|
"",
|
|
).String()
|
|
if presented.Hint != wantVisible {
|
|
t.Fatalf("visible recovery = %q, want declared-scope recovery %q", presented.Hint, wantVisible)
|
|
}
|
|
if len(presented.MissingScopes) != 0 {
|
|
t.Fatalf("presentation fabricated missing_scopes: %v", presented.MissingScopes)
|
|
}
|
|
if sourcePermission.Hint != sourceHint || len(sourcePermission.MissingScopes) != 0 {
|
|
t.Fatalf("presenter mutated producer: %+v", sourcePermission)
|
|
}
|
|
|
|
const serverScope = "calendar:calendar.event:read:server"
|
|
serverSource := errclass.BuildAPIError(
|
|
map[string]any{
|
|
"code": 99991679,
|
|
"msg": "missing scope",
|
|
"error": map[string]any{
|
|
"permission_violations": []any{map[string]any{"subject": serverScope}},
|
|
},
|
|
},
|
|
errclass.ClassifyContext{Identity: "user"},
|
|
)
|
|
var serverProducer *errs.PermissionError
|
|
if !errors.As(serverSource, &serverProducer) {
|
|
t.Fatalf("server source = %T, want *errs.PermissionError", serverSource)
|
|
}
|
|
serverPresentedError := presentRootError(f, serverSource, recovery.NewProjector(nil))
|
|
serverPresented, ok := serverPresentedError.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("server presented = %T, want *errs.PermissionError", serverPresentedError)
|
|
}
|
|
wantServer := errclass.PermissionRecovery(
|
|
[]string{serverScope},
|
|
"user",
|
|
errs.SubtypeMissingScope,
|
|
"",
|
|
).String()
|
|
if serverPresented.Hint != wantServer {
|
|
t.Fatalf("server recovery = %q, want authoritative server scope %q", serverPresented.Hint, wantServer)
|
|
}
|
|
if len(serverPresented.MissingScopes) != 1 || serverPresented.MissingScopes[0] != serverScope {
|
|
t.Fatalf("presented missing_scopes = %v, want [%s]", serverPresented.MissingScopes, serverScope)
|
|
}
|
|
if len(serverProducer.MissingScopes) != 1 || serverProducer.MissingScopes[0] != serverScope {
|
|
t.Fatalf("presenter mutated server producer: %+v", serverProducer)
|
|
}
|
|
|
|
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
|
surface.CommandAuthLogin: surface.CommandConcealed,
|
|
})
|
|
concealedSource, _ := newSource(t)
|
|
concealed := presentRootError(f, concealedSource, recovery.NewProjector(func() *surface.Plan { return plan }))
|
|
concealedPermission, ok := concealed.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("concealed = %T, want *errs.PermissionError", concealed)
|
|
}
|
|
wantConcealed := errclass.PermissionRecovery(
|
|
[]string{declaredScope},
|
|
"user",
|
|
errs.SubtypeUserUnauthorized,
|
|
"",
|
|
).Render(plan)
|
|
if concealedPermission.Hint != wantConcealed {
|
|
t.Fatalf("concealed recovery = %q, want declared-scope fallback %q", concealedPermission.Hint, wantConcealed)
|
|
}
|
|
if strings.Contains(concealedPermission.Hint, "auth login") || !strings.Contains(concealedPermission.Hint, declaredScope) {
|
|
t.Fatalf("concealed recovery leaked a command or lost scope context: %q", concealedPermission.Hint)
|
|
}
|
|
|
|
custom := errs.NewPermissionError(errs.SubtypeUserUnauthorized, "permission denied").
|
|
WithIdentity("user").
|
|
WithHint("ask the tenant admin to review the resource policy")
|
|
customPresented := presentRootError(f, custom, recovery.NewProjector(nil))
|
|
customProblem, _ := errs.ProblemOf(customPresented)
|
|
if got, want := customProblem.Hint, custom.Hint; got != want {
|
|
t.Fatalf("custom recovery = %q, want producer guidance %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRootErrorPresenterPreservesPermissionGuidanceWhenAuthLoginIsConcealed(t *testing.T) {
|
|
const authorizationFallback = "obtain or refresh a user credential through this distribution's supported authorization flow, have the user complete authorization, then retry\ncurrent command requires scope(s): im:message"
|
|
tests := []struct {
|
|
name string
|
|
subtype errs.Subtype
|
|
wantHint string
|
|
}{
|
|
{
|
|
name: "token scope insufficient",
|
|
subtype: errs.SubtypeTokenScopeInsufficient,
|
|
wantHint: "check the token's granted scopes; " + authorizationFallback,
|
|
},
|
|
{
|
|
name: "user unauthorized",
|
|
subtype: errs.SubtypeUserUnauthorized,
|
|
wantHint: authorizationFallback + "; if re-auth does not help, the operation may be blocked by external-chat or admin policy",
|
|
},
|
|
}
|
|
|
|
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
|
surface.CommandAuthLogin: surface.CommandConcealed,
|
|
})
|
|
projector := recovery.NewProjector(func() *surface.Plan { return plan })
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cause := errors.New("permission cause")
|
|
source := errs.NewPermissionError(tt.subtype, "permission denied").
|
|
WithMissingScopes("im:message").
|
|
WithIdentity("user").
|
|
WithCause(cause)
|
|
|
|
rendered := presentRootError(
|
|
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
|
source,
|
|
projector,
|
|
)
|
|
presented, ok := rendered.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("rendered error = %T, want *errs.PermissionError", rendered)
|
|
}
|
|
problem, ok := errs.ProblemOf(rendered)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf(%T) failed: %v", rendered, rendered)
|
|
}
|
|
if problem.Category != errs.CategoryAuthorization || problem.Subtype != tt.subtype {
|
|
t.Errorf("problem = %s/%s, want authorization/%s", problem.Category, problem.Subtype, tt.subtype)
|
|
}
|
|
if got := presented.Hint; got != tt.wantHint {
|
|
t.Fatalf("concealed recovery = %q, want exact joined recovery %q", got, tt.wantHint)
|
|
}
|
|
if strings.Contains(presented.Hint, "auth login") {
|
|
t.Fatalf("concealed recovery leaks unavailable auth login target: %q", presented.Hint)
|
|
}
|
|
if presented.Message != source.Message || presented.Identity != "user" ||
|
|
len(presented.MissingScopes) != 1 || presented.MissingScopes[0] != "im:message" {
|
|
t.Fatalf("presented machine fields = %+v, source = %+v", presented, source)
|
|
}
|
|
if !errors.Is(rendered, cause) {
|
|
t.Fatalf("rendered error lost cause %v: %v", cause, rendered)
|
|
}
|
|
if source.Hint != "" {
|
|
t.Fatalf("presenter mutated producer hint: %q", source.Hint)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRootErrorPresenterDoesNotRecommendUserLoginForBotPermission(t *testing.T) {
|
|
tests := []struct {
|
|
subtype errs.Subtype
|
|
want string
|
|
}{
|
|
{subtype: errs.SubtypeMissingScope, want: "app developer"},
|
|
{subtype: errs.SubtypeTokenScopeInsufficient, want: "token's granted scopes"},
|
|
{subtype: errs.SubtypeUserUnauthorized, want: "required bot permissions"},
|
|
{subtype: errs.SubtypePermissionDenied, want: "this bot"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.subtype), func(t *testing.T) {
|
|
source := errs.NewPermissionError(tt.subtype, "bot permission failure").
|
|
WithMissingScopes("drive:file:download").
|
|
WithIdentity("bot")
|
|
|
|
rendered := presentRootError(
|
|
&cmdutil.Factory{ResolvedIdentity: core.AsBot},
|
|
source,
|
|
recovery.NewProjector(nil),
|
|
)
|
|
problem, ok := errs.ProblemOf(rendered)
|
|
if !ok {
|
|
t.Fatalf("rendered error = %T, want typed permission error", rendered)
|
|
}
|
|
for _, forbidden := range []string{"auth login", "verification_url", "device_code", "user credential"} {
|
|
if strings.Contains(strings.ToLower(problem.Hint), forbidden) {
|
|
t.Errorf("bot recovery %q contains user OAuth guidance %q", problem.Hint, forbidden)
|
|
}
|
|
}
|
|
if !strings.Contains(problem.Hint, tt.want) {
|
|
t.Errorf("bot recovery = %q, want guidance containing %q", problem.Hint, tt.want)
|
|
}
|
|
if source.Hint != "" || source.Identity != "bot" || len(source.MissingScopes) != 1 {
|
|
t.Errorf("presenter mutated producer: %+v", source)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRootErrorPresenterDoesNotMutateNestedPermissionCause(t *testing.T) {
|
|
inner := errs.NewPermissionError(errs.SubtypeMissingScope, "inner permission").
|
|
WithMissingScopes("docx:document").
|
|
WithIdentity("user")
|
|
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
|
|
WithHint("retry the operation").
|
|
WithCause(inner)
|
|
|
|
rendered := presentRootError(
|
|
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
|
|
outer,
|
|
recovery.NewProjector(nil),
|
|
)
|
|
|
|
if inner.Hint != "" {
|
|
t.Fatalf("presenter mutated nested producer hint: %q", inner.Hint)
|
|
}
|
|
problem, _ := errs.ProblemOf(rendered)
|
|
if got, want := problem.Hint, "retry the operation"; got != want {
|
|
t.Fatalf("rendered outer hint = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRootErrorPresenterDoesNotMutateNestedAuthenticationCause(t *testing.T) {
|
|
f := factoryWithDeclaredServiceScope(t)
|
|
source := internalauth.NewNeedUserAuthorizationError("ou_nested")
|
|
var inner *errs.AuthenticationError
|
|
if !errors.As(source, &inner) {
|
|
t.Fatalf("source = %T, want nested *errs.AuthenticationError", source)
|
|
}
|
|
originalHint := inner.Hint
|
|
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
|
|
WithHint("retry the operation").
|
|
WithCause(source)
|
|
|
|
rendered := presentRootError(f, outer, recovery.NewProjector(nil))
|
|
|
|
if got := inner.Hint; got != originalHint {
|
|
t.Fatalf("presenter mutated nested authentication hint: got %q want %q", got, originalHint)
|
|
}
|
|
problem, _ := errs.ProblemOf(rendered)
|
|
if got, want := problem.Hint, "retry the operation"; got != want {
|
|
t.Fatalf("rendered outer hint = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func factoryWithDeclaredServiceScope(t *testing.T) *cmdutil.Factory {
|
|
t.Helper()
|
|
f := &cmdutil.Factory{ResolvedIdentity: core.AsUser}
|
|
var target registry.CommandEntry
|
|
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
|
if len(entry.Scopes) > 0 {
|
|
target = entry
|
|
break
|
|
}
|
|
}
|
|
if target.Command == "" {
|
|
t.Fatal("failed to locate a service command with declared user scopes")
|
|
}
|
|
parts := strings.Split(target.Command, " ")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("service command = %q, want resource and method", target.Command)
|
|
}
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
domain := &cobra.Command{Use: "calendar"}
|
|
resource := &cobra.Command{Use: parts[0]}
|
|
method := &cobra.Command{Use: parts[1]}
|
|
root.AddCommand(domain)
|
|
domain.AddCommand(resource)
|
|
resource.AddCommand(method)
|
|
f.CurrentCommand = method
|
|
return f
|
|
}
|