Files
larksuite-cli/internal/cmdutil/factory_test.go
sang-neo03 fe9dc4ce6a fix(strict-mode): reject explicit --as instead of silently overriding it (#673)
* fix(strict-mode): reject explicit --as instead of silently overriding it

ResolveAs checked strict mode before the --as flag, so `--as bot` under strict=user
  was silently rewritten to user. Reorder so explicit --as is returned as-is and CheckStrictMode rejects the conflict (exit=2). Implicit paths (--as auto / unset) are still forced by
   strict mode.

* fix(strict-mode): fix CI
2026-04-27 15:18:35 +08:00

474 lines
15 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
func newCmdWithAsFlag(asValue string, changed bool) *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("as", "auto", "identity")
if changed {
_ = cmd.Flags().Set("as", asValue)
}
return cmd
}
// --- ResolveAs tests ---
func TestResolveAs_ExplicitAs(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for explicit --as")
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAsUser(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("want user, got %s", got)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) {
// --as auto explicitly: should fall through to auto-detect
// Config has no UserOpenId → auto-detect returns bot
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (auto-detect, no login), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true for auto-detect path")
}
}
func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "bot",
})
cmd := newCmdWithAsFlag("auto", false) // --as not changed
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (from default-as config), got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for default-as path")
}
}
func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) {
t.Setenv(envvars.CliDefaultAs, "user")
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (env default-as should not bypass config source), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true when no account default-as is set")
}
}
func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
// default-as = "auto" should fall through to auto-detect
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "auto",
})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
// No UserOpenId → auto-detect returns bot
if got != core.AsBot {
t.Errorf("want bot (auto-detect), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true")
}
}
func TestResolveAs_NilCmd_AutoDetect(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
got := f.ResolveAs(context.Background(), nil, "auto")
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
}
// --- CheckIdentity tests ---
func TestCheckIdentity_Supported(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsBot, []string{"bot", "user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Supported_UserOnly(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsUser, []string{"user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Unsupported_Explicit(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = false // explicit --as
err := f.CheckIdentity(core.AsUser, []string{"bot"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--as user is not supported") {
t.Errorf("unexpected error message: %v", err)
}
if !strings.Contains(err.Error(), "bot") {
t.Errorf("error should mention supported identity: %v", err)
}
}
func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "resolved identity") {
t.Errorf("expected 'resolved identity' in error, got: %v", err)
}
if !strings.Contains(err.Error(), "hint: use --as bot") {
t.Errorf("expected hint in error, got: %v", err)
}
}
// --- NewAPIClient / NewAPIClientWithConfig tests ---
func TestNewAPIClient(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClient()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
}
func TestNewAPIClientWithConfig(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
if ac.SDK == nil {
t.Error("SDK should not be nil")
}
if ac.HTTP == nil {
t.Error("HTTP should not be nil")
}
}
func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
f.IOStreams = nil
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac == nil {
t.Fatal("expected non-nil APIClient")
}
}
// --- ResolveStrictMode tests ---
func TestResolveStrictMode_Off(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off, got %q", got)
}
}
func TestResolveStrictMode_BotFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Errorf("expected bot, got %q", got)
}
}
func TestResolveStrictMode_UserFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeUser {
t.Errorf("expected user, got %q", got)
}
}
func TestResolveStrictMode_BothIdentities(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off when both supported, got %q", got)
}
}
func TestResolveStrictMode_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.Credential = nil
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off with nil credential, got %q", got)
}
}
// --- CheckStrictMode tests ---
func TestCheckStrictMode_BotMode_BotAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed in bot mode, got: %v", err)
}
}
func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error for user in bot mode")
}
if !strings.Contains(err.Error(), "strict mode") {
t.Errorf("error should mention strict mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_UserAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed in user mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_BotBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsBot)
if err == nil {
t.Fatal("expected error for bot in user mode")
}
}
func TestCheckStrictMode_Off_BothAllowed(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed when off: %v", err)
}
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed when off: %v", err)
}
}
// --- ResolveAs strict mode tests ---
func TestResolveAs_StrictModeBot_ForceBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should force bot, got %s", got)
}
}
func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsUser {
t.Errorf("user mode should force user, got %s", got)
}
}
func TestResolveAs_StrictModeUser_PreservesExplicitBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("explicit bot should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit bot in user mode")
}
}
func TestResolveAs_StrictModeBot_PreservesExplicitUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("explicit user should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit user in bot mode")
}
}
func TestResolveAs_StrictModeUser_ExplicitAutoForcesUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, core.AsAuto)
if got != core.AsUser {
t.Errorf("--as auto should use strict-mode user identity, got %s", got)
}
}
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should override default-as user, got %s", got)
}
}
// stubExtProvider is a minimal extcred.Provider for testing external-provider guards.
type stubExtProvider struct {
name string
acct *extcred.Account
err error
}
func (s *stubExtProvider) Name() string { return s.name }
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return s.acct, s.err
}
func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider")
}
if exitErr.Detail.Message == "" {
t.Error("expected non-empty message")
}
if exitErr.Detail.Hint == "" {
t.Error("expected non-empty hint")
}
}
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
// No extension providers → built-in path → no error
f, _, _, _ := TestFactory(t, nil)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error with nil Credential: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if !errors.Is(err, sentinel) {
t.Fatalf("error = %v, want sentinel", err)
}
}