mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +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.
297 lines
8.5 KiB
Go
297 lines
8.5 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package errs
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Per-type marshal tests pin each typed error's wire shape against its
|
|
// canonical fields. They guard against future refactors that change struct
|
|
// layout from accidentally altering the externally visible JSON contract.
|
|
//
|
|
// Each test asserts (a) Problem fields surface at the top level via embed
|
|
// promotion, (b) extension fields sit alongside as siblings (NOT under a
|
|
// `detail` sub-object), and (c) omitempty is honored on optional fields.
|
|
|
|
func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
|
|
pe := &PermissionError{
|
|
Problem: Problem{
|
|
Category: CategoryAuthorization, Subtype: SubtypeMissingScope, Code: 99991679,
|
|
Message: "x", Hint: "y", LogID: "lg", Retryable: false,
|
|
},
|
|
MissingScopes: []string{"docx:document"},
|
|
Identity: "user",
|
|
ConsoleURL: "https://example",
|
|
}
|
|
b, err := json.Marshal(pe)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"authorization"`,
|
|
`"subtype":"missing_scope"`,
|
|
`"code":99991679`,
|
|
`"message":"x"`,
|
|
`"hint":"y"`,
|
|
`"log_id":"lg"`,
|
|
`"missing_scopes":["docx:document"]`,
|
|
`"identity":"user"`,
|
|
`"console_url":"https://example"`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
if strings.Contains(s, `"retryable"`) {
|
|
t.Errorf("retryable should be omitted when false; got %s", s)
|
|
}
|
|
if strings.Contains(s, `"detail"`) {
|
|
t.Errorf("extension fields must not be wrapped under detail; got %s", s)
|
|
}
|
|
}
|
|
|
|
func TestPermissionError_RequestedGrantedMarshal(t *testing.T) {
|
|
err := NewPermissionError(SubtypeMissingScope, "partial grant").
|
|
WithRequestedScopes("docx:document", "im:message:send").
|
|
WithGrantedScopes("docx:document").
|
|
WithMissingScopes("im:message:send")
|
|
|
|
b, e := json.Marshal(err)
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
got := string(b)
|
|
for _, want := range []string{
|
|
`"requested_scopes":["docx:document","im:message:send"]`,
|
|
`"granted_scopes":["docx:document"]`,
|
|
`"missing_scopes":["im:message:send"]`,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("envelope missing %s\nactual: %s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidationError_MarshalJSON(t *testing.T) {
|
|
ve := &ValidationError{
|
|
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
|
|
Param: "--scope",
|
|
}
|
|
b, _ := json.Marshal(ve)
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"validation"`,
|
|
`"subtype":"invalid_argument"`,
|
|
`"message":"bad"`,
|
|
`"param":"--scope"`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
|
|
// Param omitempty when ""
|
|
ve2 := &ValidationError{Problem: Problem{Category: CategoryValidation, Message: "x"}}
|
|
b2, _ := json.Marshal(ve2)
|
|
if strings.Contains(string(b2), `"param"`) {
|
|
t.Errorf("param should be omitted when empty; got %s", b2)
|
|
}
|
|
}
|
|
|
|
func TestAuthError_MarshalJSON(t *testing.T) {
|
|
ae := &AuthenticationError{
|
|
Problem: Problem{Category: CategoryAuthentication, Subtype: SubtypeTokenExpired, Message: "expired"},
|
|
UserOpenID: "ou_x",
|
|
}
|
|
b, _ := json.Marshal(ae)
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"authentication"`,
|
|
`"subtype":"token_expired"`,
|
|
`"message":"expired"`,
|
|
`"user_open_id":"ou_x"`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigError_MarshalJSON(t *testing.T) {
|
|
ce := &ConfigError{
|
|
Problem: Problem{Category: CategoryConfig, Subtype: SubtypeInvalidClient, Message: "bad"},
|
|
Field: "app_id",
|
|
}
|
|
b, _ := json.Marshal(ce)
|
|
s := string(b)
|
|
for _, want := range []string{`"type":"config"`, `"subtype":"invalid_client"`, `"field":"app_id"`} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNetworkError_MarshalJSON(t *testing.T) {
|
|
ne := &NetworkError{
|
|
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
|
}
|
|
b, _ := json.Marshal(ne)
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"network"`,
|
|
`"subtype":"timeout"`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
if strings.Contains(s, `"cause"`) {
|
|
t.Errorf("cause field should no longer be on the wire; got %s", s)
|
|
}
|
|
}
|
|
|
|
func TestAPIError_MarshalJSON(t *testing.T) {
|
|
ae := &APIError{
|
|
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
|
|
}
|
|
b, _ := json.Marshal(ae)
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"api"`,
|
|
`"subtype":"rate_limit"`,
|
|
`"code":99991400`,
|
|
`"retryable":true`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProblem_MarshalJSON_Troubleshooter pins the upstream Lark API
|
|
// troubleshooter URL (resp.error.troubleshooter) surfacing on the wire under
|
|
// "troubleshooter". Carried via Problem so any typed error that embeds it
|
|
// inherits the field — populated by errclass.BuildAPIError before the
|
|
// category switch.
|
|
func TestProblem_MarshalJSON_Troubleshooter(t *testing.T) {
|
|
ae := &APIError{
|
|
Problem: Problem{
|
|
Category: CategoryAPI,
|
|
Subtype: SubtypeUnknown,
|
|
Code: 99991400,
|
|
Message: "x",
|
|
Troubleshooter: "https://open.feishu.cn/document/troubleshoot/abc",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(ae)
|
|
s := string(b)
|
|
if !strings.Contains(s, `"troubleshooter":"https://open.feishu.cn/document/troubleshoot/abc"`) {
|
|
t.Errorf("missing troubleshooter in %s", s)
|
|
}
|
|
|
|
// Absent Troubleshooter must omit the wire key.
|
|
bare := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
|
|
b2, _ := json.Marshal(bare)
|
|
if strings.Contains(string(b2), `"troubleshooter"`) {
|
|
t.Errorf("absent Troubleshooter must omit wire key; got %s", string(b2))
|
|
}
|
|
}
|
|
|
|
func TestSecurityPolicyError_MarshalJSON(t *testing.T) {
|
|
spe := &SecurityPolicyError{
|
|
Problem: Problem{Category: CategoryPolicy, Subtype: SubtypeChallengeRequired, Message: "blocked"},
|
|
ChallengeURL: "https://chal.example",
|
|
}
|
|
b, _ := json.Marshal(spe)
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"policy"`,
|
|
`"subtype":"challenge_required"`,
|
|
`"challenge_url":"https://chal.example"`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pin per-Subtype symmetry: SubtypeAccessDenied must serialize the same
|
|
// envelope shape as SubtypeChallengeRequired so callers can switch on
|
|
// subtype without conditional field probing. The constructor + builder
|
|
// path (mirroring how callsites actually construct these) is exercised
|
|
// here rather than the struct literal, since SubtypeAccessDenied is the
|
|
// path threaded through cmd/* sites that surface policy-deny outcomes.
|
|
func TestSecurityPolicyError_MarshalJSON_AccessDenied(t *testing.T) {
|
|
err := NewSecurityPolicyError(SubtypeAccessDenied, "user denied").
|
|
WithChallengeURL("https://chal.example/2")
|
|
|
|
b, e := json.Marshal(err)
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
got := string(b)
|
|
for _, want := range []string{
|
|
`"type":"policy"`,
|
|
`"subtype":"access_denied"`,
|
|
`"challenge_url":"https://chal.example/2"`,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("envelope missing %s\nactual: %s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContentSafetyError_MarshalJSON(t *testing.T) {
|
|
cse := &ContentSafetyError{
|
|
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
|
|
Rules: []string{"pii", "violence"},
|
|
}
|
|
b, _ := json.Marshal(cse)
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"policy"`,
|
|
`"rules":["pii","violence"]`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInternalError_MarshalJSON(t *testing.T) {
|
|
ie := &InternalError{
|
|
Problem: Problem{Category: CategoryInternal, Subtype: SubtypeSDKError, Message: "boom"},
|
|
}
|
|
b, _ := json.Marshal(ie)
|
|
s := string(b)
|
|
for _, want := range []string{`"type":"internal"`, `"subtype":"sdk_error"`} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfirmationRequiredError_MarshalJSON(t *testing.T) {
|
|
cre := &ConfirmationRequiredError{
|
|
Problem: Problem{Category: CategoryConfirmation, Subtype: Subtype("confirmation_required"), Message: "confirm"},
|
|
Risk: "write",
|
|
Action: "mail +send",
|
|
}
|
|
b, _ := json.Marshal(cre)
|
|
s := string(b)
|
|
for _, want := range []string{
|
|
`"type":"confirmation"`,
|
|
`"risk":"write"`,
|
|
`"action":"mail +send"`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %q in %s", want, s)
|
|
}
|
|
}
|
|
}
|