mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
* feat(credential): add ActiveExtensionProviderName to detect external providers Change-Id: Ie17a4b714e5eca17ae574ac188d570721790107d * feat(cmdutil): add RequireBuiltinCredentialProvider guard for external credential providers Change-Id: I8f2ea0af6fe6506b29beb69264b04c21c0f75da1 * feat(config): block all config subcommands when external credential provider is active Change-Id: If215cb8f0a53cc92d623dd3d842e4465124af2be * feat(auth): block all auth subcommands when external credential provider is active Change-Id: Ia61184fb2daeb6a7a38d122c647b7cb67eaf8b1f * fix(auth,config): silence usage in PersistentPreRunE to match root command behaviour Change-Id: I6d4b3c7d9d9c7b10fc2482fdc80252bf051771ee * test(auth,config,credential): address CodeRabbit review comments - Use cmd.Find() to assert SilenceUsage on matched subcommand (not parent) - Add TestRequireBuiltinCredentialProvider_PropagatesProviderError for error path - Add 'external' fallback sentinel in ActiveExtensionProviderName Change-Id: Iba35779ad2ed9807556264ba23db7096541e2bf3
494 lines
15 KiB
Go
494 lines
15 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credential
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/core"
|
|
)
|
|
|
|
type mockExtProvider struct {
|
|
name string
|
|
account *extcred.Account
|
|
token *extcred.Token
|
|
err error
|
|
accountErr error
|
|
tokenErr error
|
|
}
|
|
|
|
func (m *mockExtProvider) Name() string { return m.name }
|
|
func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
|
if m.accountErr != nil {
|
|
return nil, m.accountErr
|
|
}
|
|
return m.account, m.err
|
|
}
|
|
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
|
if m.tokenErr != nil {
|
|
return nil, m.tokenErr
|
|
}
|
|
return m.token, m.err
|
|
}
|
|
|
|
type mockDefaultAcct struct {
|
|
account *Account
|
|
err error
|
|
}
|
|
|
|
func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error) {
|
|
return m.account, m.err
|
|
}
|
|
|
|
type mockDefaultToken struct {
|
|
result *TokenResult
|
|
err error
|
|
}
|
|
|
|
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
|
return m.result, m.err
|
|
}
|
|
|
|
func TestCredentialProvider_AccountFromExtension(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "ext_app", Brand: "lark"}}},
|
|
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
|
&mockDefaultToken{}, nil,
|
|
)
|
|
acct, err := cp.ResolveAccount(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if acct.AppID != "ext_app" {
|
|
t.Errorf("expected ext_app, got %s", acct.AppID)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_AccountFallsToDefault(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
|
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: "feishu"}},
|
|
&mockDefaultToken{}, nil,
|
|
)
|
|
acct, err := cp.ResolveAccount(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if acct.AppID != "default_app" {
|
|
t.Errorf("expected default_app, got %s", acct.AppID)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_AccountBlockStopsChain(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "blocker", err: &extcred.BlockError{Provider: "blocker", Reason: "denied"}}},
|
|
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
|
&mockDefaultToken{}, nil,
|
|
)
|
|
_, err := cp.ResolveAccount(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var blockErr *extcred.BlockError
|
|
if !errors.As(err, &blockErr) {
|
|
t.Fatalf("expected BlockError, got %T", err)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_AccountCached(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "cached"}}},
|
|
nil, nil, nil,
|
|
)
|
|
a1, _ := cp.ResolveAccount(context.Background())
|
|
a2, _ := cp.ResolveAccount(context.Background())
|
|
if a1 != a2 {
|
|
t.Error("expected same pointer (cached)")
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{
|
|
name: "env",
|
|
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
|
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
|
}},
|
|
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
|
)
|
|
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Token != "ext_tok" {
|
|
t.Errorf("expected ext_tok, got %s", result.Token)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
|
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
|
)
|
|
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Token != "default_tok" {
|
|
t.Errorf("expected default_tok, got %s", result.Token)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
|
|
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu}},
|
|
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
|
nil,
|
|
)
|
|
|
|
if _, err := cp.ResolveAccount(context.Background()); err != nil {
|
|
t.Fatalf("ResolveAccount() error = %v", err)
|
|
}
|
|
|
|
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
|
if err != nil {
|
|
t.Fatalf("ResolveToken() error = %v", err)
|
|
}
|
|
if result.Token != "default_tok" {
|
|
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "default_tok")
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{
|
|
name: "env",
|
|
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
|
}},
|
|
nil, nil, nil,
|
|
)
|
|
|
|
if _, err := cp.ResolveAccount(context.Background()); err != nil {
|
|
t.Fatalf("ResolveAccount() error = %v", err)
|
|
}
|
|
|
|
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
|
if err == nil {
|
|
t.Fatal("ResolveToken() error = nil, want unavailable error")
|
|
}
|
|
var unavailableErr *TokenUnavailableError
|
|
if !errors.As(err, &unavailableErr) {
|
|
t.Fatalf("ResolveToken() error type = %T, want *TokenUnavailableError", err)
|
|
}
|
|
if unavailableErr.Source != "env" || unavailableErr.Type != TokenTypeUAT {
|
|
t.Fatalf("ResolveToken() unavailable error = %+v, want source env and type uat", unavailableErr)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", err: errors.New("provider exploded")}},
|
|
nil,
|
|
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
|
nil,
|
|
)
|
|
|
|
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
|
if err == nil || err.Error() != "provider exploded" {
|
|
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveIdentityHint_FromExtensionAccount(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
|
AppID: "ext_app",
|
|
Brand: "feishu",
|
|
DefaultAs: extcred.IdentityUser,
|
|
SupportedIdentities: extcred.SupportsUser,
|
|
}}},
|
|
nil, nil, nil,
|
|
)
|
|
|
|
hint, err := cp.ResolveIdentityHint(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
|
}
|
|
if hint.DefaultAs != core.AsUser {
|
|
t.Fatalf("ResolveIdentityHint() defaultAs = %q, want %q", hint.DefaultAs, core.AsUser)
|
|
}
|
|
if hint.AutoAs != core.AsUser {
|
|
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveIdentityHint_DefaultSourceUsesStoredTokenState(t *testing.T) {
|
|
origGetStoredToken := getStoredToken
|
|
origTokenStatus := getStoredTokenStatus
|
|
t.Cleanup(func() {
|
|
getStoredToken = origGetStoredToken
|
|
getStoredTokenStatus = origTokenStatus
|
|
})
|
|
|
|
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
|
|
if appID != "default_app" || userOpenID != "ou_default" {
|
|
t.Fatalf("GetStoredToken() args = (%q, %q), want (%q, %q)", appID, userOpenID, "default_app", "ou_default")
|
|
}
|
|
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
|
|
}
|
|
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
|
|
return "valid"
|
|
}
|
|
|
|
cp := NewCredentialProvider(
|
|
nil,
|
|
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
|
|
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
|
nil,
|
|
)
|
|
|
|
hint, err := cp.ResolveIdentityHint(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
|
}
|
|
if hint.AutoAs != core.AsUser {
|
|
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
|
|
origGetStoredToken := getStoredToken
|
|
origTokenStatus := getStoredTokenStatus
|
|
t.Cleanup(func() {
|
|
getStoredToken = origGetStoredToken
|
|
getStoredTokenStatus = origTokenStatus
|
|
})
|
|
|
|
storedCalls := 0
|
|
statusCalls := 0
|
|
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
|
|
storedCalls++
|
|
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
|
|
}
|
|
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
|
|
statusCalls++
|
|
return "valid"
|
|
}
|
|
|
|
cp := NewCredentialProvider(
|
|
nil,
|
|
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
|
|
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
|
nil,
|
|
)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
hint, err := cp.ResolveIdentityHint(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ResolveIdentityHint() error = %v", err)
|
|
}
|
|
if hint.AutoAs != core.AsUser {
|
|
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
|
|
}
|
|
}
|
|
|
|
if storedCalls != 1 {
|
|
t.Fatalf("GetStoredToken() calls = %d, want 1", storedCalls)
|
|
}
|
|
if statusCalls != 1 {
|
|
t.Fatalf("TokenStatus() calls = %d, want 1", statusCalls)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
nil,
|
|
nil,
|
|
&mockDefaultToken{result: &TokenResult{Token: ""}},
|
|
nil,
|
|
)
|
|
|
|
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
|
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
|
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveAccountDoesNotEnrichWithTokenFromDifferentProvider(t *testing.T) {
|
|
httpClientCalls := 0
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
|
|
&mockDefaultAcct{account: &Account{
|
|
AppID: "default_app",
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_default",
|
|
UserName: "Default User",
|
|
}},
|
|
&mockDefaultToken{},
|
|
func() (*http.Client, error) {
|
|
httpClientCalls++
|
|
return nil, errors.New("unexpected enrich call")
|
|
},
|
|
)
|
|
|
|
acct, err := cp.ResolveAccount(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ResolveAccount() error = %v", err)
|
|
}
|
|
if httpClientCalls != 0 {
|
|
t.Fatalf("httpClient() called %d times, want 0", httpClientCalls)
|
|
}
|
|
if acct.UserOpenId != "ou_default" || acct.UserName != "Default User" {
|
|
t.Fatalf("resolved user = (%q, %q), want (%q, %q)", acct.UserOpenId, acct.UserName, "ou_default", "Default User")
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveAccountClearsUnverifiedExtensionIdentityOnTokenError(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
|
AppID: "ext_app",
|
|
Brand: "feishu",
|
|
OpenID: "ou_ext",
|
|
}, tokenErr: errors.New("token lookup failed")}},
|
|
nil,
|
|
nil,
|
|
func() (*http.Client, error) {
|
|
t.Fatal("httpClient() should not be called when token lookup fails")
|
|
return nil, nil
|
|
},
|
|
)
|
|
|
|
acct, err := cp.ResolveAccount(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ResolveAccount() error = %v", err)
|
|
}
|
|
if acct.UserOpenId != "" || acct.UserName != "" {
|
|
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerificationFails(t *testing.T) {
|
|
var warnBuf bytes.Buffer
|
|
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
|
|
AppID: "ext_app",
|
|
Brand: "feishu",
|
|
OpenID: "ou_ext",
|
|
}, tokenErr: errors.New("token lookup failed")}},
|
|
nil,
|
|
nil,
|
|
func() (*http.Client, error) {
|
|
t.Fatal("httpClient() should not be called when token lookup fails")
|
|
return nil, nil
|
|
},
|
|
)
|
|
cp.SetWarnOut(&warnBuf)
|
|
|
|
acct, err := cp.ResolveAccount(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ResolveAccount() error = %v", err)
|
|
}
|
|
if acct.UserOpenId != "" || acct.UserName != "" {
|
|
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
|
|
}
|
|
if !strings.Contains(warnBuf.String(), "unable to verify user identity from credential source \"env\"") {
|
|
t.Fatalf("warning output = %q, want source-specific verification warning", warnBuf.String())
|
|
}
|
|
if !strings.Contains(warnBuf.String(), "token lookup failed") {
|
|
t.Fatalf("warning output = %q, want underlying error", warnBuf.String())
|
|
}
|
|
}
|
|
|
|
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
nil,
|
|
&mockDefaultAcct{err: errors.New("config unavailable")},
|
|
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
|
nil,
|
|
)
|
|
|
|
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
|
if err == nil || err.Error() != "config unavailable" {
|
|
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
|
|
}
|
|
}
|
|
|
|
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "app"}}},
|
|
nil, nil, nil,
|
|
)
|
|
name, err := cp.ActiveExtensionProviderName(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if name != "env" {
|
|
t.Errorf("got %q, want %q", name, "env")
|
|
}
|
|
}
|
|
|
|
func TestActiveExtensionProviderName_BlockError(t *testing.T) {
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{
|
|
name: "env",
|
|
accountErr: &extcred.BlockError{Provider: "env", Reason: "APP_ID missing"},
|
|
}},
|
|
nil, nil, nil,
|
|
)
|
|
name, err := cp.ActiveExtensionProviderName(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if name != "env" {
|
|
t.Errorf("got %q, want %q", name, "env")
|
|
}
|
|
}
|
|
|
|
func TestActiveExtensionProviderName_NoExtProvider(t *testing.T) {
|
|
cp := NewCredentialProvider(nil, nil, nil, nil)
|
|
name, err := cp.ActiveExtensionProviderName(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if name != "" {
|
|
t.Errorf("got %q, want empty string", name)
|
|
}
|
|
}
|
|
|
|
func TestActiveExtensionProviderName_UnexpectedError(t *testing.T) {
|
|
sentinel := errors.New("network timeout")
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "env", accountErr: sentinel}},
|
|
nil, nil, nil,
|
|
)
|
|
_, err := cp.ActiveExtensionProviderName(context.Background())
|
|
if !errors.Is(err, sentinel) {
|
|
t.Errorf("got %v, want sentinel error", err)
|
|
}
|
|
}
|
|
|
|
func TestActiveExtensionProviderName_SkipsNilProvider(t *testing.T) {
|
|
// nil account + nil error = provider not applicable; fallback returns ""
|
|
cp := NewCredentialProvider(
|
|
[]extcred.Provider{&mockExtProvider{name: "sidecar"}}, // no account set → returns nil, nil
|
|
nil, nil, nil,
|
|
)
|
|
name, err := cp.ActiveExtensionProviderName(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if name != "" {
|
|
t.Errorf("got %q, want empty string", name)
|
|
}
|
|
}
|