mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:
- a fixed nine-category taxonomy on the wire, each mapped to a
stable shell exit code (authentication/authorization/config = 3,
network = 4, internal = 5, policy = 6, confirmation = 10)
- identity-aware detail fields (missing_scopes, requested_scopes,
granted_scopes, console_url, log_id, retryable, hint) carried
uniformly on the envelope
- a single canonical policy envelope at exit 6; the legacy
auth_error carve-out is retired
- per-subtype canonical message + hint that preserves Lark's
diagnostic phrasing and routes recovery to the right actor:
app developer (app_scope_not_applied), user (missing_scope,
token_scope_insufficient, user_unauthorized), or tenant admin
(app_unavailable, app_disabled)
- wrong app credentials classify as config/invalid_client whether
surfaced by the Open API endpoint (99991543) or the tenant
access-token mint endpoint (10003 / 10014), instead of
collapsing to a transport error or api/unknown
- local shortcut scope preflight emits the same
authorization/missing_scope envelope (identity + deterministic
missing-scope set) used by the post-call permission path, so AI
consumers read the same structured shape from precheck and from
server-returned permission denial
- streaming download/upload failures keep the same network subtype
split (timeout / TLS / DNS / transport) as the non-stream path
instead of collapsing every cause to a generic transport failure
- console_url is carried only on the bot-perspective
app_scope_not_applied envelope (where the recovery action is
"developer applies the scope at the developer console"); the
user-perspective missing_scope envelope drops the field, since
the only actionable user recovery is `lark-cli auth login --scope`
and pointing an end user at a console they cannot modify is
misleading
- bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
Type tags to wire 'config' with the original module name kept
as a metric label
All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
134 lines
4.6 KiB
Go
134 lines
4.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
|
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
|
|
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
|
|
// not for missing user input.
|
|
|
|
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
|
|
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
|
|
assertValidationParam(t, err, "--app-secret")
|
|
}
|
|
|
|
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
|
|
existing := &core.MultiAppConfig{
|
|
Apps: []core.AppConfig{{
|
|
Name: "default",
|
|
AppId: "app-default",
|
|
AppSecret: core.PlainSecret("secret-default"),
|
|
Brand: core.BrandFeishu,
|
|
}},
|
|
}
|
|
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
|
|
assertValidationParam(t, err, "--app-secret")
|
|
}
|
|
|
|
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
|
|
existing := &core.MultiAppConfig{
|
|
CurrentApp: "missing",
|
|
Apps: []core.AppConfig{{
|
|
Name: "default",
|
|
AppId: "app-default",
|
|
AppSecret: core.PlainSecret("secret-default"),
|
|
Brand: core.BrandFeishu,
|
|
}},
|
|
}
|
|
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
|
|
assertValidationParam(t, err, "--app-secret")
|
|
}
|
|
|
|
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
|
|
existing := &core.MultiAppConfig{
|
|
Apps: []core.AppConfig{{
|
|
Name: "default",
|
|
AppId: "app-default",
|
|
AppSecret: core.PlainSecret("secret-default"),
|
|
Brand: core.BrandFeishu,
|
|
}},
|
|
}
|
|
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
|
|
assertValidationParam(t, err, "--app-secret")
|
|
}
|
|
|
|
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
|
|
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
|
|
// exit semantics (regression: typed ValidationError was being downgraded to
|
|
// InternalError by the legacy *output.ExitError-only passthrough).
|
|
|
|
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
|
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
|
t.Fatalf("expected nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) {
|
|
in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
|
WithParam("--app-secret")
|
|
got := wrapUpdateExistingProfileErr(in)
|
|
assertValidationParam(t, got, "--app-secret")
|
|
// Exit code must remain ExitValidation (2), not ExitInternal (5).
|
|
if code := output.ExitCodeOf(got); code != output.ExitValidation {
|
|
t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
}
|
|
// Must NOT be wrapped as *InternalError.
|
|
var intErr *errs.InternalError
|
|
if errors.As(got, &intErr) {
|
|
t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
|
|
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
|
|
got := wrapUpdateExistingProfileErr(in)
|
|
var exitErr *output.ExitError
|
|
if !errors.As(got, &exitErr) {
|
|
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
|
|
}
|
|
if exitErr.Code != 7 {
|
|
t.Errorf("Code = %d, want 7", exitErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
|
|
in := fmt.Errorf("disk full")
|
|
got := wrapUpdateExistingProfileErr(in)
|
|
var intErr *errs.InternalError
|
|
if !errors.As(got, &intErr) {
|
|
t.Fatalf("expected *errs.InternalError, got %T: %v", got, got)
|
|
}
|
|
if intErr.Subtype != errs.SubtypeSDKError {
|
|
t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
|
}
|
|
}
|
|
|
|
// assertValidationParam asserts err is *ValidationError with the given Param.
|
|
func assertValidationParam(t *testing.T, err error, wantParam string) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
var valErr *errs.ValidationError
|
|
if !errors.As(err, &valErr) {
|
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
|
}
|
|
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if valErr.Param != wantParam {
|
|
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
|
|
}
|
|
}
|