mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
635 lines
24 KiB
Go
635 lines
24 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/cmd/api"
|
|
"github.com/larksuite/cli/cmd/auth"
|
|
cmdconfig "github.com/larksuite/cli/cmd/config"
|
|
"github.com/larksuite/cli/cmd/schema"
|
|
"github.com/larksuite/cli/errs"
|
|
internalauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/deprecation"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/registry"
|
|
)
|
|
|
|
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
|
// auth, config, and schema commands have auth check disabled,
|
|
// while api does not.
|
|
func TestPersistentPreRunE_AuthCheckDisabledAnnotations(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
authCmd := auth.NewCmdAuth(f)
|
|
if !cmdutil.IsAuthCheckDisabled(authCmd) {
|
|
t.Error("expected auth command to have auth check disabled")
|
|
}
|
|
|
|
configCmd := cmdconfig.NewCmdConfig(f)
|
|
if !cmdutil.IsAuthCheckDisabled(configCmd) {
|
|
t.Error("expected config command to have auth check disabled")
|
|
}
|
|
|
|
schemaCmd := schema.NewCmdSchema(f, nil)
|
|
if !cmdutil.IsAuthCheckDisabled(schemaCmd) {
|
|
t.Error("expected schema command to have auth check disabled")
|
|
}
|
|
|
|
apiCmd := api.NewCmdApi(f, nil)
|
|
if cmdutil.IsAuthCheckDisabled(apiCmd) {
|
|
t.Error("expected api command to NOT have auth check disabled")
|
|
}
|
|
}
|
|
|
|
func TestPersistentPreRunE_AuthSubcommands(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
authCmd := auth.NewCmdAuth(f)
|
|
for _, sub := range authCmd.Commands() {
|
|
if !cmdutil.IsAuthCheckDisabled(sub) {
|
|
t.Errorf("expected auth subcommand %q to inherit disabled auth check", sub.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
configCmd := cmdconfig.NewCmdConfig(f)
|
|
for _, sub := range configCmd.Commands() {
|
|
if !cmdutil.IsAuthCheckDisabled(sub) {
|
|
t.Errorf("expected config subcommand %q to inherit disabled auth check", sub.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
|
// The human skills-install guidance now lives in the root usage-template
|
|
// footer (below the command list), not in the agent-facing Long.
|
|
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
|
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
|
}
|
|
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
|
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
|
}
|
|
}
|
|
|
|
func TestConfigureFlagCompletions(t *testing.T) {
|
|
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantDisabled bool
|
|
}{
|
|
{"plain command", []string{"im", "+send"}, true},
|
|
{"help flag", []string{"im", "--help"}, true},
|
|
{"no args", []string{}, true},
|
|
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
|
{"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false},
|
|
{"completion subcommand", []string{"completion", "bash"}, false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
|
|
configureFlagCompletions(tc.args)
|
|
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
|
|
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// isCompletionCommand must classify BOTH cobra completion aliases as
|
|
// completion requests so the Shutdown emit and update-notice paths skip
|
|
// shell-completion invocations. __completeNoDesc is an Alias of
|
|
// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and
|
|
// dispatches the same RunE; bash/zsh completion typically calls the
|
|
// NoDesc variant.
|
|
func TestIsCompletionCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want bool
|
|
}{
|
|
{"plain command", []string{"im", "+send"}, false},
|
|
{"__complete", []string{"__complete", "im"}, true},
|
|
{"__completeNoDesc", []string{"__completeNoDesc", "im"}, true},
|
|
{"completion subcommand", []string{"completion", "bash"}, true},
|
|
{"completion in tail", []string{"foo", "bar", "completion"}, true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isCompletionCommand(tc.args); got != tc.want {
|
|
t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
|
|
// *errs.SecurityPolicyError flows through the canonical typed envelope
|
|
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
|
|
// top-level identity, exit code 6 — after the dispatcher carve-out is removed.
|
|
func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
t.Run("21000 challenge_required", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
spErr := &errs.SecurityPolicyError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryPolicy,
|
|
Subtype: errs.SubtypeChallengeRequired,
|
|
Code: 21000,
|
|
Message: "blocked by access policy",
|
|
Hint: "complete challenge in your browser",
|
|
},
|
|
ChallengeURL: "https://example.com/challenge",
|
|
}
|
|
|
|
gotExit := handleRootError(f, spErr)
|
|
if gotExit != int(output.ExitContentSafety) {
|
|
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
|
|
}
|
|
|
|
var env map[string]any
|
|
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
|
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
|
}
|
|
errObj, ok := env["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
|
}
|
|
if got := errObj["type"]; got != "policy" {
|
|
t.Errorf("error.type = %v, want %q", got, "policy")
|
|
}
|
|
if got := errObj["subtype"]; got != "challenge_required" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "challenge_required")
|
|
}
|
|
if got, ok := errObj["code"].(float64); !ok || int(got) != 21000 {
|
|
t.Errorf("error.code = %v (%T), want 21000 (number)", errObj["code"], errObj["code"])
|
|
}
|
|
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
|
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
|
}
|
|
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
|
t.Errorf("error.hint = %v, want hint message", got)
|
|
}
|
|
if _, exists := errObj["retryable"]; exists {
|
|
t.Errorf("error.retryable leaked into canonical envelope: %v", errObj["retryable"])
|
|
}
|
|
})
|
|
|
|
t.Run("21001 access_denied", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
spErr := &errs.SecurityPolicyError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryPolicy,
|
|
Subtype: errs.SubtypeAccessDenied,
|
|
Code: 21001,
|
|
Message: "access denied",
|
|
},
|
|
}
|
|
|
|
gotExit := handleRootError(f, spErr)
|
|
if gotExit != int(output.ExitContentSafety) {
|
|
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
|
|
}
|
|
|
|
var env map[string]any
|
|
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
|
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
|
}
|
|
errObj := env["error"].(map[string]any)
|
|
if got := errObj["type"]; got != "policy" {
|
|
t.Errorf("error.type = %v, want %q", got, "policy")
|
|
}
|
|
if got := errObj["subtype"]; got != "access_denied" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "access_denied")
|
|
}
|
|
if got, ok := errObj["code"].(float64); !ok || int(got) != 21001 {
|
|
t.Errorf("error.code = %v, want 21001 (number)", errObj["code"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
|
|
// contains the need_user_authorization marker — the same shape that
|
|
// resolveAccessToken now produces when the credential chain returns
|
|
// *internalauth.NeedAuthorizationError.
|
|
func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
|
|
cause := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
|
return &errs.AuthenticationError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryAuthentication,
|
|
Subtype: errs.SubtypeUnknown,
|
|
Message: fmt.Sprintf("API call failed: %s", cause),
|
|
},
|
|
Cause: cause,
|
|
}
|
|
}
|
|
|
|
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
|
|
// the write that would push past the limit. Used to simulate a stderr that
|
|
// dies mid-envelope.
|
|
type failingWriter struct {
|
|
limit int
|
|
n int
|
|
}
|
|
|
|
func (f *failingWriter) Write(p []byte) (int, error) {
|
|
if f.n+len(p) > f.limit {
|
|
canWrite := f.limit - f.n
|
|
if canWrite < 0 {
|
|
canWrite = 0
|
|
}
|
|
f.n += canWrite
|
|
return canWrite, io.ErrShortWrite
|
|
}
|
|
f.n += len(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
|
|
// backward-compat alias failing on a cobra-level required flag (which
|
|
// short-circuits before RunE) routes through the structured envelope, so the
|
|
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
|
|
// of being dropped on a plain "Error:" line.
|
|
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
deprecation.SetPending(&deprecation.Notice{
|
|
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
|
})
|
|
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
|
|
// errs.* error, so it reaches the deprecation fallback.
|
|
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
|
|
|
out := errOut.String()
|
|
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
|
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
|
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
|
}
|
|
// The envelope is typed validation, so the exit code must derive from that
|
|
// category (2) — the wire type and the exit code must not disagree.
|
|
if exit != int(output.ExitValidation) {
|
|
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
|
|
// baseline for auth/config errors: it pins the typed envelope and exit code the
|
|
// dispatcher produces for the two source-of-truth shapes, which are constructed
|
|
// typed at their origin in internal/auth and internal/core.
|
|
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
|
}
|
|
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "authentication" {
|
|
t.Errorf("error.type = %v, want %q", got, "authentication")
|
|
}
|
|
if got := errObj["subtype"]; got != "token_missing" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
|
|
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
|
|
t.Errorf("error.message = %q, must carry the user open id", got)
|
|
}
|
|
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
|
|
t.Errorf("error.hint = %q, must point at auth login", got)
|
|
}
|
|
if got := errObj["user_open_id"]; got != "u_golden" {
|
|
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
|
|
}
|
|
})
|
|
|
|
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, core.NotConfiguredError())
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
|
|
}
|
|
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "config" {
|
|
t.Errorf("error.type = %v, want %q", got, "config")
|
|
}
|
|
if got := errObj["subtype"]; got != "not_configured" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
|
|
t.Errorf("error.message = %q, want the not-configured message", got)
|
|
}
|
|
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
|
|
t.Errorf("error.hint = %q, must point at config init", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
|
|
// top-level "error" object, failing the test if the shape is unexpected.
|
|
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
|
|
t.Helper()
|
|
var env map[string]any
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
|
|
}
|
|
errObj, ok := env["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("envelope missing top-level error object: %s", raw)
|
|
}
|
|
return errObj
|
|
}
|
|
|
|
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
|
|
// usage error (missing required flag) is typed as invalid_argument with exit 2
|
|
// even with no deprecation pending — never cobra's plain "Error:" line.
|
|
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
|
deprecation.SetPending(nil)
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
|
|
|
out := errOut.String()
|
|
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
|
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
|
|
}
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "validation" {
|
|
t.Errorf("error.type = %v, want %q", got, "validation")
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
|
|
t.Errorf("error.message = %q, must carry the failing flag name", got)
|
|
}
|
|
if exit != int(output.ExitValidation) {
|
|
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
|
|
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
|
|
// typed boundary from a helper) is classified as an internal fault (exit 5),
|
|
// not blamed on the user's input as a validation error.
|
|
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
|
deprecation.SetPending(nil)
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
|
|
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "internal" {
|
|
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
|
|
}
|
|
if exit != int(output.ExitInternal) {
|
|
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
|
// stderr write fails mid-envelope, handleRootError still returns the typed
|
|
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
|
// plain "Error:" path with exit 1. ExitCodeOf is computed from the typed
|
|
// err BEFORE the envelope write so the exit code is preserved even when
|
|
// the consumer's stderr pipe dies.
|
|
func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
w := &failingWriter{limit: 20}
|
|
f.IOStreams.ErrOut = w
|
|
|
|
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
|
exit := handleRootError(f, err)
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
|
|
// contract: a *output.BareError is honored for its exit code while stderr stays
|
|
// empty (stdout already carries the result, so the dispatcher must not layer a
|
|
// second envelope on top).
|
|
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
|
|
}
|
|
if errOut.Len() != 0 {
|
|
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
|
|
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
|
|
// Cause chain renders the producer's TokenExpired subtype + custom hint
|
|
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
|
|
// shape.
|
|
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
innerLegacy := &internalauth.NeedAuthorizationError{UserOpenId: "u_123"}
|
|
outer := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired").
|
|
WithHint("custom producer hint").
|
|
WithCause(innerLegacy)
|
|
|
|
exit := handleRootError(f, outer)
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
|
}
|
|
got := errOut.String()
|
|
if !strings.Contains(got, `"subtype": "token_expired"`) {
|
|
t.Errorf("envelope lost producer Subtype TokenExpired; got %s", got)
|
|
}
|
|
if !strings.Contains(got, "custom producer hint") {
|
|
t.Errorf("envelope lost producer Hint; got %s", got)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
|
|
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
|
|
// declared-scopes Hint appended when the current command is a registered
|
|
// service method.
|
|
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
var target registry.CommandEntry
|
|
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
|
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
|
target = entry
|
|
break
|
|
}
|
|
}
|
|
if target.Command == "" {
|
|
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
|
}
|
|
parts := strings.Split(target.Command, " ")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("expected resource/method command, got %q", target.Command)
|
|
}
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "calendar"}
|
|
resourceCmd := &cobra.Command{Use: parts[0]}
|
|
methodCmd := &cobra.Command{Use: parts[1]}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(resourceCmd)
|
|
resourceCmd.AddCommand(methodCmd)
|
|
f.CurrentCommand = methodCmd
|
|
|
|
authErr := newAuthErrorWithNeedAuthMarker()
|
|
applyNeedAuthorizationHint(f, authErr)
|
|
|
|
if authErr.Category != errs.CategoryAuthentication {
|
|
t.Errorf("Category = %q, want authentication", authErr.Category)
|
|
}
|
|
if !strings.Contains(authErr.Message, "need_user_authorization") {
|
|
t.Errorf("Message should preserve need_user_authorization marker; got %q", authErr.Message)
|
|
}
|
|
if !strings.Contains(authErr.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
|
t.Errorf("expected declared-scope hint, got %q", authErr.Hint)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT pins the
|
|
// same hint behavior for mounted shortcut commands.
|
|
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "docs"}
|
|
shortcutCmd := &cobra.Command{Use: "+create"}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(shortcutCmd)
|
|
f.CurrentCommand = shortcutCmd
|
|
|
|
authErr := newAuthErrorWithNeedAuthMarker()
|
|
applyNeedAuthorizationHint(f, authErr)
|
|
|
|
if !strings.Contains(authErr.Hint, "current command requires scope(s): docx:document:create") {
|
|
t.Errorf("expected shortcut scope hint, got %q", authErr.Hint)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes pins that
|
|
// conditional scopes declared on a shortcut surface in the hint.
|
|
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "drive"}
|
|
shortcutCmd := &cobra.Command{Use: "+status"}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(shortcutCmd)
|
|
f.CurrentCommand = shortcutCmd
|
|
|
|
authErr := newAuthErrorWithNeedAuthMarker()
|
|
applyNeedAuthorizationHint(f, authErr)
|
|
|
|
if !strings.Contains(authErr.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
|
t.Errorf("expected conditional scope hint for drive +status, got %q", authErr.Hint)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_AppendsExistingHint pins that the
|
|
// declared-scopes guidance is appended (separated by newline) when the typed
|
|
// AuthenticationError already carries a Hint from elsewhere.
|
|
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "docs"}
|
|
shortcutCmd := &cobra.Command{Use: "+create"}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(shortcutCmd)
|
|
f.CurrentCommand = shortcutCmd
|
|
|
|
authErr := newAuthErrorWithNeedAuthMarker()
|
|
authErr.Hint = "existing hint"
|
|
applyNeedAuthorizationHint(f, authErr)
|
|
|
|
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
|
if authErr.Hint != want {
|
|
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
|
|
}
|
|
}
|