Files
larksuite__cli/cmd/auth/auth_test.go
zhaoyukun.yk 4dfe1b7dc0 feat(auth): support managed external credentials
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.
2026-08-12 14:55:41 +08:00

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)
}
})
}
}