diff --git a/internal/cmdutil/identity_flag_test.go b/internal/cmdutil/identity_flag_test.go index 54d539583..4420e978c 100644 --- a/internal/cmdutil/identity_flag_test.go +++ b/internal/cmdutil/identity_flag_test.go @@ -5,6 +5,7 @@ package cmdutil import ( "context" + "strings" "testing" "github.com/larksuite/cli/internal/core" @@ -66,3 +67,40 @@ func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) { t.Fatalf("default value = %q, want empty string", got) } } + +// TC-10: AuthTypes=["user"] → usage contains "identity type: user" and NOT "bot". +func TestAddShortcutIdentityFlag_UserOnlyAuthTypes(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := &cobra.Command{Use: "test"} + + AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user"}) + + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + wantUsage := "identity type: user" + if flag.Usage != wantUsage { + t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage) + } + if strings.Contains(flag.Usage, "bot") { + t.Errorf("Usage should not contain \"bot\" for user-only shortcut, got %q", flag.Usage) + } +} + +// TC-11: AuthTypes=["user","bot"] → usage == "identity type: user | bot". +func TestAddShortcutIdentityFlag_UserBotAuthTypes(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := &cobra.Command{Use: "test"} + + AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user", "bot"}) + + flag := cmd.Flags().Lookup("as") + if flag == nil { + t.Fatal("expected --as flag to be registered") + } + wantUsage := "identity type: user | bot" + if flag.Usage != wantUsage { + t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage) + } +} diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index f34eac8e1..275b0698f 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -2602,3 +2602,14 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd senderEmail, toAddrs, ccAddrs, ) } + +// validateBotMailboxNotMe rejects the combination of bot identity with --mailbox me. +// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT. +func validateBotMailboxNotMe(runtime *common.RuntimeContext) error { + if runtime.IsBot() && runtime.Str("mailbox") == "me" { + return output.ErrValidation( + "--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " + + "pass an explicit email address, e.g. --mailbox alice@example.com") + } + return nil +} diff --git a/shortcuts/mail/mail_message.go b/shortcuts/mail/mail_message.go index 86fabb51e..577178127 100644 --- a/shortcuts/mail/mail_message.go +++ b/shortcuts/mail/mail_message.go @@ -26,6 +26,9 @@ var MailMessage = common.Shortcut{ {Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBotMailboxNotMe(runtime) + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveMailboxID(runtime) messageID := runtime.Str("message-id") diff --git a/shortcuts/mail/mail_messages.go b/shortcuts/mail/mail_messages.go index 444aa105f..717562248 100644 --- a/shortcuts/mail/mail_messages.go +++ b/shortcuts/mail/mail_messages.go @@ -34,6 +34,9 @@ var MailMessages = common.Shortcut{ {Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBotMailboxNotMe(runtime) + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveMailboxID(runtime) messageIDs := splitByComma(runtime.Str("message-ids")) diff --git a/shortcuts/mail/mail_shortcut_validation_test.go b/shortcuts/mail/mail_shortcut_validation_test.go new file mode 100644 index 000000000..698130210 --- /dev/null +++ b/shortcuts/mail/mail_shortcut_validation_test.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +// assertValidationError fails the test unless err is a *output.ExitError with +// ExitValidation code whose message contains wantSubstr. +func assertValidationError(t *testing.T, err error, wantSubstr string) { + t.Helper() + if err == nil { + t.Fatal("expected a validation error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, exitErr.Code) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "validation" { + t.Errorf("expected detail type \"validation\", got %+v", exitErr.Detail) + } + if wantSubstr != "" && !strings.Contains(exitErr.Error(), wantSubstr) { + t.Errorf("expected error message to contain %q, got: %v", wantSubstr, exitErr.Error()) + } +} + +// assertValidatePasses fails the test if err is a validation error; other +// errors (e.g. API call failures from missing tokens) are acceptable because +// we only care that the Validate callback passed. +func assertValidatePasses(t *testing.T, err error) { + t.Helper() + if err == nil { + return + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Code == output.ExitValidation { + t.Fatalf("Validate callback should have passed but returned validation error: %v", exitErr) + } + // Non-validation errors (auth/API failures) are expected without HTTP mocks. +} + +// TC-1: +message --as bot --mailbox me → ErrValidation +func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailMessage, []string{ + "+message", "--as", "bot", "--mailbox", "me", "--message-id", "msg_xxx", + }, f, stdout) + assertValidationError(t, err, "does not support --mailbox me") +} + +// TC-2: +message --as bot --mailbox explicit → Validate passes +func TestMailMessageBotExplicitMailboxPassesValidation(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailMessage, []string{ + "+message", "--as", "bot", "--mailbox", "alice@example.com", "--message-id", "msg_xxx", + }, f, stdout) + assertValidatePasses(t, err) +} + +// TC-3: +message --as user --mailbox me → Validate passes +func TestMailMessageUserMailboxMePassesValidation(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailMessage, []string{ + "+message", "--as", "user", "--mailbox", "me", "--message-id", "msg_xxx", + }, f, stdout) + assertValidatePasses(t, err) +} + +// TC-4: +messages --as bot (default mailbox=me) → ErrValidation +func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailMessages, []string{ + "+messages", "--as", "bot", "--message-ids", "msg_xxx", + }, f, stdout) + assertValidationError(t, err, "does not support --mailbox me") +} + +// TC-5: +messages --as bot --mailbox explicit → Validate passes +func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailMessages, []string{ + "+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx", + }, f, stdout) + assertValidatePasses(t, err) +} + +// TC-6: +thread --as bot (default mailbox=me) → ErrValidation +func TestMailThreadBotDefaultMailboxMeReturnsValidationError(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailThread, []string{ + "+thread", "--as", "bot", "--thread-id", "thread_xxx", + }, f, stdout) + assertValidationError(t, err, "does not support --mailbox me") +} + +// TC-7: +thread --as bot --mailbox explicit → Validate passes +func TestMailThreadBotExplicitMailboxPassesValidation(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailThread, []string{ + "+thread", "--as", "bot", "--mailbox", "alice@example.com", "--thread-id", "thread_xxx", + }, f, stdout) + assertValidatePasses(t, err) +} + +// TC-8: +triage --as bot (default mailbox=me) → ErrValidation +func TestMailTriageBotDefaultMailboxMeReturnsValidationError(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailTriage, []string{ + "+triage", "--as", "bot", + }, f, stdout) + assertValidationError(t, err, "does not support --mailbox me") +} + +// TC-9: +triage --as bot --mailbox explicit → Validate passes +func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailTriage, []string{ + "+triage", "--as", "bot", "--mailbox", "alice@example.com", + }, f, stdout) + assertValidatePasses(t, err) +} diff --git a/shortcuts/mail/mail_thread.go b/shortcuts/mail/mail_thread.go index 67d3fbebd..782b2c62f 100644 --- a/shortcuts/mail/mail_thread.go +++ b/shortcuts/mail/mail_thread.go @@ -58,6 +58,9 @@ var MailThread = common.Shortcut{ {Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBotMailboxNotMe(runtime) + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveMailboxID(runtime) threadID := runtime.Str("thread-id") diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 1274c5f11..08507247b 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -64,6 +64,9 @@ var MailTriage = common.Shortcut{ {Name: "labels", Type: "bool", Desc: "include label IDs in output"}, {Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"}, }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBotMailboxNotMe(runtime) + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailbox := resolveMailboxID(runtime) query := runtime.Str("query")