mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(errs): add structured CLI error contract (#984)
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
This commit is contained in:
@@ -8,21 +8,14 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
LarkErrBlockByPolicy = 21001 // access denied by access control policy
|
||||
LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access
|
||||
needUserAuthorizationMarker = "need_user_authorization"
|
||||
)
|
||||
|
||||
// RefreshTokenRetryable contains error codes that allow one immediate retry.
|
||||
// All other refresh errors clear the token immediately.
|
||||
var RefreshTokenRetryable = map[int]bool{
|
||||
output.LarkErrRefreshServerError: true,
|
||||
}
|
||||
|
||||
// TokenRetryCodes contains error codes that allow retry after token refresh.
|
||||
var TokenRetryCodes = map[int]bool{
|
||||
output.LarkErrTokenInvalid: true,
|
||||
@@ -51,6 +44,7 @@ func IsNeedUserAuthorizationError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
|
||||
@@ -58,24 +52,7 @@ func IsNeedUserAuthorizationError(err error) bool {
|
||||
return strings.Contains(err.Error(), needUserAuthorizationMarker)
|
||||
}
|
||||
|
||||
// SecurityPolicyError is returned when a request is blocked by access control policies.
|
||||
type SecurityPolicyError struct {
|
||||
Code int
|
||||
Message string
|
||||
ChallengeURL string
|
||||
CLIHint string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the error message for SecurityPolicyError.
|
||||
func (e *SecurityPolicyError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error.
|
||||
func (e *SecurityPolicyError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
// SecurityPolicyError is preserved as a Go type alias so existing
|
||||
// errors.As(&SecurityPolicyError{}) consumers (cmd/root.go etc.) keep working.
|
||||
// The concrete struct lives in errs/types.go.
|
||||
type SecurityPolicyError = errs.SecurityPolicyError
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -85,34 +87,56 @@ func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error response.
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error
|
||||
// response coming back from a remote server (this transport is installed on
|
||||
// lark-cli's outbound HTTP client; the bodies it inspects are produced by the
|
||||
// remote, not by lark-cli itself).
|
||||
//
|
||||
// Observed production shape from the MCP gateway — Lark code in the outer
|
||||
// `error.code` slot, hint under `data.cli_hint`:
|
||||
//
|
||||
// {"jsonrpc": "2.0", "id": 1,
|
||||
// "error": {"code": 21000, "message": "...",
|
||||
// "data": {"challenge_url": "...", "cli_hint": "..."}}}
|
||||
//
|
||||
// The parser also accepts a JSON-RPC-canonical shape (outer `error.code`
|
||||
// carrying the JSON-RPC status like -32603, Lark code under `error.data.code`,
|
||||
// hint under `data.hint`) so a future server-side migration to that layout
|
||||
// would not silently drop policy detection. The Lark code is looked up in the
|
||||
// central code registry; the hint key is read from `data.hint` first and
|
||||
// falls back to `data.cli_hint`.
|
||||
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
|
||||
// MCP (JSON-RPC) response format:
|
||||
// {
|
||||
// "error": {
|
||||
// "code": 21000,
|
||||
// "message": "...",
|
||||
// "data": { "challenge_url": "...", "cli_hint": "..." }
|
||||
// }
|
||||
// }
|
||||
errMap, ok := result["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
code := getInt(errMap, "code", 0)
|
||||
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
|
||||
dataMap, _ := errMap["data"].(map[string]interface{})
|
||||
|
||||
// Try data.code first (shape B); fall back to outer error.code (shape A).
|
||||
code := 0
|
||||
if dataMap != nil {
|
||||
code = getInt(dataMap, "code", 0)
|
||||
}
|
||||
if code == 0 {
|
||||
code = getInt(errMap, "code", 0)
|
||||
}
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
dataMap, ok := errMap["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
if dataMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean up backticks and spaces from challenge_url
|
||||
challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `")
|
||||
cliHint := getStr(dataMap, "cli_hint")
|
||||
// Read `hint` first; fall back to `cli_hint` so either spelling surfaces.
|
||||
cliHint := getStr(dataMap, "hint")
|
||||
if cliHint == "" {
|
||||
cliHint = getStr(dataMap, "cli_hint")
|
||||
}
|
||||
msg := getStr(errMap, "message")
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
@@ -122,11 +146,15 @@ func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interfa
|
||||
}
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
return &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,8 +174,9 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if it's a security policy error
|
||||
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
|
||||
// 2. Check if it's a security policy error (consult central code registry)
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -173,11 +202,15 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
}
|
||||
|
||||
if msg != "" || challengeUrl != "" || cliHint != "" {
|
||||
return &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
114
internal/auth/transport_test.go
Normal file
114
internal/auth/transport_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
|
||||
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
|
||||
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
|
||||
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
|
||||
// This shape is forward-compat for a future server-side migration to the
|
||||
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
|
||||
// for the shape observed in production today.
|
||||
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603, // JSON-RPC internal error
|
||||
"message": "challenge required",
|
||||
"data": map[string]interface{}{
|
||||
"code": 21000, // Lark code for challenge_required
|
||||
"type": "policy",
|
||||
"subtype": "challenge_required",
|
||||
"challenge_url": "https://example.com/challenge",
|
||||
"hint": "please complete the challenge in your browser",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Code != 21000 {
|
||||
t.Errorf("Code = %d, want 21000", spErr.Code)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeChallengeRequired {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
|
||||
}
|
||||
if spErr.ChallengeURL != "https://example.com/challenge" {
|
||||
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
|
||||
}
|
||||
if spErr.Hint != "please complete the challenge in your browser" {
|
||||
t.Errorf("Hint = %q", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
|
||||
// in production from the MCP gateway: the Lark code sits in the outer
|
||||
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
|
||||
// The transport's outer-code fallback path must recognise the policy code and
|
||||
// surface the typed error with the hint promoted.
|
||||
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": 21001, // outer slot carries the Lark code
|
||||
"message": "access denied",
|
||||
"data": map[string]interface{}{
|
||||
"challenge_url": "https://example.com/c",
|
||||
"cli_hint": "contact admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
|
||||
}
|
||||
// `cli_hint` must surface when `hint` is absent.
|
||||
if spErr.Hint != "contact admin" {
|
||||
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
|
||||
// nil (passes through) when the Lark code does not classify as
|
||||
// CategoryPolicy — keeps regular API errors out of the security-policy path.
|
||||
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603,
|
||||
"message": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
|
||||
"type": "authorization",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := transport.tryHandleMCPResponse(result); err != nil {
|
||||
t.Fatalf("expected nil (non-policy code), got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -223,16 +225,21 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
}
|
||||
|
||||
code := getInt(data, "code", -1)
|
||||
if code == LarkErrBlockByPolicy || code == LarkErrBlockByPolicyTryAuth {
|
||||
meta, metaOK := errclass.LookupCodeMeta(code)
|
||||
if metaOK && meta.Category == errs.CategoryPolicy {
|
||||
challengeUrl := getStr(data, "challenge_url")
|
||||
cliHint := getStr(data, "cli_hint")
|
||||
msg := getStr(data, "error_description")
|
||||
|
||||
return nil, &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return nil, &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +247,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
|
||||
if (code != -1 && code != 0) || errStr != "" {
|
||||
// Retryable server error: retry once, then clear token on second failure.
|
||||
if RefreshTokenRetryable[code] {
|
||||
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
|
||||
data, err = callEndpoint()
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -19,10 +20,31 @@ const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard
|
||||
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
|
||||
// actionable API errors for raw `lark-cli api` calls. All other failures
|
||||
// remain network errors.
|
||||
//
|
||||
// Already-classified errors pass through unchanged: any *output.ExitError
|
||||
// (legacy envelope from output.ErrAuth / output.ErrAPI / output.ErrWithHint)
|
||||
// and any typed *errs.* error (carries an embedded Problem) keeps its own
|
||||
// category and exit code. This is what makes the wrap idempotent on the
|
||||
// auth/credential chain — resolveAccessToken returns output.ErrAuth for
|
||||
// missing tokens, and that classification must survive the SDK boundary.
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape (api_error + rawAPIJSONHint
|
||||
// on JSON-decode, network otherwise) for the wrap-from-untyped branch.
|
||||
// Preserved so SDK Do() callers keep the original envelope until per-domain
|
||||
// migration to typed errors. New code should route through
|
||||
// APIClient.CheckResponse (typed *errs.APIError) or construct
|
||||
// *errs.NetworkError / *errs.InternalError directly.
|
||||
func WrapDoAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var existing *output.ExitError
|
||||
if errors.As(err, &existing) {
|
||||
return err
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
if isJSONDecodeError(err, false) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
@@ -32,6 +54,11 @@ func WrapDoAPIError(err error) error {
|
||||
|
||||
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
|
||||
// into API errors with hints instead of generic parse failures.
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape (api_error + ExitAPI +
|
||||
// rawAPIJSONHint). The 3-branch behaviour is preserved so existing callers
|
||||
// of internal/client/response.go keep emitting the same envelope until
|
||||
// per-domain migration to typed errors.
|
||||
func WrapJSONResponseParseError(err error, body []byte) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -6,31 +6,15 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
|
||||
err := WrapDoAPIError(io.EOF)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
|
||||
}
|
||||
if strings.Contains(exitErr.Error(), "invalid JSON response") {
|
||||
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
||||
if err == nil {
|
||||
@@ -66,3 +50,127 @@ func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
|
||||
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic pins branch 1 of
|
||||
// the documented 3-branch behaviour: empty (or whitespace-only) response
|
||||
// bodies surface as api_error + rawAPIJSONHint, not network. Pages returning
|
||||
// only "\n" must not be reclassified as transport failures.
|
||||
func TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic(t *testing.T) {
|
||||
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
|
||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("body=%q: expected ExitError, got %T", body, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("body=%q: Code = %d, want %d", body, exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Errorf("body=%q: Detail.Type = %v, want api_error", body, exitErr.Detail)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "empty JSON response") {
|
||||
t.Errorf("body=%q: Detail.Message = %v, want empty-body diagnostic", body, exitErr.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_NonJSONErrorIsNetwork pins branch 3:
|
||||
// a non-JSON-decode error with a non-empty body falls back to ErrNetwork
|
||||
// (the SDK delivered something but the read itself failed mid-flight).
|
||||
func TestWrapJSONResponseParseError_NonJSONErrorIsNetwork(t *testing.T) {
|
||||
raw := errors.New("connection reset by peer")
|
||||
err := WrapJSONResponseParseError(raw, []byte(`{"code":0,"data":{}}`))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Errorf("Detail.Type = %v, want network", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_LegacyExitErrorPassesThrough pins the invariant that an
|
||||
// already-classified *output.ExitError (e.g. output.ErrAuth from
|
||||
// resolveAccessToken) survives WrapDoAPIError with its category and exit code
|
||||
// intact. Without this, missing-token errors regress from exit 3/auth to
|
||||
// exit 4/network at the SDK boundary.
|
||||
func TestWrapDoAPIError_LegacyExitErrorPassesThrough(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in error
|
||||
want int
|
||||
wantType string
|
||||
}{
|
||||
{"auth", output.ErrAuth("no access token available for user"), output.ExitAuth, "auth"},
|
||||
{"validation", output.ErrValidation("missing flag --foo"), output.ExitValidation, "validation"},
|
||||
{"api_unknown_code", output.ErrAPI(12345, "unknown lark code", nil), output.ExitAPI, "api_error"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := WrapDoAPIError(tc.in)
|
||||
if got != tc.in {
|
||||
t.Fatalf("expected identity passthrough, got %v (orig %v)", got, tc.in)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != tc.want {
|
||||
t.Fatalf("Code = %d, want %d", exitErr.Code, tc.want)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != tc.wantType {
|
||||
t.Fatalf("Detail.Type = %q, want %q (detail=%#v)",
|
||||
func() string {
|
||||
if exitErr.Detail == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return exitErr.Detail.Type
|
||||
}(),
|
||||
tc.wantType, exitErr.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_TypedErrsPassesThrough pins that any *errs.* typed error
|
||||
// (carries an embedded Problem) passes through unchanged. Forward-compat for
|
||||
// stage-4 credential chain migration that will return *errs.AuthenticationError
|
||||
// directly instead of legacy output.ErrAuth.
|
||||
func TestWrapDoAPIError_TypedErrsPassesThrough(t *testing.T) {
|
||||
cases := []error{
|
||||
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}},
|
||||
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}},
|
||||
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport}},
|
||||
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError}},
|
||||
}
|
||||
for _, in := range cases {
|
||||
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
|
||||
got := WrapDoAPIError(in)
|
||||
if got != in {
|
||||
t.Fatalf("expected identity passthrough, got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_PassthroughBeforeJSONDecode pins that even if a typed/legacy
|
||||
// error wraps a JSON decode error somewhere in its chain, the outer
|
||||
// classification takes precedence — we never re-classify an already-typed error
|
||||
// as a JSON parse error.
|
||||
func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) {
|
||||
jsonErr := &json.SyntaxError{Offset: 1}
|
||||
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr)
|
||||
|
||||
got := WrapDoAPIError(authWrappingJSON)
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +91,28 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
|
||||
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
|
||||
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
|
||||
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
||||
//
|
||||
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
||||
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each
|
||||
// one remembering to wrap. In stage 1 that wire shape is still the legacy
|
||||
// *output.ExitError envelope (network / api_error) — the stage-4 framework
|
||||
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
|
||||
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
|
||||
// Errors that arrive already-classified (legacy *output.ExitError from
|
||||
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
|
||||
// future stages) flow through unchanged.
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// WrapDoAPIError is idempotent on already-classified errors:
|
||||
// the *output.ExitError that resolveAccessToken returns for missing
|
||||
// tokens (via output.ErrAuth) passes through with its auth category
|
||||
// and exit 3 intact, and any future typed *errs.* error from the
|
||||
// credential chain survives the same way. Only stray untyped errors
|
||||
// (raw fmt.Errorf) get the transport-or-internal fallback.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
if as.IsBot() {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
||||
@@ -107,7 +123,11 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
}
|
||||
|
||||
opts = append(opts, extraOpts...)
|
||||
return c.SDK.Do(ctx, req, opts...)
|
||||
resp, err := c.SDK.Do(ctx, req, opts...)
|
||||
if err != nil {
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
@@ -123,7 +143,10 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
// Resolve auth
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// See DoSDKRequest comment on the same wrap pattern; the typed
|
||||
// auth-error pass-through plus untyped fallback applies equally to
|
||||
// streaming requests.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
// Build URL
|
||||
@@ -259,14 +282,27 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
|
||||
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
|
||||
}
|
||||
|
||||
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
|
||||
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
|
||||
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. Use DoAPI
|
||||
// directly when the response may not be JSON (e.g. file downloads).
|
||||
//
|
||||
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
|
||||
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
||||
// see an *output.ExitError envelope (api_error for malformed JSON, network
|
||||
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
|
||||
// or malformed page body would surface to the root handler as a plain-text
|
||||
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
|
||||
// framework-boundary migration will flip this wrapper to typed
|
||||
// *errs.InternalError / *errs.NetworkError.
|
||||
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
||||
resp, err := c.DoAPI(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseJSONResponse(resp)
|
||||
result, parseErr := ParseJSONResponse(resp)
|
||||
if parseErr != nil {
|
||||
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
|
||||
@@ -410,10 +446,14 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt
|
||||
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
|
||||
}
|
||||
|
||||
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
|
||||
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
|
||||
// Returns nil if result is not a map, map is nil, or code is 0.
|
||||
func CheckLarkResponse(result interface{}) error {
|
||||
// CheckResponse inspects a Lark API response for business-level errors (non-zero code).
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI /
|
||||
// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so
|
||||
// existing callers keep emitting the same envelope until per-domain
|
||||
// migration to typed errors. The identity parameter is reserved for the
|
||||
// stage-2 typed path; stage-1 ignores it.
|
||||
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil
|
||||
|
||||
@@ -45,12 +45,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -434,42 +428,118 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatal("unexpected HTTP request")
|
||||
return nil, nil
|
||||
}))
|
||||
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/test",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("DoSDKRequest() error = nil, want auth error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
|
||||
}
|
||||
func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
|
||||
}
|
||||
|
||||
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
// TestDoSDKRequest_AuthFailurePreservesAuthCategory pins the end-to-end
|
||||
// invariant codex caught the day this PR landed: when resolveAccessToken
|
||||
// produces output.ErrAuth ("no access token available for <identity>"),
|
||||
// DoSDKRequest must surface it with the original auth classification —
|
||||
// not silently downgrade it to a network error via the SDK-failure wrap.
|
||||
//
|
||||
// Regression scenario: shortcut path
|
||||
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
|
||||
// identity with no cached token. Pre-fix this surfaced as exit 4/type=network
|
||||
// and routed agents into "check your connection" instead of "log in".
|
||||
func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "https://example.com/open-apis/test",
|
||||
}, core.AsBot)
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsUser)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("DoStream() error = nil, want auth error")
|
||||
t.Fatal("expected auth error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoStream() error = %v, want auth error", err)
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
|
||||
// SDK transport errors get the network classification via WrapDoAPIError.
|
||||
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
|
||||
// *url.Error, which the wrap classifier recognises as a transport error.
|
||||
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsBot)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error from broken transport, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the legacy-envelope contract for
|
||||
// malformed JSON response bodies: WrapJSONResponseParseError emits api_error
|
||||
// (exit 1) with the rawAPIJSONHint, so the pagination / cmd/api / cmd/service
|
||||
// callers always see a JSON stderr envelope instead of a bare "Error: ..."
|
||||
// line. Stage-4 framework-boundary migration will flip this wrapper to typed
|
||||
// *errs.InternalError; until then this test pins the legacy shape so we do
|
||||
// not regress envelope coverage.
|
||||
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{ malformed`)),
|
||||
}, nil
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.CallAPI(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users/me",
|
||||
As: "bot",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected JSON parse error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
|
||||
}
|
||||
if exitErr.Detail.Hint != rawAPIJSONHint {
|
||||
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,25 +8,38 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// PaginationOptions contains pagination control options.
|
||||
type PaginationOptions struct {
|
||||
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
|
||||
PageDelay int // ms, default 200
|
||||
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
|
||||
PageDelay int // ms, default 200
|
||||
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
|
||||
}
|
||||
|
||||
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
|
||||
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
|
||||
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
|
||||
jqExpr string, out io.Writer, pagOpts PaginationOptions,
|
||||
checkErr func(interface{}) error) error {
|
||||
checkErr func(interface{}, core.Identity) error) error {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
// Identity resolution honors pagOpts.Identity first, then the request's
|
||||
// own identity, and only falls back to AsUser when neither caller
|
||||
// supplied one. Without checking request.As, bot/auto requests would
|
||||
// always be classified as user identity for checkErr.
|
||||
identity := pagOpts.Identity
|
||||
if identity == "" {
|
||||
identity = request.As
|
||||
}
|
||||
if identity == "" || identity == core.AsAuto {
|
||||
identity = core.AsUser
|
||||
}
|
||||
if apiErr := checkErr(result, identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
@@ -30,8 +31,13 @@ type ResponseOptions struct {
|
||||
ErrOut io.Writer // stderr
|
||||
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
|
||||
CommandPath string // raw cobra CommandPath() for content safety scanning
|
||||
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
|
||||
CheckError func(interface{}) error
|
||||
// Identity is forwarded to CheckError (default or caller-supplied) so the
|
||||
// classifier can populate identity-aware fields (e.g. PermissionError.Identity).
|
||||
// Defaults to core.AsUser when empty.
|
||||
Identity core.Identity
|
||||
// CheckError is called on parsed JSON results. Nil defaults to (*APIClient).CheckResponse
|
||||
// with the Identity field (or AsUser when unset).
|
||||
CheckError func(result interface{}, identity core.Identity) error
|
||||
}
|
||||
|
||||
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
|
||||
@@ -40,9 +46,21 @@ type ResponseOptions struct {
|
||||
// 3. If Content-Type is non-JSON and no --output, auto-save binary to file.
|
||||
func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
identity := opts.Identity
|
||||
if identity == "" {
|
||||
identity = core.AsUser
|
||||
}
|
||||
check := opts.CheckError
|
||||
if check == nil {
|
||||
check = CheckLarkResponse
|
||||
// Stage 1: default check routes through legacy CheckResponse
|
||||
// (output.ErrAPI / ClassifyLarkError). Stage-2+ migration will
|
||||
// switch this to errclass.BuildAPIError so PermissionError carries
|
||||
// MissingScopes / ConsoleURL — at that point a zero-value
|
||||
// *APIClient still works because BuildAPIError short-circuits on
|
||||
// empty AppID, gracefully degrading identity-aware fields.
|
||||
check = func(r interface{}, id core.Identity) error {
|
||||
return (&APIClient{}).CheckResponse(r, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
|
||||
@@ -58,7 +76,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if err != nil {
|
||||
return WrapJSONResponseParseError(err, resp.RawBody)
|
||||
}
|
||||
if apiErr := check(result); apiErr != nil {
|
||||
if apiErr := check(result, identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
// Content safety scanning
|
||||
|
||||
@@ -234,37 +234,6 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty JSON body")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if exitErr.Detail.Message != "API returned an empty JSON response body" {
|
||||
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
@@ -424,17 +393,3 @@ func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
|
||||
t.Errorf("saved_path should be absolute, got %q", savedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 JSON with non-zero code")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "99991400") {
|
||||
t.Errorf("expected lark error code in message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,13 @@ func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
|
||||
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
|
||||
// need a custom Message or an independent Hint (strict-mode) should
|
||||
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
|
||||
//
|
||||
// Deprecated: BuildDenialError produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — denial signals should move to a typed *errs.XxxError (a dedicated
|
||||
// typed Error for policy denial is tracked for the cmdpolicy migration PR).
|
||||
// This helper is retained only while existing call sites are migrated; it
|
||||
// will be removed once they have moved to the typed surface.
|
||||
func BuildDenialError(path string, d Denial) *output.ExitError {
|
||||
cd := CommandDeniedFromDenial(path, d)
|
||||
return &output.ExitError{
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// Deprecated: RequireConfirmation produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — confirmation-required signals should move to typed
|
||||
// *errs.ConfirmationRequiredError carrying the same agent-protocol metadata
|
||||
// (level/action) as typed extension fields. This helper is retained only
|
||||
// while existing call sites are migrated; it will be removed once they have
|
||||
// moved to the typed surface.
|
||||
func RequireConfirmation(action string) error {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitConfirmationRequired,
|
||||
|
||||
@@ -236,7 +236,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
app := raw.CurrentAppConfig(profileOverride)
|
||||
if app == nil {
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("profile %q not found", profileOverride),
|
||||
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
|
||||
@@ -244,20 +244,19 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, &ConfigError{Code: 2, Type: "config",
|
||||
return nil, &ConfigError{Code: 3, Type: "config",
|
||||
Message: "appId and appSecret keychain key are out of sync",
|
||||
Hint: err.Error()}
|
||||
}
|
||||
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
// If the error comes from the keychain, it will already be wrapped as an ExitError.
|
||||
// For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError.
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, exitErr
|
||||
}
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
|
||||
return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import "fmt"
|
||||
// ConfigError is a structured error from config resolution.
|
||||
// It carries enough information for main.go to convert it into an output.ExitError.
|
||||
type ConfigError struct {
|
||||
Code int // exit code: 2=validation, 3=auth
|
||||
Code int // exit code: 3 (config errors share the auth exit code)
|
||||
Type string // "config" or "auth"
|
||||
Message string
|
||||
Hint string
|
||||
|
||||
@@ -31,7 +31,7 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
// keeps it on the standard structured-envelope path at the root
|
||||
// command's error sink.
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("failed to load config: %v", err),
|
||||
}
|
||||
@@ -71,14 +71,14 @@ func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: "not configured",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
@@ -105,14 +105,14 @@ func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: "no active profile",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
|
||||
284
internal/errclass/classify.go
Normal file
284
internal/errclass/classify.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
||||
}
|
||||
|
||||
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
||||
// Returns nil when resp is nil or resp["code"] is 0.
|
||||
//
|
||||
// Routing by Category:
|
||||
//
|
||||
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
|
||||
// Authentication → *errs.AuthenticationError
|
||||
// Config → *errs.ConfigError
|
||||
// Policy → *errs.SecurityPolicyError
|
||||
// Validation → *errs.ValidationError
|
||||
// Network → *errs.NetworkError
|
||||
// Internal → *errs.InternalError
|
||||
// Confirmation → *errs.ConfirmationRequiredError
|
||||
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
|
||||
//
|
||||
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
||||
// CategoryAPI + SubtypeUnknown.
|
||||
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
code := intFromAny(resp["code"])
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
if msg == "" {
|
||||
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
|
||||
// so the typed wire envelope still carries a human-readable signal.
|
||||
msg = fmt.Sprintf("API error: [%d]", code)
|
||||
}
|
||||
// Lark API responses sometimes carry log_id at the top level
|
||||
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
|
||||
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
|
||||
// back to the nested location so log_id always surfaces on the typed
|
||||
// envelope.
|
||||
logID, _ := resp["log_id"].(string)
|
||||
if logID == "" {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
if nested, ok := errBlock["log_id"].(string); ok {
|
||||
logID = nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta, ok := LookupCodeMeta(code)
|
||||
if !ok {
|
||||
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
|
||||
}
|
||||
|
||||
base := errs.Problem{
|
||||
Category: meta.Category,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
LogID: logID,
|
||||
Retryable: meta.Retryable,
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
return buildPermissionError(base, resp, cc)
|
||||
case errs.CategoryAuthentication:
|
||||
return &errs.AuthenticationError{Problem: base}
|
||||
case errs.CategoryConfig:
|
||||
return &errs.ConfigError{Problem: base}
|
||||
case errs.CategoryPolicy:
|
||||
return buildSecurityPolicyError(base, resp)
|
||||
case errs.CategoryValidation:
|
||||
return &errs.ValidationError{Problem: base}
|
||||
case errs.CategoryNetwork:
|
||||
return &errs.NetworkError{Problem: base}
|
||||
case errs.CategoryInternal:
|
||||
return &errs.InternalError{Problem: base}
|
||||
case errs.CategoryConfirmation:
|
||||
return &errs.ConfirmationRequiredError{Problem: base}
|
||||
default:
|
||||
return &errs.APIError{Problem: base, Detail: resp}
|
||||
}
|
||||
}
|
||||
|
||||
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
|
||||
// response's data block, so the typed SecurityPolicyError carries the same
|
||||
// browser-challenge information that internal/auth/transport.go surfaces at
|
||||
// the HTTP layer.
|
||||
//
|
||||
// Data shapes accepted (whichever the upstream sends):
|
||||
//
|
||||
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
|
||||
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
|
||||
//
|
||||
// challenge_url is dropped (set to "") if it is not an https:// URL — same
|
||||
// validation policy as internal/auth/transport.go.isValidChallengeURL.
|
||||
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
|
||||
// either spelling surfaces, matching the transport layer.
|
||||
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
|
||||
dataMap, _ := resp["data"].(map[string]any)
|
||||
if dataMap == nil {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
dataMap, _ = errBlock["data"].(map[string]any)
|
||||
}
|
||||
}
|
||||
if dataMap == nil {
|
||||
return &errs.SecurityPolicyError{Problem: p}
|
||||
}
|
||||
|
||||
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
|
||||
if challengeURL != "" && !isHTTPSURL(challengeURL) {
|
||||
challengeURL = ""
|
||||
}
|
||||
|
||||
hint := stringFromAny(dataMap["hint"])
|
||||
if hint == "" {
|
||||
hint = stringFromAny(dataMap["cli_hint"])
|
||||
}
|
||||
if hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: p,
|
||||
ChallengeURL: challengeURL,
|
||||
}
|
||||
}
|
||||
|
||||
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
||||
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
||||
// the two will collapse when the auth transport adopts BuildAPIError in stage 4.
|
||||
func isHTTPSURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "https"
|
||||
}
|
||||
|
||||
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
|
||||
func stringFromAny(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
if identity == "" {
|
||||
identity = "user"
|
||||
}
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype)
|
||||
return &errs.PermissionError{
|
||||
Problem: p,
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionHint returns an actionable next-step string for a permission
|
||||
// error. User identity with a missing user-scope is recovered by re-running
|
||||
// `auth login --scope ...`; bot identity or app-level scope errors are
|
||||
// recovered by enabling scopes in the open-platform console. The subtype
|
||||
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
|
||||
// where re-authentication will not help regardless of the caller identity.
|
||||
//
|
||||
// Exported so direct construction sites (cmd/service/service.go's
|
||||
// checkServiceScopes) can produce hints that match the dispatcher path
|
||||
// byte-for-byte instead of hand-rolling divergent strings.
|
||||
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
|
||||
// app_scope_not_enabled means the scope has not been granted at the
|
||||
// app (developer console) level — re-authenticating cannot fix it,
|
||||
// so route every caller identity to the console hint.
|
||||
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
|
||||
if len(missing) == 0 {
|
||||
if useConsole {
|
||||
return "check the app's scope grant in the Lark open platform console"
|
||||
}
|
||||
return "ensure the calling identity has been granted the required scopes"
|
||||
}
|
||||
scopes := strings.Join(missing, " ")
|
||||
if useConsole {
|
||||
return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes)
|
||||
}
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes)
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
// Returns nil when the structure is absent.
|
||||
func extractMissingScopes(resp map[string]any) []string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := errBlock["permission_violations"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, v := range raw {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, _ := m["subject"].(string)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return int(i)
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err == nil {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
747
internal/errclass/classify_test.go
Normal file
747
internal/errclass/classify_test.go
Normal file
@@ -0,0 +1,747 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// missingScopeResp builds a minimal Lark missing-scope response with one
|
||||
// violation. Shared across the envelope-shape and brand-switch tests.
|
||||
func missingScopeResp(scope string) map[string]any {
|
||||
return map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "scope missing",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": scope},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
|
||||
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("nil resp should return nil error, got %v", got)
|
||||
}
|
||||
if got := errclass.BuildAPIError(map[string]any{"code": 0, "msg": "ok"}, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("code=0 should return nil error, got %v", got)
|
||||
}
|
||||
// json.Number 0 path (real-world SDK decodes with UseNumber)
|
||||
resp := map[string]any{"code": json.Number("0"), "msg": "ok"}
|
||||
if got := errclass.BuildAPIError(resp, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("json.Number(0) should return nil error, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// matchesTypedError reports whether err is the typed-error variant identified by
|
||||
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
|
||||
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
|
||||
// through to *APIError) fails loudly instead of passing on Category alone.
|
||||
func matchesTypedError(err error, wantTyped string) bool {
|
||||
switch wantTyped {
|
||||
case "PermissionError":
|
||||
var x *errs.PermissionError
|
||||
return errors.As(err, &x)
|
||||
case "AuthenticationError":
|
||||
var x *errs.AuthenticationError
|
||||
return errors.As(err, &x)
|
||||
case "ValidationError":
|
||||
var x *errs.ValidationError
|
||||
return errors.As(err, &x)
|
||||
case "NetworkError":
|
||||
var x *errs.NetworkError
|
||||
return errors.As(err, &x)
|
||||
case "ConfigError":
|
||||
var x *errs.ConfigError
|
||||
return errors.As(err, &x)
|
||||
case "InternalError":
|
||||
var x *errs.InternalError
|
||||
return errors.As(err, &x)
|
||||
case "ConfirmationRequiredError":
|
||||
var x *errs.ConfirmationRequiredError
|
||||
return errors.As(err, &x)
|
||||
case "SecurityPolicyError":
|
||||
var x *errs.SecurityPolicyError
|
||||
return errors.As(err, &x)
|
||||
case "APIError":
|
||||
// APIError is the default fallback; use a direct type assertion to avoid
|
||||
// matching against typed subclasses that also satisfy IsAPI.
|
||||
_, ok := err.(*errs.APIError)
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantExit int
|
||||
wantTyped string
|
||||
}{
|
||||
{"99991672 app_missing_scope", 99991672, errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, 3, "PermissionError"},
|
||||
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
|
||||
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.Subtype("task_permission_denied"), 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
{"unknown code 999999", 999999, errs.CategoryAPI, errs.SubtypeUnknown, 1, "APIError"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := map[string]any{"code": tc.code, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for code %d, got nil", tc.code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok for code %d (err = %T)", tc.code, err)
|
||||
}
|
||||
if p.Category != tc.wantCat {
|
||||
t.Errorf("Category = %q, want %q", p.Category, tc.wantCat)
|
||||
}
|
||||
if p.Subtype != tc.wantSubtype {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, tc.wantSubtype)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != tc.wantExit {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (typed = %s)", got, tc.wantExit, tc.wantTyped)
|
||||
}
|
||||
if !matchesTypedError(err, tc.wantTyped) {
|
||||
t.Errorf("typed-error mismatch: got %T, want %s", err, tc.wantTyped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_ValidationRoutesToValidationError pins that code 1470400
|
||||
// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not
|
||||
// the default *errs.APIError. The dispatcher must read codeMeta.Category and
|
||||
// route accordingly so the embedded Problem.Category matches the wire type.
|
||||
func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) {
|
||||
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 1470400")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if _, isAPI := err.(*errs.APIError); isAPI {
|
||||
t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionErrorEnvelopeShape(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"log_id": "lg-1",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": "docx:document"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
|
||||
var buf bytes.Buffer
|
||||
ok := output.WriteTypedErrorEnvelope(&buf, err, "user")
|
||||
if !ok {
|
||||
t.Fatal("WriteTypedErrorEnvelope returned false for typed error")
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// positive assertions
|
||||
for _, want := range []string{
|
||||
`"type": "authorization"`,
|
||||
`"subtype": "missing_scope"`,
|
||||
`"code": 99991679`,
|
||||
`"missing_scopes":`,
|
||||
`"docx:document"`,
|
||||
`"console_url":`,
|
||||
`open.feishu.cn/app/cli_a123/auth`,
|
||||
`"identity": "user"`,
|
||||
`"log_id": "lg-1"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("envelope missing %q\nfull: %s", want, out)
|
||||
}
|
||||
}
|
||||
// negative assertions on the wire format
|
||||
for _, mustNot := range []string{
|
||||
`"component"`,
|
||||
`"doc_url"`,
|
||||
`"retryable":`, // Retryable defaults false, omitempty → key absent
|
||||
} {
|
||||
if strings.Contains(out, mustNot) {
|
||||
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableEnvelope_TrueOnly(t *testing.T) {
|
||||
// Test 1: Retryable:true → key present
|
||||
apiErr := &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Message: "x", Retryable: true,
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
output.WriteTypedErrorEnvelope(&buf, apiErr, "user")
|
||||
if !strings.Contains(buf.String(), `"retryable": true`) {
|
||||
t.Errorf("Retryable:true should emit key; got: %s", buf.String())
|
||||
}
|
||||
|
||||
// Test 2: Retryable:false → key absent
|
||||
buf.Reset()
|
||||
apiErr2 := &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI, Message: "x", Retryable: false,
|
||||
}}
|
||||
if ok := output.WriteTypedErrorEnvelope(&buf, apiErr2, "user"); !ok {
|
||||
t.Fatal("WriteTypedErrorEnvelope returned false for typed error — emission failed silently")
|
||||
}
|
||||
if strings.Contains(buf.String(), `"retryable"`) {
|
||||
t.Errorf("Retryable:false should omit key; got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_EmptyAppID(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.ConsoleURL != "" {
|
||||
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
|
||||
// scope values so a hostile value cannot break out of the URL framing
|
||||
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
|
||||
func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
scopes []string
|
||||
wantInURL []string // substrings that MUST appear
|
||||
denyInURL []string // substrings that MUST NOT appear
|
||||
}{
|
||||
{
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes)
|
||||
for _, want := range tt.wantInURL {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got)
|
||||
}
|
||||
}
|
||||
for _, deny := range tt.denyInURL {
|
||||
if strings.Contains(got, deny) {
|
||||
t.Errorf("ConsoleURL contains unescaped dangerous substring\n deny: %s\n got: %s", deny, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_DefaultIdentity(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.Identity != "user" {
|
||||
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_NoViolations(t *testing.T) {
|
||||
// permission error without a permission_violations array → MissingScopes nil,
|
||||
// ConsoleURL falls back to the no-scope form.
|
||||
resp := map[string]any{"code": 99991679, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMissingScopes_Dedup(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "x",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": "docx:document"},
|
||||
map[string]any{"subject": "docx:document"}, // dup
|
||||
map[string]any{"subject": ""}, // ignored
|
||||
map[string]any{"subject": "im:message"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if got, want := len(pe.MissingScopes), 2; got != want {
|
||||
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceShortcutEnvelopeConverge guards that the wire envelope is
|
||||
// identical whether produced via the dispatcher (BuildAPIError — the normal
|
||||
// service / shortcut path) or constructed directly at the call site (the
|
||||
// cmd/service permission path).
|
||||
//
|
||||
// cmd/service/service.go's checkServiceScopes builds PermissionError using the
|
||||
// exported PermissionHint and ConsoleURL helpers — the same helpers
|
||||
// BuildAPIError uses. The hand-constructed branch below intentionally mirrors
|
||||
// service.go line-by-line so a future drift on either side (e.g. a new
|
||||
// extension field on PermissionError that only BuildAPIError populates) fails
|
||||
// loudly here. The remaining limitation is that this test invokes the helpers
|
||||
// directly rather than driving checkServiceScopes (which requires a credential
|
||||
// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight
|
||||
// mock harness lands.
|
||||
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
||||
const (
|
||||
brand = "feishu"
|
||||
appID = "cli_a123"
|
||||
identity = "user"
|
||||
)
|
||||
missing := []string{"docx:document"}
|
||||
|
||||
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
|
||||
resp := missingScopeResp(missing[0])
|
||||
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
|
||||
dispatcherPE, ok := dispatcherErr.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
||||
}
|
||||
|
||||
// Path B: direct construction — exactly mirrors cmd/service/service.go's
|
||||
// checkServiceScopes (same helpers, same field-fill order). Code
|
||||
// and Message are copied from Path A so the byte-comparison below isolates
|
||||
// the contract under test (Hint + Identity + ConsoleURL convergence).
|
||||
directErr := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Code: dispatcherPE.Code,
|
||||
Message: dispatcherPE.Message,
|
||||
Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope),
|
||||
},
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: errclass.ConsoleURL(brand, appID, missing),
|
||||
}
|
||||
|
||||
var bufA, bufB bytes.Buffer
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
|
||||
t.Fatal("dispatcher path failed to emit typed envelope")
|
||||
}
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufB, directErr, identity); !ok {
|
||||
t.Fatal("direct path failed to emit typed envelope")
|
||||
}
|
||||
|
||||
if bufA.String() != bufB.String() {
|
||||
t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
|
||||
// Mirrors what the cmd/service direct-construction path produces.
|
||||
pe := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Message: "missing required scope(s): docx:document",
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
Identity: "user",
|
||||
}
|
||||
if got := output.ExitCodeOf(pe); got != 3 {
|
||||
t.Errorf("ExitCodeOf = %d, want 3", got)
|
||||
}
|
||||
if !errs.IsPermission(pe) {
|
||||
t.Error("expected IsPermission(pe) == true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTypedEnvelope_UntypedReturnsFalse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if output.WriteTypedErrorEnvelope(&buf, errors.New("plain"), "user") {
|
||||
t.Error("expected WriteTypedErrorEnvelope to return false for untyped error")
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("expected no output for untyped error, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_LogIDNestedInError(t *testing.T) {
|
||||
// Some Lark API responses carry log_id nested under "error" rather than
|
||||
// at the top level. BuildAPIError must surface either location.
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"error": map[string]any{
|
||||
"log_id": "lg-nested-123",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.LogID != "lg-nested-123" {
|
||||
t.Errorf("LogID = %q, want lg-nested-123", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"log_id": "lg-top-456",
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.LogID != "lg-top-456" {
|
||||
t.Errorf("LogID = %q, want lg-top-456", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_UserWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "lark-cli auth login") {
|
||||
t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
||||
t.Errorf("user hint should include missing scopes; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_BotWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot hint should mention the open-platform console; got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("bot hint must not suggest re-running `auth login`; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_NoScopes(t *testing.T) {
|
||||
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") {
|
||||
t.Errorf("user no-scope hint missing fallback wording; got %q", got)
|
||||
}
|
||||
if got := errclass.PermissionHint(nil, "bot", errs.SubtypeMissingScope); !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot no-scope hint should still point at the console; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// 99991672 / app_scope_not_enabled means the scope has not been granted
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("identity=%q: hint should point to console; got %q", identity, got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
|
||||
// Regression: code 99991672 with user identity previously emitted
|
||||
// `lark-cli auth login --scope ...` which sends agents into a re-auth
|
||||
// loop because the missing scope is not yet enabled at the app level.
|
||||
resp := map[string]any{
|
||||
"code": 99991672,
|
||||
"msg": "app scope not enabled",
|
||||
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "open platform console") {
|
||||
t.Errorf("Hint should route to console; got %q", p.Hint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "auth login") {
|
||||
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_HintPopulated(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.Hint == "" {
|
||||
t.Error("PermissionError.Hint should be populated by BuildAPIError")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "docx:document") {
|
||||
t.Errorf("Hint should reference missing scope; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_JSONNumberCode(t *testing.T) {
|
||||
// SDK parses with json.Number; verify intFromAny handles it.
|
||||
resp := map[string]any{"code": json.Number("99991679"), "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for json.Number-encoded code")
|
||||
}
|
||||
if _, ok := err.(*errs.PermissionError); !ok {
|
||||
t.Errorf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
|
||||
// passing through BuildAPIError keep the browser-challenge URL and hint —
|
||||
// agents need challenge_url to drive the user through MFA / device-trust
|
||||
// flows. Without extraction, the typed envelope is degenerate vs. what the
|
||||
// internal/auth/transport.go HTTP-layer interceptor already produces.
|
||||
func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"data": map[string]any{
|
||||
"challenge_url": "https://passport.feishu.cn/challenge/xyz",
|
||||
"hint": "complete MFA in the browser, then retry",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
|
||||
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Hint != "complete MFA in the browser, then retry" {
|
||||
t.Errorf("Hint = %q, want MFA hint", spe.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint pins that responses
|
||||
// using data.cli_hint still surface via Hint when data.hint is absent.
|
||||
func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21001,
|
||||
"msg": "access denied",
|
||||
"data": map[string]any{
|
||||
"cli_hint": "ask your admin for elevated approval",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.Hint != "ask your admin for elevated approval" {
|
||||
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge pins that an
|
||||
// untrusted challenge_url (non-https) is dropped — same policy as
|
||||
// internal/auth/transport.go isValidChallengeURL.
|
||||
func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
|
||||
cases := []string{
|
||||
"http://attacker.example.com/challenge",
|
||||
"javascript:alert(1)",
|
||||
"ftp://example.com/challenge",
|
||||
"not a url at all",
|
||||
}
|
||||
for _, bad := range cases {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"data": map[string]any{"challenge_url": bad, "hint": "h"},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyNoData pins the no-data case — typed
|
||||
// envelope still routes correctly with empty extension fields when the
|
||||
// upstream response carries only code+msg.
|
||||
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
|
||||
resp := map[string]any{"code": 21000, "msg": "challenge required"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Message != "challenge required" {
|
||||
t.Errorf("Message = %q, want challenge required", spe.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyMalformedData pins that malformed `data`
|
||||
// blocks (wrong type, wrong shape, non-string fields) degrade gracefully —
|
||||
// extension fields stay empty, no panic. Server-side bugs or transitional
|
||||
// API shapes must never crash the CLI dispatcher.
|
||||
func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp map[string]any
|
||||
}{
|
||||
{"data is string not map", map[string]any{"code": 21000, "msg": "x", "data": "oops"}},
|
||||
{"data is array not map", map[string]any{"code": 21000, "msg": "x", "data": []any{1, 2}}},
|
||||
{"data is nil", map[string]any{"code": 21000, "msg": "x", "data": nil}},
|
||||
{"challenge_url is int", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": 123}}},
|
||||
{"challenge_url is nil", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": nil}}},
|
||||
{"hint is array", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"hint": []any{"a"}}}},
|
||||
{"error.data is wrong type", map[string]any{"code": 21000, "msg": "x", "error": map[string]any{"data": "oops"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("BuildAPIError panicked on malformed data: %v", r)
|
||||
}
|
||||
}()
|
||||
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyErrorDataShape pins extraction from the
|
||||
// {"error": {"data": {...}}} envelope variant — same lookup paths the
|
||||
// transport-layer interceptor uses on inbound responses.
|
||||
func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"error": map[string]any{
|
||||
"data": map[string]any{
|
||||
"challenge_url": "https://passport.feishu.cn/c/abc",
|
||||
"hint": "wrapped variant",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
|
||||
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Hint != "wrapped variant" {
|
||||
t.Errorf("Hint = %q, want wrapped variant", spe.Hint)
|
||||
}
|
||||
}
|
||||
87
internal/errclass/codemeta.go
Normal file
87
internal/errclass/codemeta.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CodeMeta is the classification metadata attached to a Lark numeric code.
|
||||
// It does NOT carry Message or Hint — those are derived at the dispatcher
|
||||
// (see BuildAPIError).
|
||||
type CodeMeta struct {
|
||||
Category errs.Category
|
||||
Subtype errs.Subtype
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
|
||||
// policy/config codes shared across services) live here; service-specific
|
||||
// sub-tables (e.g. task) live in dedicated files like codemeta_task.go and
|
||||
// merge into this map via init().
|
||||
//
|
||||
// Go language guarantees package-level vars initialize before init() functions,
|
||||
// so sub-tables registering via init() can always assume codeMeta is non-nil.
|
||||
var codeMeta = map[int]CodeMeta{
|
||||
// CategoryAuthentication
|
||||
99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing
|
||||
99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-)
|
||||
99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish)
|
||||
99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid
|
||||
99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired
|
||||
20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format
|
||||
20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired
|
||||
20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked
|
||||
20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used
|
||||
20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error
|
||||
|
||||
// CategoryAuthorization
|
||||
99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false},
|
||||
99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false},
|
||||
99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope
|
||||
230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app
|
||||
99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable
|
||||
99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant
|
||||
|
||||
// CategoryAPI
|
||||
99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true},
|
||||
1061045: {errs.CategoryAPI, errs.SubtypeConflict, true},
|
||||
131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff
|
||||
1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false},
|
||||
1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false},
|
||||
1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable
|
||||
1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false},
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
|
||||
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
|
||||
}
|
||||
|
||||
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
|
||||
// the caller (BuildAPIError) is responsible for falling back to
|
||||
// CategoryAPI/SubtypeUnknown.
|
||||
func LookupCodeMeta(code int) (CodeMeta, bool) {
|
||||
m, ok := codeMeta[code]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// mergeCodeMeta is invoked by sub-table init() functions to merge service-specific
|
||||
// codes into the central registry. Panics on duplicate code so a misregistration
|
||||
// fails fast at startup rather than producing silently-inconsistent classification.
|
||||
func mergeCodeMeta(src map[int]CodeMeta, owner string) {
|
||||
for code, meta := range src {
|
||||
if existing, dup := codeMeta[code]; dup {
|
||||
panic(fmt.Sprintf("codeMeta dup: code %d already mapped %+v; %s wants %+v",
|
||||
code, existing, owner, meta))
|
||||
}
|
||||
codeMeta[code] = meta
|
||||
}
|
||||
}
|
||||
24
internal/errclass/codemeta_task.go
Normal file
24
internal/errclass/codemeta_task.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// taskCodeMeta holds the task-service-specific Lark code classifications.
|
||||
// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task
|
||||
// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this
|
||||
// map via mergeCodeMeta + LookupCodeMeta.
|
||||
var taskCodeMeta = map[int]CodeMeta{
|
||||
1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false},
|
||||
1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied
|
||||
1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false},
|
||||
1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true},
|
||||
1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true},
|
||||
1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false},
|
||||
1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false},
|
||||
1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false},
|
||||
1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false},
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(taskCodeMeta, "task") }
|
||||
105
internal/errclass/codemeta_test.go
Normal file
105
internal/errclass/codemeta_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMeta_MissingScope(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991679)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991679) ok=false, want true")
|
||||
}
|
||||
want := CodeMeta{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Retryable: false}
|
||||
if got != want {
|
||||
t.Fatalf("LookupCodeMeta(99991679) = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(1470403)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(1470403) ok=false, want true (task sub-table init merge)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("task_permission_denied") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied")
|
||||
}
|
||||
if got.Retryable {
|
||||
t.Errorf("Retryable = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(20050) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(20050).Retryable = false, want true (sole retryable refresh code)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthentication {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthentication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991400)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991400) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(99991400).Retryable = false, want true (rate_limit retryable)")
|
||||
}
|
||||
if got.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
t.Fatalf("LookupCodeMeta(999999) ok=true, want false for unknown code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(21000)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(21000) ok=false, want true")
|
||||
}
|
||||
if got.Category != errs.CategoryPolicy {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryPolicy)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("challenge_required") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "challenge_required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("mergeCodeMeta with duplicate code did not panic")
|
||||
}
|
||||
msg, ok := r.(string)
|
||||
if !ok {
|
||||
t.Fatalf("panic value is not a string: %T (%v)", r, r)
|
||||
}
|
||||
for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Errorf("panic message %q missing substring %q", msg, needle)
|
||||
}
|
||||
}
|
||||
}()
|
||||
mergeCodeMeta(map[int]CodeMeta{
|
||||
1470403: {Category: errs.CategoryAPI, Subtype: errs.Subtype("intruder")},
|
||||
}, "test")
|
||||
}
|
||||
32
internal/errcompat/promote.go
Normal file
32
internal/errcompat/promote.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package errcompat bridges the legacy *core.ConfigError shape into the
|
||||
// canonical typed errors taxonomy in errs/. It is a thin boundary helper —
|
||||
// placed in its own package so it can import both core (for the legacy
|
||||
// type) and errs (for the typed targets) without creating an import cycle
|
||||
// with internal/errclass, which intentionally avoids depending on
|
||||
// internal/core.
|
||||
package errcompat
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// PromoteConfigError is the stage-2 boundary helper that will convert a
|
||||
// *core.ConfigError into the matching typed errs.* error. In stage 1 it
|
||||
// is a passthrough — the dispatcher continues to render *core.ConfigError
|
||||
// via the legacy envelope path (cmd/root.go asExitError) so the wire
|
||||
// shape stays identical to pre-PR. Per-domain typed migration in stage 2+
|
||||
// will fill in the actual promotion logic alongside its corresponding
|
||||
// wire-change announcement.
|
||||
func PromoteConfigError(cfgErr *core.ConfigError) error {
|
||||
if cfgErr == nil {
|
||||
return nil
|
||||
}
|
||||
return cfgErr
|
||||
}
|
||||
|
||||
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
|
||||
var _ = errs.CategoryConfig
|
||||
37
internal/errcompat/promote_test.go
Normal file
37
internal/errcompat/promote_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errcompat_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
)
|
||||
|
||||
// TestPromoteConfigError_Stage1Passthrough pins the stage-1 passthrough
|
||||
// behaviour: every input *core.ConfigError flows out unchanged so the
|
||||
// dispatcher's legacy envelope path emits the same wire shape as pre-PR.
|
||||
// Per-domain typed migration will replace this in stage 2+.
|
||||
func TestPromoteConfigError_Stage1Passthrough(t *testing.T) {
|
||||
for _, cfgType := range []string{"config", "auth", "openclaw", ""} {
|
||||
t.Run(cfgType, func(t *testing.T) {
|
||||
src := &core.ConfigError{Code: 3, Type: cfgType, Message: "msg", Hint: "hint"}
|
||||
out := errcompat.PromoteConfigError(src)
|
||||
var got *core.ConfigError
|
||||
if !errors.As(out, &got) || got != src {
|
||||
t.Fatalf("Type=%q: expected passthrough of original *core.ConfigError, got %T (%v)", cfgType, out, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteConfigError_NilInputReturnsNil pins that PromoteConfigError on a
|
||||
// nil input returns nil rather than panicking on the (cfgErr.Type) access.
|
||||
func TestPromoteConfigError_NilInputReturnsNil(t *testing.T) {
|
||||
if got := errcompat.PromoteConfigError(nil); got != nil {
|
||||
t.Errorf("PromoteConfigError(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,13 @@ func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invoca
|
||||
// *output.ExitError so cmd/root.go's envelope writer emits the right
|
||||
// JSON structure (type="hook"). Non-AbortError values pass through
|
||||
// unchanged.
|
||||
//
|
||||
// Deprecated: wrapAbortError converts to a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// add producers of this shape — hook abort signals should move to a typed
|
||||
// *errs.XxxError (typed hook error is tracked for the hook framework
|
||||
// migration PR). This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func wrapAbortError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,14 @@ type Envelope struct {
|
||||
}
|
||||
|
||||
// ErrorEnvelope is the standard error response wrapper.
|
||||
//
|
||||
// Deprecated: ErrorEnvelope belongs to the legacy *output.ExitError surface
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — the typed envelope shape is owned by
|
||||
// internal/output.WriteTypedErrorEnvelope which marshals typed errs.* errors
|
||||
// directly via JSON reflection (no wrapper struct needed). This struct is
|
||||
// retained only while existing *ExitError call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
type ErrorEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
@@ -23,6 +31,13 @@ type ErrorEnvelope struct {
|
||||
}
|
||||
|
||||
// ErrDetail describes a structured error.
|
||||
//
|
||||
// Deprecated: ErrDetail belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — typed errs.* structs embed errs.Problem and own their wire shape
|
||||
// via JSON tags (Category, Subtype, Hint, etc. promote to the top level).
|
||||
// This struct is retained only while existing *ExitError call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
type ErrDetail struct {
|
||||
Type string `json:"type"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -37,6 +52,14 @@ type ErrDetail struct {
|
||||
// confirmation_required errors. Level is one of "read" | "write" |
|
||||
// "high-risk-write". Action identifies the command for the agent (e.g.
|
||||
// "mail +send", "drive.files.delete").
|
||||
//
|
||||
// Deprecated: RiskDetail is reachable only via *output.ExitError.Detail.Risk,
|
||||
// part of the legacy envelope surface that predates the typed error contract
|
||||
// introduced by errs/. New code MUST NOT use it — confirmation-required
|
||||
// signals belong on *errs.ConfirmationRequiredError (its own typed extension
|
||||
// fields can carry agent-protocol metadata directly). This struct is
|
||||
// retained only while existing *ExitError call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
type RiskDetail struct {
|
||||
Level string `json:"level"`
|
||||
Action string `json:"action"`
|
||||
|
||||
@@ -9,16 +9,26 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ExitError is a structured error that carries an exit code and optional detail.
|
||||
// It is propagated up the call chain and handled by main.go to produce
|
||||
// a JSON error envelope on stderr and the correct exit code.
|
||||
//
|
||||
// Deprecated: *output.ExitError is the legacy error type that predates the
|
||||
// typed error contract introduced by errs/. New code MUST NOT instantiate it
|
||||
// — return a typed *errs.XxxError (see errs/ for the available categories:
|
||||
// *AuthenticationError / *PermissionError / *ValidationError / *NetworkError /
|
||||
// *APIError / *InternalError / etc.). This type is retained only while
|
||||
// existing call sites are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Detail *ErrDetail
|
||||
Err error
|
||||
Raw bool // when true, skip enrichment (e.g. enrichPermissionError) and preserve original error
|
||||
Raw bool // when true, the dispatcher skips enrichment (e.g. enrichPermissionError) and preserves the original error detail
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
@@ -35,7 +45,31 @@ func (e *ExitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// MarkRaw sets Raw=true on an ExitError so that the dispatcher skips
|
||||
// enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and
|
||||
// preserves the original API error detail. Returns the original error
|
||||
// unchanged if it is not (or does not wrap) an ExitError.
|
||||
//
|
||||
// Used by `cmd/api` and other "passthrough" call sites where the caller
|
||||
// explicitly wants the raw Lark API detail (log_id, troubleshooter, etc.)
|
||||
// on the wire rather than the enriched message/hint variant.
|
||||
func MarkRaw(err error) error {
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitErr.Raw = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w.
|
||||
//
|
||||
// Deprecated: WriteErrorEnvelope is the legacy envelope writer paired with
|
||||
// *output.ExitError, which predates the typed error contract introduced by
|
||||
// errs/. New code MUST NOT call this directly — return a typed *errs.XxxError
|
||||
// from the command, and cmd/root.go handleRootError will dispatch through
|
||||
// WriteTypedErrorEnvelope. This writer is retained only while existing
|
||||
// *ExitError producers are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||
if err.Detail == nil {
|
||||
return
|
||||
@@ -60,6 +94,13 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||
// --- Convenience constructors ---
|
||||
|
||||
// Errorf creates an ExitError with the given code, type, and formatted message.
|
||||
//
|
||||
// Deprecated: Errorf belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — construct a typed *errs.XxxError directly (e.g.
|
||||
// *errs.ValidationError, *errs.InternalError). This helper is retained only
|
||||
// while existing call sites are migrated; it will be removed once they have
|
||||
// moved to the typed surface.
|
||||
func Errorf(code int, errType, format string, args ...any) *ExitError {
|
||||
var err error
|
||||
for _, arg := range args {
|
||||
@@ -75,23 +116,58 @@ func Errorf(code int, errType, format string, args ...any) *ExitError {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrValidation creates a validation ExitError (exit 2).
|
||||
// ErrValidation creates a validation ExitError (exit 2, wire type
|
||||
// "validation"). The legacy *output.ExitError envelope emits only
|
||||
// `type`+`message` — no `subtype`/`param` extension fields.
|
||||
//
|
||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
||||
// (type, message) pair. To carry extension fields (Subtype, Param, etc.)
|
||||
// on the wire, construct `&errs.ValidationError{...}` directly so
|
||||
// cmd/root.go routes it through the typed envelope writer. Per-domain
|
||||
// typed migration in stage 2+ will migrate existing call sites and
|
||||
// remove this helper.
|
||||
func ErrValidation(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitValidation, "validation", format, args...)
|
||||
}
|
||||
|
||||
// ErrAuth creates an auth ExitError (exit 3).
|
||||
// ErrAuth creates an authentication ExitError (exit 3, wire type "auth").
|
||||
//
|
||||
// Stage-1 status: kept as the canonical helper for token-missing /
|
||||
// login-required errors, so the 19 existing call sites in cmd/auth,
|
||||
// cmd/config, cmd/event, internal/client, and shortcuts/common keep
|
||||
// emitting `type: "auth"`. To migrate a single call site to the typed
|
||||
// taxonomy (`type: "authentication"` on the wire), construct
|
||||
// `&errs.AuthenticationError{...}` directly — but note that flips a
|
||||
// user-visible wire field and belongs in the per-domain stage-2 PR for
|
||||
// that area, not in unrelated new code.
|
||||
func ErrAuth(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitAuth, "auth", format, args...)
|
||||
}
|
||||
|
||||
// ErrNetwork creates a network ExitError (exit 4).
|
||||
// ErrNetwork creates a network ExitError (exit 4, wire type "network").
|
||||
// The legacy *output.ExitError envelope emits only `type`+`message` — no
|
||||
// `subtype`/`cause` extension fields.
|
||||
//
|
||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
||||
// (type, message) pair. To carry extension fields (Subtype "transport" /
|
||||
// "timeout" / "tls" / "dns", retryable hint, etc.) on the wire, construct
|
||||
// `&errs.NetworkError{...}` directly. Per-domain typed migration in
|
||||
// stage 2+ will migrate existing call sites and remove this helper.
|
||||
func ErrNetwork(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitNetwork, "network", format, args...)
|
||||
}
|
||||
|
||||
// ErrAPI creates an API ExitError using ClassifyLarkError.
|
||||
// For permission errors, uses a concise message; the raw API response is preserved in Detail.
|
||||
//
|
||||
// Deprecated: ErrAPI belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code SHOULD
|
||||
// construct a typed *errs.XxxError directly. The stage-2+ migration will
|
||||
// route classification through internal/errclass.BuildAPIError (shipped
|
||||
// but not yet invoked from production paths) so the typed envelope carries
|
||||
// Category, Subtype, MissingScopes, ConsoleURL, and Identity from the
|
||||
// source. This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
||||
exitCode, errType, hint := ClassifyLarkError(larkCode, msg)
|
||||
if errType == "permission" {
|
||||
@@ -110,6 +186,13 @@ func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
||||
}
|
||||
|
||||
// ErrWithHint creates an ExitError with a hint string.
|
||||
//
|
||||
// Deprecated: ErrWithHint belongs to the legacy *output.ExitError surface
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — construct a typed *errs.XxxError directly and set its Hint
|
||||
// field (the typed envelope promotes Problem.Hint to the wire). This helper
|
||||
// is retained only while existing call sites are migrated; it will be
|
||||
// removed once they have moved to the typed surface.
|
||||
func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
||||
return &ExitError{
|
||||
Code: code,
|
||||
@@ -119,17 +202,62 @@ func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
||||
|
||||
// ErrBare creates an ExitError with only an exit code and no envelope.
|
||||
// Used for cases like `auth check` where the JSON output is already written to stdout.
|
||||
//
|
||||
// Deprecated: ErrBare belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — express the "exit with code, emit no envelope" semantics
|
||||
// explicitly at the call site (e.g. return a typed *errs.XxxError or call
|
||||
// os.Exit directly from RunE). This helper is retained only while existing
|
||||
// call sites are migrated; it will be removed once they have moved to the
|
||||
// typed surface.
|
||||
func ErrBare(code int) *ExitError {
|
||||
return &ExitError{Code: code}
|
||||
}
|
||||
|
||||
// MarkRaw sets Raw=true on an ExitError so that enrichment (e.g. enrichPermissionError)
|
||||
// is skipped and the original API error is preserved. Returns the original error unchanged
|
||||
// if it is not an ExitError.
|
||||
func MarkRaw(err error) error {
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitErr.Raw = true
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
|
||||
// a `detail` sub-object.
|
||||
//
|
||||
// Returns true when err was a typed error (envelope written) and false when
|
||||
// err had no Problem (caller should fall back to WriteErrorEnvelope).
|
||||
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return err
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
// Encoding failed — emit nothing here and let the dispatcher fall
|
||||
// back to the legacy envelope writer so stderr is never blank.
|
||||
return false
|
||||
}
|
||||
if _, writeErr := buf.WriteTo(w); writeErr != nil {
|
||||
// Write failed mid-envelope. Return false so the dispatcher does
|
||||
// not silently treat a half-written stderr as a successful emit
|
||||
// and skip every other fallback.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// typedEnvelope wraps a typed error for wire emission. Error is `error` so the
|
||||
// underlying typed error's own json tags determine the inner shape via
|
||||
// encoding/json reflection; Notice mirrors the existing ErrorEnvelope (see
|
||||
// GetNotice in envelope.go).
|
||||
type typedEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Error error `json:"error"`
|
||||
Notice map[string]interface{} `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
@@ -6,40 +6,10 @@ package output
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkRaw_ExitError(t *testing.T) {
|
||||
err := ErrAPI(99991672, "API error: [99991672] scope not enabled", nil)
|
||||
if err.Raw {
|
||||
t.Fatal("expected Raw=false before MarkRaw")
|
||||
}
|
||||
|
||||
result := MarkRaw(err)
|
||||
if result != err {
|
||||
t.Error("expected MarkRaw to return the same error")
|
||||
}
|
||||
if !err.Raw {
|
||||
t.Error("expected Raw=true after MarkRaw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRaw_NonExitError(t *testing.T) {
|
||||
plain := fmt.Errorf("some plain error")
|
||||
result := MarkRaw(plain)
|
||||
if result != plain {
|
||||
t.Error("expected MarkRaw to return the same error for non-ExitError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRaw_Nil(t *testing.T) {
|
||||
result := MarkRaw(nil)
|
||||
if result != nil {
|
||||
t.Error("expected MarkRaw(nil) to return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
|
||||
// Set up PendingNotice
|
||||
origNotice := PendingNotice
|
||||
@@ -148,3 +118,89 @@ func TestGetNotice(t *testing.T) {
|
||||
|
||||
PendingNotice = origNotice
|
||||
}
|
||||
|
||||
// TestErrValidation_LegacyExitErrorShape pins the stage-1 wire contract for
|
||||
// output.ErrValidation: the helper MUST return *output.ExitError (so callers
|
||||
// using errors.As(&exitErr) continue to work), with wire fields restricted
|
||||
// to type+message — no `subtype` emission. The typed envelope shape (which
|
||||
// adds subtype, param, etc.) is reserved for stage-2 per-domain migration.
|
||||
func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
|
||||
err := ErrValidation("bad arg: %s", "x")
|
||||
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("ErrValidation must return *ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != ExitValidation {
|
||||
t.Errorf("Code = %d, want ExitValidation (%d)", exitErr.Code, ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail must be populated")
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "validation")
|
||||
}
|
||||
if exitErr.Detail.Message != "bad arg: x" {
|
||||
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "bad arg: x")
|
||||
}
|
||||
|
||||
// Wire envelope must have only type+message — no subtype field.
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
|
||||
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
|
||||
}
|
||||
errObj, ok := wire["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
|
||||
}
|
||||
if _, hasSubtype := errObj["subtype"]; hasSubtype {
|
||||
t.Errorf("legacy ErrValidation envelope must NOT emit `subtype`; got: %s", buf.String())
|
||||
}
|
||||
if errObj["type"] != "validation" {
|
||||
t.Errorf("envelope error.type = %v, want \"validation\"", errObj["type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for
|
||||
// output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation —
|
||||
// no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork.
|
||||
func TestErrNetwork_LegacyExitErrorShape(t *testing.T) {
|
||||
err := ErrNetwork("conn refused: %s", "10.0.0.1")
|
||||
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("ErrNetwork must return *ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != ExitNetwork {
|
||||
t.Errorf("Code = %d, want ExitNetwork (%d)", exitErr.Code, ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail must be populated")
|
||||
}
|
||||
if exitErr.Detail.Type != "network" {
|
||||
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "network")
|
||||
}
|
||||
if exitErr.Detail.Message != "conn refused: 10.0.0.1" {
|
||||
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "conn refused: 10.0.0.1")
|
||||
}
|
||||
|
||||
// Wire envelope must have only type+message — no subtype field.
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
|
||||
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
|
||||
}
|
||||
errObj, ok := wire["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
|
||||
}
|
||||
if _, hasSubtype := errObj["subtype"]; hasSubtype {
|
||||
t.Errorf("legacy ErrNetwork envelope must NOT emit `subtype`; got: %s", buf.String())
|
||||
}
|
||||
if errObj["type"] != "network" {
|
||||
t.Errorf("envelope error.type = %v, want \"network\"", errObj["type"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// Fine-grained error types (permission, not_found, rate_limit, etc.)
|
||||
// are communicated via the JSON error envelope's "type" field,
|
||||
// not via exit codes.
|
||||
@@ -16,3 +22,48 @@ const (
|
||||
ExitContentSafety = 6 // content safety violation (block mode)
|
||||
ExitConfirmationRequired = 10 // 高风险操作需要 --yes 确认(agent 协议信号)
|
||||
)
|
||||
|
||||
// ExitCodeForCategory maps an errs.Category to the shell exit code.
|
||||
// Multiple categories may share an exit code (Authentication / Authorization /
|
||||
// Config all map to 3), so the relationship is many-to-one.
|
||||
func ExitCodeForCategory(cat errs.Category) int {
|
||||
switch cat {
|
||||
case errs.CategoryValidation:
|
||||
return ExitValidation
|
||||
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig:
|
||||
return ExitAuth
|
||||
case errs.CategoryNetwork:
|
||||
return ExitNetwork
|
||||
case errs.CategoryAPI:
|
||||
return ExitAPI
|
||||
case errs.CategoryPolicy:
|
||||
return ExitContentSafety
|
||||
case errs.CategoryInternal:
|
||||
return ExitInternal
|
||||
case errs.CategoryConfirmation:
|
||||
return ExitConfirmationRequired
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
// ExitCodeOf returns the shell exit code for any error.
|
||||
// - typed errors (*errs.PermissionError, *errs.APIError, ...) → routed by Category
|
||||
// - legacy *output.ExitError → uses its own Code field
|
||||
// - *core.ConfigError → reaches the dispatcher as a legacy
|
||||
// *output.ExitError via cmd/root asExitError (stage 1); the typed
|
||||
// promotion path through internal/errcompat.PromoteConfigError is
|
||||
// reserved for stage 2+.
|
||||
// - untyped → ExitInternal
|
||||
func ExitCodeOf(err error) int {
|
||||
if err == nil {
|
||||
return ExitOK
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return ExitCodeForCategory(errs.CategoryOf(err))
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Code
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
68
internal/output/exitcode_test.go
Normal file
68
internal/output/exitcode_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestExitCodeForCategory(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cat errs.Category
|
||||
want int
|
||||
}{
|
||||
{"validation", errs.CategoryValidation, 2},
|
||||
{"authentication", errs.CategoryAuthentication, 3},
|
||||
{"authorization", errs.CategoryAuthorization, 3},
|
||||
{"config", errs.CategoryConfig, 3},
|
||||
{"network", errs.CategoryNetwork, 4},
|
||||
{"api", errs.CategoryAPI, 1},
|
||||
{"policy", errs.CategoryPolicy, 6},
|
||||
{"internal", errs.CategoryInternal, 5},
|
||||
{"confirmation", errs.CategoryConfirmation, 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ExitCodeForCategory(tc.cat); got != tc.want {
|
||||
t.Errorf("ExitCodeForCategory(%q) = %d, want %d", tc.cat, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeForCategory_UnknownDefaults(t *testing.T) {
|
||||
if got := ExitCodeForCategory(errs.Category("not_a_real_category")); got != ExitInternal {
|
||||
t.Errorf("ExitCodeForCategory(unknown) = %d, want %d (ExitInternal)", got, ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_Nil(t *testing.T) {
|
||||
if got := ExitCodeOf(nil); got != 0 {
|
||||
t.Errorf("ExitCodeOf(nil) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_PermissionError(t *testing.T) {
|
||||
err := &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}}
|
||||
if got := ExitCodeOf(err); got != 3 {
|
||||
t.Errorf("ExitCodeOf(PermissionError) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_APIError(t *testing.T) {
|
||||
err := &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}}
|
||||
if got := ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("ExitCodeOf(APIError) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_UntypedFallsBackToInternal(t *testing.T) {
|
||||
if got := ExitCodeOf(fmt.Errorf("plain")); got != 5 {
|
||||
t.Errorf("ExitCodeOf(plain) = %d, want 5 (untyped → CategoryInternal → ExitInternal)", got)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,19 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
)
|
||||
|
||||
// Lark API generic error code constants.
|
||||
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
|
||||
//
|
||||
// Kept as exported identifiers because external shortcut packages reference
|
||||
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
|
||||
// Subtype / Retryable metadata for each code lives in internal/errclass and
|
||||
// must remain the single source of truth — ClassifyLarkError below resolves
|
||||
// classification through errclass.LookupCodeMeta.
|
||||
const (
|
||||
// Auth: token missing / invalid / expired.
|
||||
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
|
||||
@@ -32,7 +43,6 @@ const (
|
||||
LarkErrRefreshExpired = 20037 // refresh_token expired
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
@@ -58,59 +68,159 @@ const (
|
||||
LarkErrOwnershipMismatch = 231205
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
// errType provides fine-grained classification in the JSON envelope;
|
||||
// exitCode is kept coarse (ExitAuth or ExitAPI).
|
||||
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
switch code {
|
||||
// auth: token missing / invalid / expired
|
||||
case LarkErrTokenMissing, LarkErrTokenBadFmt:
|
||||
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
|
||||
case LarkErrTokenInvalid, LarkErrATInvalid, LarkErrTokenExpired:
|
||||
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
|
||||
// legacyHints supplies the per-code actionable hint string for the legacy
|
||||
// (exitCode, errType, hint) tuple returned by ClassifyLarkError. Hint
|
||||
// composition is not yet centralized in errclass (the canonical
|
||||
// PermissionHint lives there but the long-form per-code hints below are
|
||||
// still wire-stable strings), so this small lookup remains here. Codes
|
||||
// absent from this map fall back to "".
|
||||
var legacyHints = map[int]string{
|
||||
LarkErrTokenMissing: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenBadFmt: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenInvalid: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
|
||||
|
||||
// permission: scope not granted
|
||||
case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission,
|
||||
LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized:
|
||||
return ExitAPI, "permission", "check app permissions or re-authorize: lark-cli auth login"
|
||||
LarkErrAppScopeNotEnabled: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrTokenNoPermission: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrUserScopeInsufficient: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrUserNotAuthorized: "check app permissions or re-authorize: lark-cli auth login",
|
||||
|
||||
// app credential / status
|
||||
case LarkErrAppCredInvalid:
|
||||
return ExitAuth, "config", "check app_id / app_secret: lark-cli config set"
|
||||
case LarkErrAppNotInUse, LarkErrAppUnauthorized:
|
||||
return ExitAuth, "app_status", "app is disabled or not installed — check developer console"
|
||||
LarkErrAppCredInvalid: "check app_id / app_secret: lark-cli config set",
|
||||
LarkErrAppNotInUse: "app is disabled or not installed — check developer console",
|
||||
LarkErrAppUnauthorized: "app is disabled or not installed — check developer console",
|
||||
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrWikiLockContention:
|
||||
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
|
||||
// drive permission-apply specific guidance
|
||||
case LarkErrDrivePermApplyRateLimit:
|
||||
return ExitAPI, "rate_limit",
|
||||
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
|
||||
case LarkErrDrivePermApplyNotApplicable:
|
||||
return ExitAPI, "invalid_params",
|
||||
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
|
||||
|
||||
case LarkErrOwnershipMismatch:
|
||||
return ExitAPI, "ownership_mismatch", buildOwnershipRecoveryHint()
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
LarkErrRateLimit: "please try again later",
|
||||
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
|
||||
LarkErrWikiLockContention: "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes",
|
||||
LarkErrDriveCrossTenantUnit: "operate on source and target within the same tenant and region/unit",
|
||||
LarkErrDriveCrossBrand: "operate on source and target within the same brand environment",
|
||||
LarkErrSheetsFloatImageInvalidDims: "check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height",
|
||||
LarkErrDrivePermApplyRateLimit: "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly",
|
||||
LarkErrDrivePermApplyNotApplicable: "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly",
|
||||
}
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to the legacy
|
||||
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
|
||||
//
|
||||
// Classification (Category / Subtype) is sourced from
|
||||
// errclass.LookupCodeMeta — the single source of truth shipped for both
|
||||
// this legacy adapter and the stage-2+ typed pipeline (errclass.BuildAPIError,
|
||||
// not yet invoked in production). This function adapts that result back to
|
||||
// the legacy tuple shape for callers that still go through *ExitError:
|
||||
//
|
||||
// - exitCode: derived from (Category, Subtype) via legacyExitCode below.
|
||||
// Note this differs from the typed pipeline's ExitCodeForCategory in
|
||||
// two preserved-legacy-quirks: Authorization+permission subtypes return
|
||||
// ExitAPI (legacy treats "permission" as exit 1) and Config returns
|
||||
// ExitAuth (legacy bundles "check app_id/secret" under exit 3).
|
||||
// - errType: legacy short string per (Category, Subtype), mapped by
|
||||
// legacyErrType. Subtypes not present in the legacy taxonomy fall back
|
||||
// to "api_error".
|
||||
// - hint: per-code lookup in legacyHints; "" when absent.
|
||||
//
|
||||
// Unknown codes (LookupCodeMeta returns false) classify as
|
||||
// (ExitAPI, "api_error", "") — matching the prior default.
|
||||
//
|
||||
// Deprecated: ClassifyLarkError belongs to the legacy *output.ExitError
|
||||
// surface that predates the typed error contract introduced by errs/. New
|
||||
// code MUST NOT use it — classify Lark API responses via
|
||||
// internal/errclass.BuildAPIError, which emits a typed *errs.XxxError with
|
||||
// Category, Subtype, and identity-aware extension fields populated at the
|
||||
// source. This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok {
|
||||
return ExitAPI, "api_error", ""
|
||||
}
|
||||
exitCode := legacyExitCode(meta.Category, meta.Subtype)
|
||||
errType := legacyErrType(meta.Category, meta.Subtype)
|
||||
hint := legacyHints[code]
|
||||
// IM ownership mismatch keeps its dynamic recovery hint.
|
||||
if code == LarkErrOwnershipMismatch {
|
||||
hint = buildOwnershipRecoveryHint()
|
||||
}
|
||||
return exitCode, errType, hint
|
||||
}
|
||||
|
||||
// legacyExitCode maps (Category, Subtype) to the legacy *ExitError exit
|
||||
// code. It diverges from ExitCodeForCategory in two places to preserve the
|
||||
// historic wire:
|
||||
//
|
||||
// - CategoryAuthorization with a "permission" subtype (missing_scope,
|
||||
// app_scope_not_enabled, token_no_permission) → ExitAPI (1), not
|
||||
// ExitAuth (3). Legacy considered permission failures a generic API
|
||||
// refusal.
|
||||
// - CategoryConfig → ExitAuth (3). Legacy bundled "check app_id/secret"
|
||||
// under the auth bucket.
|
||||
func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
|
||||
switch cat {
|
||||
case errs.CategoryAuthentication:
|
||||
return ExitAuth
|
||||
case errs.CategoryAuthorization:
|
||||
switch sub {
|
||||
case errs.SubtypeMissingScope,
|
||||
errs.SubtypeUserUnauthorized,
|
||||
errs.SubtypeAppScopeNotApplied,
|
||||
errs.SubtypeTokenScopeInsufficient:
|
||||
return ExitAPI
|
||||
case errs.SubtypeAppUnavailable,
|
||||
errs.SubtypeAppNotInstalled:
|
||||
return ExitAuth
|
||||
}
|
||||
return ExitAPI
|
||||
case errs.CategoryConfig:
|
||||
return ExitAuth
|
||||
}
|
||||
return ExitAPI
|
||||
}
|
||||
|
||||
// legacyErrType maps (Category, Subtype) to the legacy *ExitError errType
|
||||
// string (e.g. "permission", "rate_limit"). Subtypes outside the
|
||||
// historically-classified set fall back to "api_error", matching the prior
|
||||
// default-case behavior.
|
||||
func legacyErrType(cat errs.Category, sub errs.Subtype) string {
|
||||
switch cat {
|
||||
case errs.CategoryAuthentication:
|
||||
return "auth"
|
||||
case errs.CategoryAuthorization:
|
||||
switch sub {
|
||||
case errs.SubtypeMissingScope,
|
||||
errs.SubtypeUserUnauthorized,
|
||||
errs.SubtypeAppScopeNotApplied,
|
||||
errs.SubtypeTokenScopeInsufficient:
|
||||
return "permission"
|
||||
case errs.SubtypeAppUnavailable,
|
||||
errs.SubtypeAppNotInstalled:
|
||||
return "app_status"
|
||||
}
|
||||
return "permission"
|
||||
case errs.CategoryConfig:
|
||||
switch sub {
|
||||
case errs.SubtypeInvalidClient,
|
||||
errs.SubtypeNotConfigured,
|
||||
errs.SubtypeInvalidConfig:
|
||||
return "config"
|
||||
}
|
||||
return "config"
|
||||
case errs.CategoryAPI:
|
||||
switch sub {
|
||||
case errs.SubtypeRateLimit:
|
||||
return "rate_limit"
|
||||
case errs.SubtypeConflict:
|
||||
return "conflict"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "cross_tenant"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "cross_brand"
|
||||
case errs.SubtypeInvalidParameters:
|
||||
return "invalid_parameters"
|
||||
case errs.SubtypeOwnershipMismatch:
|
||||
return "ownership_mismatch"
|
||||
}
|
||||
return "api_error"
|
||||
}
|
||||
return "api_error"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantType: "cross_tenant",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
@@ -44,7 +44,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantType: "invalid_parameters",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
{
|
||||
@@ -58,7 +58,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "drive permission apply not applicable",
|
||||
code: LarkErrDrivePermApplyNotApplicable,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantType: "invalid_parameters",
|
||||
wantHint: "does not accept a permission-apply request",
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user