mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
* feat: add --json flag support to auth subcommands * feat(auth/logout): add json output support for logout command * feat(auth/list): add json output support for auth list command
561 lines
15 KiB
Go
561 lines
15 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/registry"
|
|
)
|
|
|
|
func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *LoginOptions
|
|
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Scope != "calendar:calendar:read" {
|
|
t.Errorf("expected scope calendar:calendar:read, got %s", gotOpts.Scope)
|
|
}
|
|
if !gotOpts.JSON {
|
|
t.Error("expected JSON=true")
|
|
}
|
|
}
|
|
|
|
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
|
|
cmd.SetOut(stdout)
|
|
cmd.SetErr(io.Discard)
|
|
cmd.SetArgs([]string{"--help"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
got := stdout.String()
|
|
for _, want := range []string{
|
|
"only delivers final turn messages",
|
|
"--no-wait --json",
|
|
"send the verification URL (or QR code) to the user as your final message",
|
|
"run --device-code in a later step",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("help missing %q, got:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *CheckOptions
|
|
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--scope", "calendar:calendar:read drive:drive:read"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Scope != "calendar:calendar:read drive:drive:read" {
|
|
t.Errorf("expected scope string, got %s", gotOpts.Scope)
|
|
}
|
|
}
|
|
|
|
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *CheckOptions
|
|
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Fatal("expected opts to be set")
|
|
}
|
|
if !gotOpts.JSON {
|
|
t.Error("expected JSON=true")
|
|
}
|
|
}
|
|
|
|
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *LogoutOptions
|
|
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Error("expected opts to be set")
|
|
}
|
|
}
|
|
|
|
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *LogoutOptions
|
|
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--json"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Fatal("expected opts to be set")
|
|
}
|
|
if !gotOpts.JSON {
|
|
t.Error("expected JSON=true")
|
|
}
|
|
}
|
|
|
|
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *ListOptions
|
|
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Error("expected opts to be set")
|
|
}
|
|
}
|
|
|
|
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *ListOptions
|
|
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--json"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Error("expected opts to be set")
|
|
}
|
|
if !gotOpts.JSON {
|
|
t.Error("expected JSON=true")
|
|
}
|
|
}
|
|
|
|
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *StatusOptions
|
|
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Error("expected opts to be set")
|
|
}
|
|
}
|
|
|
|
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *StatusOptions
|
|
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--json"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Error("expected opts to be set")
|
|
}
|
|
if !gotOpts.JSON {
|
|
t.Error("expected JSON=true")
|
|
}
|
|
}
|
|
|
|
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *StatusOptions
|
|
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--verify"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Fatal("expected opts to be set")
|
|
}
|
|
if !gotOpts.Verify {
|
|
t.Error("expected Verify=true when --verify flag is passed")
|
|
}
|
|
}
|
|
|
|
func TestDomainFlagCompletion(t *testing.T) {
|
|
allDomains := registry.ListFromMetaProjects()
|
|
|
|
tests := []struct {
|
|
name string
|
|
toComplete string
|
|
wantContains []string
|
|
wantExclude []string
|
|
}{
|
|
{
|
|
name: "empty returns all domains",
|
|
toComplete: "",
|
|
wantContains: allDomains,
|
|
},
|
|
{
|
|
name: "partial match",
|
|
toComplete: "cal",
|
|
wantContains: []string{"calendar"},
|
|
wantExclude: []string{"bitable", "drive", "task"},
|
|
},
|
|
{
|
|
name: "comma prefix completes second value",
|
|
toComplete: "calendar,",
|
|
wantContains: func() []string {
|
|
var out []string
|
|
for _, d := range allDomains {
|
|
out = append(out, "calendar,"+d)
|
|
}
|
|
return out
|
|
}(),
|
|
},
|
|
{
|
|
name: "comma with partial second value",
|
|
toComplete: "calendar,ta",
|
|
wantContains: []string{"calendar,task"},
|
|
wantExclude: []string{"calendar,bitable", "calendar,drive"},
|
|
},
|
|
{
|
|
name: "no match returns empty",
|
|
toComplete: "xxx",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
comps := completeDomain(tt.toComplete)
|
|
sort.Strings(comps)
|
|
|
|
for _, want := range tt.wantContains {
|
|
found := false
|
|
for _, c := range comps {
|
|
if c == want {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("completions %v missing expected %q", comps, want)
|
|
}
|
|
}
|
|
|
|
for _, exclude := range tt.wantExclude {
|
|
for _, c := range comps {
|
|
if c == exclude {
|
|
t.Errorf("completions %v should not contain %q", comps, exclude)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify no completion contains trailing comma artifacts
|
|
for _, c := range comps {
|
|
if strings.HasSuffix(c, ",") {
|
|
t.Errorf("completion %q should not end with comma", c)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *ScopesOptions
|
|
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--format", "json"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Format != "json" {
|
|
t.Errorf("expected format json, got %s", gotOpts.Format)
|
|
}
|
|
}
|
|
|
|
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *ScopesOptions
|
|
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Fatal("expected opts to be set")
|
|
}
|
|
if !gotOpts.JSON {
|
|
t.Error("expected JSON=true")
|
|
}
|
|
if gotOpts.Format != "json" {
|
|
t.Errorf("expected format json, got %s", gotOpts.Format)
|
|
}
|
|
}
|
|
|
|
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
|
})
|
|
tokenResolver := &authScopesTokenResolver{}
|
|
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
|
|
|
appInfoStub := &httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/application/v6/applications/test-app",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"app": map[string]interface{}{
|
|
"creator_id": "ou_creator",
|
|
"scopes": []map[string]interface{}{
|
|
{
|
|
"scope": "im:message",
|
|
"token_types": []string{"tenant"},
|
|
},
|
|
{
|
|
"scope": "im:message:send_as_user",
|
|
"token_types": []string{"user"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(appInfoStub)
|
|
|
|
err := authScopesRun(&ScopesOptions{
|
|
Factory: f,
|
|
Ctx: context.Background(),
|
|
Format: "json",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authScopesRun() error = %v", err)
|
|
}
|
|
|
|
if len(tokenResolver.requests) != 1 {
|
|
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
|
|
}
|
|
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
|
|
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
|
|
}
|
|
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
|
|
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
|
|
}
|
|
}
|
|
|
|
// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when
|
|
// the Lark API returns a permission code (99991679 with permission_violations),
|
|
// getAppInfo classifies it as *errs.PermissionError carrying the server-
|
|
// supplied MissingScopes — not a bare error wrapped as InternalError.
|
|
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
tokenResolver := &authScopesTokenResolver{}
|
|
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/application/v6/applications/test-app",
|
|
Body: map[string]interface{}{
|
|
"code": 99991679,
|
|
"msg": "scope missing",
|
|
"error": map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "application:application:self_manage"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := authScopesRun(&ScopesOptions{
|
|
Factory: f,
|
|
Ctx: context.Background(),
|
|
Format: "json",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
var pe *errs.PermissionError
|
|
if !errors.As(err, &pe) {
|
|
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
|
}
|
|
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" {
|
|
t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes)
|
|
}
|
|
|
|
var intErr *errs.InternalError
|
|
if errors.As(err, &intErr) {
|
|
t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost")
|
|
}
|
|
}
|
|
|
|
type authScopesTokenResolver struct {
|
|
requests []credential.TokenSpec
|
|
}
|
|
|
|
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
|
r.requests = append(r.requests, req)
|
|
switch req.Type {
|
|
case credential.TokenTypeTAT:
|
|
return &credential.TokenResult{Token: "tenant-token"}, nil
|
|
case credential.TokenTypeUAT:
|
|
return &credential.TokenResult{Token: "user-token"}, nil
|
|
default:
|
|
return &credential.TokenResult{Token: "unexpected-token"}, nil
|
|
}
|
|
}
|
|
|
|
// stubExternalProvider is a minimal extcred.Provider that always reports an account,
|
|
// simulating env/sidecar mode for guard tests.
|
|
type stubExternalProvider struct{ name string }
|
|
|
|
func (s *stubExternalProvider) Name() string { return s.name }
|
|
func (s *stubExternalProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
|
return &extcred.Account{AppID: "test-app"}, nil
|
|
}
|
|
func (s *stubExternalProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// newFactoryWithExternalProvider creates a Factory whose Credential uses a stub
|
|
// extension provider, simulating env/sidecar credential mode.
|
|
func newFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
|
|
t.Helper()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
stub := &stubExternalProvider{name: "env"}
|
|
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
f.Credential = cred
|
|
return f
|
|
}
|
|
|
|
func TestAuthBlockedByExternalProvider(t *testing.T) {
|
|
f := newFactoryWithExternalProvider(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{"login", []string{"login"}},
|
|
{"logout", []string{"logout"}},
|
|
{"status", []string{"status"}},
|
|
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
|
|
{"list", []string{"list"}},
|
|
{"scopes", []string{"scopes"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := NewCmdAuth(f)
|
|
cmd.SilenceErrors = true
|
|
cmd.SetErr(io.Discard)
|
|
cmd.SetArgs(tt.args)
|
|
|
|
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
|
|
matched, _, _ := cmd.Find(tt.args)
|
|
|
|
err := cmd.Execute()
|
|
|
|
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
|
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
|
}
|
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
|
}
|
|
})
|
|
}
|
|
}
|