mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
4dfe1b7dc0
Add Extended-only direct, credential-proxy, and platform-proxy modes while keeping Standard behavior and external-credential product details isolated behind source-neutral runtime capabilities. Fail closed across helper execution, proxy routing, streams, managed file transfers, and unsupported event flows. Harden system configuration, edition updates, and release publication. Cover Standard compatibility and Extended integration with contract tests, repository-local three-mode E2E, native trust checks, and CI and release gates.
574 lines
15 KiB
Go
574 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"}},
|
|
{"list", []string{"list"}},
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthReadOnlyCommandsAllowedByExternalProvider(t *testing.T) {
|
|
f := newFactoryWithExternalProvider(t)
|
|
for _, name := range []string{"status", "check", "scopes", "qrcode"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
cmd := NewCmdAuth(f)
|
|
matched, _, err := cmd.Find([]string{name})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
|
|
t.Fatalf("read-only command blocked: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|