feat(errs): typed envelope contract for auth-domain errors (#1135)

Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:

  - a fixed nine-category taxonomy on the wire, each mapped to a
    stable shell exit code (authentication/authorization/config = 3,
    network = 4, internal = 5, policy = 6, confirmation = 10)
  - identity-aware detail fields (missing_scopes, requested_scopes,
    granted_scopes, console_url, log_id, retryable, hint) carried
    uniformly on the envelope
  - a single canonical policy envelope at exit 6; the legacy
    auth_error carve-out is retired
  - per-subtype canonical message + hint that preserves Lark's
    diagnostic phrasing and routes recovery to the right actor:
    app developer (app_scope_not_applied), user (missing_scope,
    token_scope_insufficient, user_unauthorized), or tenant admin
    (app_unavailable, app_disabled)
  - wrong app credentials classify as config/invalid_client whether
    surfaced by the Open API endpoint (99991543) or the tenant
    access-token mint endpoint (10003 / 10014), instead of
    collapsing to a transport error or api/unknown
  - local shortcut scope preflight emits the same
    authorization/missing_scope envelope (identity + deterministic
    missing-scope set) used by the post-call permission path, so AI
    consumers read the same structured shape from precheck and from
    server-returned permission denial
  - streaming download/upload failures keep the same network subtype
    split (timeout / TLS / DNS / transport) as the non-stream path
    instead of collapsing every cause to a generic transport failure
  - console_url is carried only on the bot-perspective
    app_scope_not_applied envelope (where the recovery action is
    "developer applies the scope at the developer console"); the
    user-perspective missing_scope envelope drops the field, since
    the only actionable user recovery is `lark-cli auth login --scope`
    and pointing an end user at a console they cannot modify is
    misleading
  - bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
    Type tags to wire 'config' with the original module name kept
    as a metric label

All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
This commit is contained in:
evandance
2026-05-30 19:08:41 +08:00
committed by GitHub
parent 50b3f0a2af
commit 99e314fe0b
100 changed files with 6323 additions and 1700 deletions

View File

@@ -214,7 +214,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("token refresh parse error: %v", err)
return nil, fmt.Errorf("token refresh parse error: %w", err)
}
return data, nil
}

View File

@@ -31,7 +31,7 @@ func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string)
Msg string `json:"msg"`
}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return fmt.Errorf("failed to parse response: %v", err)
return fmt.Errorf("failed to parse response: %w", err)
}
if resp.Code != 0 {
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)

View File

@@ -5,91 +5,130 @@ package client
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// rawAPIJSONHint guides users when an SDK or response body parse fails. The
// most common cause is a non-JSON payload (file download endpoint hit without
// `--output`, or an upstream HTML error page).
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
// 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.
// WrapDoAPIError converts SDK-boundary failures into typed errs.* errors:
// already-typed errors pass through (idempotent), JSON-decode failures
// become InternalError{SubtypeInvalidResponse}, everything else becomes
// NetworkError with a chain-derived subtype (timeout / tls / dns /
// server_error / transport-fallback).
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
var existing *output.ExitError
if errors.As(err, &existing) {
return err
}
// (1) Pass-through any typed errs.* error.
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)
// (2) JSON-decode failure at the SDK boundary → InternalError.
if isJSONDecodeError(err) {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"SDK returned an invalid JSON response: %v", err).
WithHint("%s", rawAPIJSONHint).
WithCause(err)
}
return output.ErrNetwork("API call failed: %v", err)
// (3) Otherwise classify as a network failure with a chain-derived subtype.
return errs.NewNetworkError(classifyNetworkSubtype(err),
"API call failed: %v", err).
WithCause(err)
}
// 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.
// WrapJSONResponseParseError lifts a response-layer JSON parse failure into
// *errs.InternalError{Subtype: SubtypeInvalidResponse}. Empty body, malformed
// JSON, and mid-stream EOFs all collapse to this single shape.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil
}
var e *errs.InternalError
if len(bytes.TrimSpace(body)) == 0 {
return output.ErrWithHint(output.ExitAPI, "api_error",
"API returned an empty JSON response body", rawAPIJSONHint)
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an empty JSON response body")
} else {
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err)
}
if isJSONDecodeError(err, true) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
return e.WithHint("%s", rawAPIJSONHint).WithCause(err)
}
func isJSONDecodeError(err error, allowEOF bool) bool {
// classifyNetworkSubtype maps an error chain to one of the network subtypes,
// falling back to SubtypeNetworkTransport. Timeout is checked first because
// a net.OpError can satisfy net.Error and also wrap a DNS sub-error in
// pathological proxy configurations — we prefer the timeout signal.
func classifyNetworkSubtype(err error) errs.Subtype {
// (a) Timeout — net.Error.Timeout(), plus the SDK's typed timeout
// errors (which do not implement net.Error).
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return errs.SubtypeNetworkTimeout
}
var sdkServerTimeout *larkcore.ServerTimeoutError
if errors.As(err, &sdkServerTimeout) {
return errs.SubtypeNetworkTimeout
}
var sdkClientTimeout *larkcore.ClientTimeoutError
if errors.As(err, &sdkClientTimeout) {
return errs.SubtypeNetworkTimeout
}
// (b) TLS — typed x509 error or message substring fallback.
var x509Err *x509.UnknownAuthorityError
if errors.As(err, &x509Err) {
return errs.SubtypeNetworkTLS
}
msg := err.Error()
if strings.Contains(msg, "x509:") || strings.Contains(msg, "tls:") {
return errs.SubtypeNetworkTLS
}
// (c) DNS — *net.DNSError covers SDK chains coming from net.Dialer.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return errs.SubtypeNetworkDNS
}
// HTTP 5xx classification lives on the call sites with *http.Response
// access (DoStream, HandleResponse); the SDK never surfaces non-504 5xx
// as an error here.
return errs.SubtypeNetworkTransport
}
// isJSONDecodeError reports whether err is a JSON decode failure at the
// SDK boundary, matching both typed json errors and their fmt.Errorf-
// wrapped substring form. io.EOF is intentionally excluded — at the SDK
// boundary an EOF is a transport failure, not a payload-shape failure.
func isJSONDecodeError(err error) bool {
var syntaxErr *json.SyntaxError
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
return true
}
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return true
}
// Substring fallback for fmt.Errorf-wrapped json decode errors that no
// longer satisfy errors.As against the typed json errors. "invalid
// character" alone is too broad (other libraries surface it for non-
// JSON failures), so it is gated on the message also containing "json".
msg := err.Error()
if allowEOF && strings.Contains(msg, "unexpected EOF") {
if strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "cannot unmarshal") {
return true
}
return strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "invalid character") ||
strings.Contains(msg, "cannot unmarshal")
lower := strings.ToLower(msg)
return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json")
}

View File

@@ -4,173 +4,312 @@
package client
import (
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
t.Fatal("expected error")
}
// ─────────────────────────────────────────────────────────────────────────────
// WrapDoAPIError: typed error contract.
//
// Pass-through: any error carrying *errs.Problem (detected via ProblemOf).
// JSON decode failures → *errs.InternalError{Subtype: invalid_response}.
// Otherwise → *errs.NetworkError with one of: timeout / tls / dns /
// server_error / transport (fallback).
// ─────────────────────────────────────────────────────────────────────────────
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
// timeoutNetError implements net.Error with Timeout() == true. Used to exercise
// the timeout branch of the network classifier without depending on a live
// transport.
type timeoutNetError struct{}
func (timeoutNetError) Error() string { return "i/o timeout" }
func (timeoutNetError) Timeout() bool { return true }
func (timeoutNetError) Temporary() bool { return true }
// TestWrapDoAPIError_SyntaxError_ReturnsInternalError pins that a raw
// *json.SyntaxError from the SDK boundary surfaces as an *errs.InternalError
// with Subtype=invalid_response — replacing the legacy api_error envelope.
func TestWrapDoAPIError_SyntaxError_ReturnsInternalError(t *testing.T) {
got := WrapDoAPIError(&json.SyntaxError{Offset: 1})
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T (%v)", got, got)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
if ie.Category != errs.CategoryInternal {
t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
}
}
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
if err == nil {
t.Fatal("expected error")
// TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError pins the second
// json-decode error variant (type-mismatch decoding) routes through the same
// invalid_response branch — not the network fallback.
func TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError(t *testing.T) {
got := WrapDoAPIError(&json.UnmarshalTypeError{Value: "string", Type: nil})
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T", got)
}
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 || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
}
}
// 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)
}
// TestWrapDoAPIError_Timeout pins that an SDK transport error whose chain
// carries a net.Error with Timeout()==true classifies as
// NetworkError{Subtype: timeout}. Covers the E2E timeout scenario
// (HTTPS_PROXY pointing at a non-routable address).
func TestWrapDoAPIError_Timeout(t *testing.T) {
got := WrapDoAPIError(&net.OpError{Op: "dial", Net: "tcp", Err: timeoutNetError{}})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T (%v)", got, got)
}
if ne.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
}
if ne.Category != errs.CategoryNetwork {
t.Errorf("Category = %v, want %v", ne.Category, errs.CategoryNetwork)
}
}
// 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)
// TestWrapDoAPIError_TLS pins that an x509.UnknownAuthorityError classifies
// as NetworkError{Subtype: tls}.
func TestWrapDoAPIError_TLS(t *testing.T) {
got := WrapDoAPIError(&x509.UnknownAuthorityError{})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
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)
if ne.Subtype != errs.SubtypeNetworkTLS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
}
}
// 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"},
// TestWrapDoAPIError_TLS_HandshakeMessage covers the message-substring fallback
// for TLS errors that don't surface as a typed x509 error.
func TestWrapDoAPIError_TLS_HandshakeMessage(t *testing.T) {
got := WrapDoAPIError(errors.New("remote error: tls: handshake failure"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
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)
}
})
if ne.Subtype != errs.SubtypeNetworkTLS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
}
}
// 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) {
// TestWrapDoAPIError_DNS pins that a *net.DNSError classifies as
// NetworkError{Subtype: dns}.
func TestWrapDoAPIError_DNS(t *testing.T) {
got := WrapDoAPIError(&net.DNSError{Name: "example.invalid"})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkDNS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkDNS)
}
}
// TestWrapDoAPIError_SDKServerTimeout pins that a *larkcore.ServerTimeoutError
// (504 Gateway Timeout surfaced by the SDK as a typed error rather than an
// *http.Response) classifies as timeout — upstream took too long to respond.
func TestWrapDoAPIError_SDKServerTimeout(t *testing.T) {
got := WrapDoAPIError(&larkcore.ServerTimeoutError{})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
}
}
// TestWrapDoAPIError_SDKClientTimeout pins that a *larkcore.ClientTimeoutError
// (client-side request timeout the SDK reports without satisfying net.Error)
// classifies as timeout.
func TestWrapDoAPIError_SDKClientTimeout(t *testing.T) {
got := WrapDoAPIError(&larkcore.ClientTimeoutError{})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
}
}
// TestWrapDoAPIError_UnknownCause_FallsBackToTransport pins the fallback:
// when none of the specific causes match, NetworkError uses the generic
// transport subtype.
func TestWrapDoAPIError_UnknownCause_FallsBackToTransport(t *testing.T) {
got := WrapDoAPIError(errors.New("connection reset by peer"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Subtype = %v, want %v (fallback)", ne.Subtype, errs.SubtypeNetworkTransport)
}
}
// TestWrapDoAPIError_PassThrough_TypedError pins that any typed *errs.* error
// (carrying an embedded Problem) passes through unchanged — same pointer
// identity, no re-classification. This is the load-bearing invariant for
// resolveAccessToken returning *errs.AuthenticationError through DoSDKRequest.
func TestWrapDoAPIError_PassThrough_TypedError(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}},
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing, Message: "no token"}},
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Message: "no scope"}},
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport, Message: "transport"}},
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, Message: "sdk"}},
}
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)
t.Fatalf("expected identity pass-through, 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)
// TestWrapDoAPIError_Nil pins that nil in stays nil out (no allocation, no
// panic). Callers rely on this when the SDK returns success.
func TestWrapDoAPIError_Nil(t *testing.T) {
if got := WrapDoAPIError(nil); got != nil {
t.Errorf("WrapDoAPIError(nil) = %v, want nil", got)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WrapJSONResponseParseError: typed error contract.
//
// All response-layer parse failures (empty body, malformed JSON, mid-stream
// read failures that surface as parse errors) collapse to a single
// *errs.InternalError{Subtype: invalid_response}. The rawAPIJSONHint is
// preserved on Problem.Hint so users still get the "may have returned an
// empty or non-standard body, rerun with --output" guidance.
// ─────────────────────────────────────────────────────────────────────────────
// TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError pins the
// new shape for malformed JSON bodies — replaces the legacy api_error path.
func TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError(t *testing.T) {
got := WrapJSONResponseParseError(&json.SyntaxError{Offset: 1}, []byte("{ malformed"))
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T", got)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
}
if ie.Hint != rawAPIJSONHint {
t.Errorf("Hint = %q, want rawAPIJSONHint preserved", ie.Hint)
}
}
// TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError pins that
// empty / whitespace-only response bodies also surface as invalid_response,
// not as a network error. Endpoints returning only "\n" or "" trigger this.
func TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError(t *testing.T) {
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("body=%q: expected *errs.InternalError, got %T", body, got)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("body=%q: Subtype = %v, want invalid_response", body, ie.Subtype)
}
}
}
// TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError pins that
// io.ErrUnexpectedEOF mid-decode also surfaces as invalid_response — keeps
// the legacy non-empty-body decode-failure semantics under the new typed
// envelope.
func TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError(t *testing.T) {
got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T", got)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want invalid_response", ie.Subtype)
}
}
// TestWrapJSONResponseParseError_Nil pins nil pass-through.
func TestWrapJSONResponseParseError_Nil(t *testing.T) {
if got := WrapJSONResponseParseError(nil, []byte("anything")); got != nil {
t.Errorf("WrapJSONResponseParseError(nil, ...) = %v, want nil", got)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Cross-cutting: existing tests already in this file (kept and adjusted below).
// ─────────────────────────────────────────────────────────────────────────────
// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy
// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier
// and is therefore not pass-through — only typed *errs.* values are.
// Legacy values fall through to the network/JSON branches based on their
// inner shape.
func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) {
// An *output.ErrAuth has no embedded Problem and no JSON-decode chain;
// it routes to the network branch with the fallback transport subtype.
got := WrapDoAPIError(output.ErrAuth("no access token available for user"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got)
}
// Sanity: not silently re-classified as JSON-decode.
var ie *errs.InternalError
if errors.As(got, &ie) {
t.Fatalf("expected NetworkError, got InternalError %v", ie)
}
}
// TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins pins that a typed
// *errs.AuthenticationError wrapping a JSON syntax error in its chain still
// passes through as the outer type — we never re-classify a typed problem
// carrier just because the chain contains a json.SyntaxError. Forward-compat
// for credential chain errors that bundle a parse failure as Cause.
func TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins(t *testing.T) {
jsonErr := &json.SyntaxError{Offset: 1}
outer := &errs.AuthenticationError{
Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired, Message: "expired"},
Cause: jsonErr,
}
got := WrapDoAPIError(outer)
if got != outer {
t.Fatalf("expected outer typed error to win, got %T %v", got, got)
}
}
// TestWrapDoAPIError_MessageContainsCause pins that the wrapped error's
// message is carried into Problem.Message so logs / debugging retain the
// underlying cause string.
func TestWrapDoAPIError_MessageContainsCause(t *testing.T) {
raw := errors.New("dial tcp 10.0.0.1:443: i/o timeout")
got := WrapDoAPIError(raw)
if !strings.Contains(got.Error(), "i/o timeout") {
t.Errorf("Error() = %q, want to contain underlying cause", got.Error())
}
}

View File

@@ -18,8 +18,12 @@ import (
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
@@ -48,16 +52,38 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
if err != nil {
var unavailableErr *credential.TokenUnavailableError
if errors.As(err, &unavailableErr) {
return "", output.ErrAuth("no access token available for %s", as)
return "", newTokenMissingError(as, unavailableErr)
}
// NeedAuthorizationError from the credential chain (e.g. UAT refresh
// returned need_user_authorization) must surface as typed
// AuthenticationError. Without this, WrapDoAPIError would wrap the
// raw err as NetworkError, and cmd/root.go's outer-typed gate would
// then skip PromoteAuthError — leaving the user with exit 4 and no
// auth-login hint instead of exit 3 typed authentication.
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return "", errcompat.PromoteAuthError(needAuthErr)
}
return "", err
}
if result.Token == "" {
return "", output.ErrAuth("no access token available for %s", as)
return "", newTokenMissingError(as, nil)
}
return result.Token, nil
}
// newTokenMissingError builds the typed *errs.AuthenticationError that
// resolveAccessToken returns when no usable token is available for the
// requested identity. cause is the underlying credential-chain error (or nil
// for the defensive empty-token branch) and is preserved for errors.Is /
// errors.Unwrap traversal without being serialized on the wire.
func newTokenMissingError(as core.Identity, cause error) error {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no access token available for %s", as).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(cause)
}
// buildApiReq converts a RawApiRequest into SDK types and collects
// request-specific options (ExtraOpts, URL-based headers).
// Auth is handled separately by DoSDKRequest.
@@ -93,14 +119,14 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
// 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
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
// each one remembering to wrap. Today that wire shape is still the legacy
// *output.ExitError envelope (network / api_error); future 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.
// resolveAccessToken's missing-credential paths, or a typed *errs.*) 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
@@ -177,7 +203,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
if err != nil {
cancel()
return nil, output.ErrNetwork("stream request failed: %s", err)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "stream request failed: %s", err).WithCause(err)
}
// Apply headers from opts
@@ -195,7 +221,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
return nil, output.ErrNetwork("stream request failed: %s", err)
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
@@ -204,31 +230,32 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
defer resp.Body.Close()
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(errBody))
if msg != "" {
err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
attachStreamLogID(err, resp.Header)
return nil, err
subtype := errs.SubtypeNetworkTransport
if resp.StatusCode >= 500 {
subtype = errs.SubtypeNetworkServer
}
err := output.ErrNetwork("HTTP %d", resp.StatusCode)
attachStreamLogID(err, resp.Header)
return nil, err
var netErr *errs.NetworkError
if msg != "" {
netErr = errs.NewNetworkError(subtype, "HTTP %d: %s", resp.StatusCode, msg)
} else {
netErr = errs.NewNetworkError(subtype, "HTTP %d", resp.StatusCode)
}
netErr = netErr.WithCode(resp.StatusCode)
if logID := streamLogID(resp.Header); logID != "" {
netErr = netErr.WithLogID(logID)
}
return nil, netErr
}
return resp, nil
}
func attachStreamLogID(err *output.ExitError, header http.Header) {
if err == nil || err.Detail == nil {
return
}
func streamLogID(header http.Header) string {
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
}
if logID == "" {
return
}
err.Detail.Detail = map[string]any{"log_id": logID}
return logID
}
type cancelOnCloseBody struct {
@@ -256,10 +283,10 @@ func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error)
pathKey := strings.TrimPrefix(segment, ":")
pathValue, ok := req.PathParams[pathKey]
if !ok {
return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
}
if pathValue == "" {
return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "empty path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
}
pathSegs = append(pathSegs, url.PathEscape(pathValue))
}
@@ -285,7 +312,7 @@ func buildStreamBody(body interface{}) (io.Reader, string, error) {
default:
payload, err := json.Marshal(typed)
if err != nil {
return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err)
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "failed to encode request body: %s", err).WithCause(err)
}
return bytes.NewReader(payload), "application/json", nil
}
@@ -306,11 +333,9 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
// 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
// for everything else) instead of a bare fmt.Errorf — otherwise 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.
// "Error: ..." line and bypass the JSON stderr envelope contract.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
@@ -464,23 +489,23 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
}
// 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.
// CheckResponse inspects a Lark API response for business-level errors (non-zero code)
// and routes the result through errclass.BuildAPIError so the wire envelope carries
// the canonical Category/Subtype + identity-aware extension fields (MissingScopes,
// ConsoleURL, etc.) for known Lark codes; unknown codes still surface as
// *errs.APIError{Subtype: unknown}.
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil
}
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
if code, _ := util.ToFloat64(resultMap["code"]); code == 0 {
return nil
}
larkCode := int(code)
msg, _ := resultMap["msg"].(string)
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
cc := errclass.ClassifyContext{Identity: string(identity)}
if c != nil && c.Config != nil {
cc.Brand = string(c.Config.Brand)
cc.AppID = c.Config.AppID
}
return errclass.BuildAPIError(resultMap, cc)
}

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
@@ -18,6 +19,8 @@ import (
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
@@ -428,6 +431,39 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
}
}
// TestDoStream_TransportFailureSplitsSubtype pins that a streaming-request
// transport failure routes through classifyNetworkSubtype rather than emitting
// a hardcoded SubtypeNetworkTransport for every cause. Concretely: a DNS
// failure must surface as SubtypeNetworkDNS so downstream agents can react
// (retry / give up / show recovery hint) without parsing the message text.
// Pre-fix, DoStream collapsed every httpClient.Do failure to NetworkTransport,
// erasing the timeout / TLS / DNS distinctions the SDK path already preserved.
func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return nil, &net.DNSError{Err: "no such host", Name: "nowhere.invalid"}
})
ac := &APIClient{
HTTP: &http.Client{Transport: rt},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/files/file_token/download",
}, core.AsBot)
if err == nil {
t.Fatal("expected DNS error from DoStream transport, got nil")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
}
if netErr.Subtype != errs.SubtypeNetworkDNS {
t.Errorf("Subtype = %q, want %q (DNS failures must not be classified as generic transport)", netErr.Subtype, errs.SubtypeNetworkDNS)
}
}
// failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{}
@@ -436,17 +472,93 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
}
// 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.
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
// the missing-token path of resolveAccessToken returns the typed
// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy
// *output.ExitError envelope.
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.resolveAccessToken(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error when no token available, got nil")
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err)
}
if authErr.Category != errs.CategoryAuthentication {
t.Errorf("Category = %v, want %v", authErr.Category, errs.CategoryAuthentication)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
}
// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to
// exercise the P1 regression path: a credential chain that signals
// "user must re-authorize" must surface as typed AuthenticationError, not
// fall through to the generic err return which WrapDoAPIError would then
// wrap as NetworkError (the outer-typed dispatcher gate would then skip
// PromoteAuthError and the user would see exit 4 with no auth-login hint).
type needAuthTokenResolver struct {
userOpenID string
}
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID}
}
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
// is the codex P1 regression test: without this branch, the credential
// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError
// would mis-classify it as NetworkError.
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.resolveAccessToken(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error when credential chain signals need_user_authorization, got nil")
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
if !strings.Contains(authErr.Message, "need_user_authorization") {
t.Errorf("Message must contain the marker 'need_user_authorization' (invariant), got %q", authErr.Message)
}
// Underlying NeedAuthorizationError preserved in Cause chain so
// existing errors.As(&NeedAuthorizationError{}) consumers still match.
var needErr *internalauth.NeedAuthorizationError
if !errors.As(err, &needErr) {
t.Errorf("NeedAuthorizationError not preserved in Cause chain")
}
}
// TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError pins the
// end-to-end invariant codex caught the day this PR landed: when
// resolveAccessToken fails because no token is cached, DoSDKRequest must
// surface that as a typed *errs.AuthenticationError — 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) {
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
@@ -461,22 +573,20 @@ func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
if err == nil {
t.Fatal("expected auth error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T (%v) — WrapDoAPIError must pass typed *errs.* through unchanged", err, 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)
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
}
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
// SDK transport errors get the network classification via WrapDoAPIError.
// SDK transport errors get the typed 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.
// *url.Error, which the wrap classifier reaches as the transport-error
// fallback (no specific subtype matches — falls back to transport).
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
@@ -491,25 +601,29 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
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)
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
if netErr.Category != errs.CategoryNetwork {
t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
if netErr.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Subtype = %v, want %v", netErr.Subtype, errs.SubtypeNetworkTransport)
}
// io.ErrUnexpectedEOF round-tripping through net/http does not satisfy
// any of the specific cause checks; subtype falls back to transport.
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("ExitCodeOf = %d, want %d (network)", output.ExitCodeOf(err), output.ExitNetwork)
}
}
// 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.
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
// malformed JSON response bodies: WrapJSONResponseParseError emits
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is
// "internal", not the legacy "api_error".
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
@@ -529,17 +643,20 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
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)
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
if intErr.Category != errs.CategoryInternal {
t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if exitErr.Detail.Hint != rawAPIJSONHint {
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
if intErr.Hint != rawAPIJSONHint {
t.Errorf("Hint = %q, want rawAPIJSONHint preserved", intErr.Hint)
}
if output.ExitCodeOf(err) != output.ExitInternal {
t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal)
}
}

View File

@@ -11,10 +11,10 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
@@ -41,12 +41,11 @@ func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
if detail["log_id"] != "202605270003" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
if netErr.LogID != "202605270003" {
t.Fatalf("LogID = %q, want %q", netErr.LogID, "202605270003")
}
}

View File

@@ -14,6 +14,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -52,12 +53,10 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
}
check := opts.CheckError
if check == nil {
// 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.
// Default check routes through BuildAPIError, producing typed
// *errs.PermissionError / AuthenticationError / etc. A zero-value
// *APIClient is safe here because BuildAPIError gracefully degrades
// identity-aware fields (ConsoleURL etc.) when AppID is empty.
check = func(r interface{}, id core.Identity) error {
return (&APIClient{}).CheckResponse(r, id)
}
@@ -65,9 +64,20 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
// instead of falling through to the binary-save path.
// 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error).
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500)
return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
// JSON responses: always check for business errors before saving.
@@ -102,7 +112,9 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
// Non-JSON (binary) responses.
if opts.JqExpr != "" {
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--jq requires a JSON response (got Content-Type: %s)", ct).
WithParam("--jq")
}
if opts.OutputPath != "" {
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
@@ -111,7 +123,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
// No --output: auto-save with derived filename.
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
return classifySaveErr(err)
}
fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct)
output.PrintJson(opts.Out, meta)
@@ -121,12 +133,23 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
meta, err := SaveResponse(fio, resp, path)
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
return classifySaveErr(err)
}
output.PrintJson(w, meta)
return nil
}
// classifySaveErr routes a SaveResponse error to the right typed shape.
// Path-validation failures are caller-induced (an unsafe --output path),
// so they surface as ValidationError on --output. Mkdir / write failures
// are local I/O issues classified as InternalError with SubtypeFileIO.
func classifySaveErr(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--output")
}
return errs.NewInternalError(errs.SubtypeFileIO, "save response: %v", err).WithCause(err)
}
// ── JSON helpers ──
// IsJSONContentType reports whether the Content-Type header indicates a JSON response.
@@ -160,13 +183,13 @@ func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string)
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return nil, fmt.Errorf("unsafe output path: %s", err)
return nil, fmt.Errorf("unsafe output path: %w", err)
case errors.As(err, &me):
return nil, fmt.Errorf("create directory: %s", err)
return nil, fmt.Errorf("create directory: %w", err)
case errors.As(err, &we):
return nil, fmt.Errorf("cannot write file: %s", err)
return nil, fmt.Errorf("cannot write file: %w", err)
default:
return nil, fmt.Errorf("cannot write file: %s", err)
return nil, fmt.Errorf("cannot write file: %w", err)
}
}
@@ -225,12 +248,3 @@ func mimeToExt(ct string) string {
return ".bin"
}
}
// httpExitCode maps HTTP status ranges to CLI exit codes:
// 5xx → ExitNetwork (server error), 4xx → ExitAPI (client error).
func httpExitCode(status int) int {
if status >= 500 {
return output.ExitNetwork
}
return output.ExitAPI
}

View File

@@ -15,6 +15,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -294,9 +295,12 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) {
if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") {
t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
@@ -312,9 +316,12 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") {
t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code)
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Errorf("expected *errs.NetworkError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
}
}

View File

@@ -5,7 +5,6 @@ package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"strings"
@@ -13,13 +12,13 @@ import (
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
// Factory holds shared dependencies injected into every command.
@@ -129,11 +128,18 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
}
list := strings.Join(supported, ", ")
if f.IdentityAutoDetected {
return output.ErrValidation(
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s",
as, list, supported[0])
base := errs.NewValidationError(errs.SubtypeInvalidArgument,
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s",
as, list).
WithParam("--as")
if len(supported) > 0 {
return base.WithHint("use --as %s", supported[0])
}
return base
}
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--as %s is not supported, this command only supports: %s", as, list).
WithParam("--as")
}
// ResolveStrictMode returns the effective strict mode by reading
@@ -161,9 +167,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return output.ErrWithHint(output.ExitValidation, "command_denied",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()).
WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
return nil
}
@@ -202,9 +208,9 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
}, nil
}
// RequireBuiltinCredentialProvider returns a structured error (exit 2, code
// "external_provider") when an extension provider is actively managing credentials.
// Intended for use as PersistentPreRunE on the auth and config parent commands.
// RequireBuiltinCredentialProvider returns a typed validation error when an
// extension provider is actively managing credentials. Intended for use as
// PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
@@ -220,10 +226,7 @@ func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command
if provName == "" {
return nil
}
return output.ErrWithHint(
output.ExitValidation,
"external_provider",
fmt.Sprintf("%q is not supported: credentials are provided externally and do not support interactive management", command),
"If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.",
)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%q is not supported: credentials are provided externally and do not support interactive management", command).
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
@@ -179,14 +180,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
if err == nil {
t.Fatal("expected error")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), "resolved identity") {
t.Errorf("expected 'resolved identity' in error, got: %v", err)
if !strings.Contains(ve.Message, "resolved identity") {
t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message)
}
if !strings.Contains(err.Error(), "hint: use --as bot") {
t.Errorf("expected hint in error, got: %v", err)
if !strings.Contains(ve.Hint, "use --as bot") {
t.Errorf("expected hint to suggest --as bot, got: %v", ve.Hint)
}
}
@@ -422,20 +424,17 @@ func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider")
}
if exitErr.Detail.Message == "" {
if ve.Message == "" {
t.Error("expected non-empty message")
}
if exitErr.Detail.Hint == "" {
if ve.Hint == "" {
t.Error("expected non-empty hint")
}
}

View File

@@ -12,13 +12,44 @@ import (
"net/http"
"sync"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keychain"
extcred "github.com/larksuite/cli/extension/credential"
)
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
// canonical typed error. The TAT mint endpoint reports invalid credentials
// with two distinct codes:
//
// - 10003: bad app_id format or non-existent app_id ("invalid param")
// - 10014: invalid app_secret ("app secret invalid")
//
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
// the configured credentials cannot mint a tenant access token. 10014 is
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
// 10003 is NOT globally mapped because in other Lark endpoints it carries
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
// the override stays local to this TAT call site instead of leaking into the
// shared codemeta table.
func classifyTATResponseCode(code int, msg, brand, appID string) error {
if code == 10003 {
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
}
return errclass.BuildAPIError(map[string]any{
"code": code,
"msg": msg,
}, errclass.ClassifyContext{
Brand: brand,
AppID: appID,
})
}
// DefaultAccountProvider resolves account from config.json via keychain.
type DefaultAccountProvider struct {
keychain func() keychain.KeychainAccess
@@ -170,7 +201,7 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg)
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
}
return &TokenResult{Token: result.TenantAccessToken}, nil
}

View File

@@ -4,7 +4,10 @@
package credential
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
@@ -15,3 +18,68 @@ func TestDefaultTokenProvider_Dispatches(t *testing.T) {
func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{}
}
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
// which from the user's perspective is the same actionable failure as 10014
// ("app secret invalid") — both mean the configured credentials cannot mint a
// tenant access token. The global codemeta intentionally does not map 10003
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
// API uses it for permission denied), so the override is local to this site.
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10003")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
if cfgErr.Hint == "" {
t.Error("Hint must be non-empty so the user gets a recovery action")
}
}
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
// goes through the global BuildAPIError path (codemeta entry) so the override
// for 10003 does not regress the existing mapping.
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10014")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10014 {
t.Errorf("Code = %d, want 10014", cfgErr.Code)
}
}
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
// the credential set fall through to the generic BuildAPIError fallback
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for unmapped code")
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
}
}

View File

@@ -20,6 +20,7 @@ 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
LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm
}
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
@@ -35,7 +36,7 @@ type ClassifyContext struct {
// Network → *errs.NetworkError
// Internal → *errs.InternalError
// Confirmation → *errs.ConfirmationRequiredError
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
// default (CategoryAPI) → *errs.APIError (catch-all for classified Lark business errors)
//
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
// CategoryAPI + SubtypeUnknown.
@@ -80,6 +81,17 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
LogID: logID,
Retryable: meta.Retryable,
}
// Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted
// universally before the category switch so every classified typed
// error surfaces it when present. The remaining contents of resp["error"]
// (permission_violations.subject, data.challenge_url, data.hint) are
// either lifted into category-specific typed extension fields below or
// intentionally dropped as redundant with the typed envelope.
if errBlock, ok := resp["error"].(map[string]any); ok {
if ts, _ := errBlock["troubleshooter"].(string); ts != "" {
base.Troubleshooter = ts
}
}
switch meta.Category {
case errs.CategoryAuthorization:
@@ -87,7 +99,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
case errs.CategoryAuthentication:
return &errs.AuthenticationError{Problem: base}
case errs.CategoryConfig:
return &errs.ConfigError{Problem: base}
return buildConfigError(base)
case errs.CategoryPolicy:
return buildSecurityPolicyError(base, resp)
case errs.CategoryValidation:
@@ -97,9 +109,39 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
case errs.CategoryInternal:
return &errs.InternalError{Problem: base}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{Problem: base}
// Risk + Action are non-omitempty wire fields. Derive from
// CodeMeta when available; otherwise emit RiskUnknown +
// ctx.LarkCmd placeholder so the envelope is never wire-invalid.
risk := meta.Risk
if risk == "" {
risk = errs.RiskUnknown
}
action := meta.Action
if action == "" {
action = cc.LarkCmd
}
if action == "" {
action = "unknown"
}
return &errs.ConfirmationRequiredError{
Problem: base,
Risk: risk,
Action: action,
}
case errs.CategoryAPI:
return &errs.APIError{Problem: base}
default:
return &errs.APIError{Problem: base, Detail: resp}
// Fail closed: an unrecognized Category routes to InternalError
// instead of emitting an empty Problem on the wire.
return &errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeSDKError,
Code: base.Code,
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
LogID: base.LogID,
},
}
}
}
@@ -149,7 +191,7 @@ func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.Securit
// 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.
// the two collapse once the auth transport adopts BuildAPIError directly.
func isHTTPSURL(rawURL string) bool {
if rawURL == "" {
return false
@@ -167,47 +209,142 @@ func stringFromAny(v any) string {
return s
}
// buildConfigError enriches a typed ConfigError with the canonical
// per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) *errs.ConfigError {
p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p}
}
// ConfigHint returns the canonical per-subtype recovery hint for a typed
// ConfigError emitted via BuildAPIError.
func ConfigHint(subtype errs.Subtype) string {
switch subtype {
case errs.SubtypeInvalidClient:
return "run `lark-cli config init` to set valid app_id and app_secret"
case errs.SubtypeNotConfigured:
return "run `lark-cli config init` to set up app_id and app_secret"
case errs.SubtypeInvalidConfig:
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
}
return ""
}
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{
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{
Problem: p,
MissingScopes: missing,
Identity: identity,
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
}
// ConsoleURL is the developer-console deep-link an app developer follows to
// apply for a missing scope. That action only resolves SubtypeAppScopeNotApplied,
// which is bot-perspective. The other authorization subtypes route to a
// different actor: SubtypeMissingScope / SubtypeTokenScopeInsufficient /
// SubtypeUserUnauthorized recover via `lark-cli auth login`; SubtypeAppUnavailable
// / SubtypeAppDisabled require tenant admin. Carrying ConsoleURL on those
// envelopes is dead weight and risks pointing an end user at a console they
// cannot modify; the URL is still computed so the hint composer can use it
// where appropriate.
if p.Subtype == errs.SubtypeAppScopeNotApplied {
permErr.ConsoleURL = consoleURL
}
return permErr
}
// 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.
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
// typed PermissionError, preserving the Lark official-API phrasing
// ("access denied" / "unauthorized" / "token has no permission") and
// enhancing it with CLI context (app ID, missing scope list). Subtypes
// outside the known set fall through to fallback so the upstream message
// is preserved.
func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []string, fallback string) string {
switch subtype {
case errs.SubtypeAppScopeNotApplied:
if len(missing) > 0 {
scopes := strings.Join(missing, ", ")
if appID != "" {
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s): %s", appID, scopes)
}
return fmt.Sprintf("access denied: app has not applied for the required scope(s): %s", scopes)
}
if appID != "" {
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s)", appID)
}
return "access denied: app has not applied for the required scope(s)"
case errs.SubtypeMissingScope:
if len(missing) > 0 {
return fmt.Sprintf("unauthorized: user authorization does not cover the required scope(s): %s", strings.Join(missing, ", "))
}
return "unauthorized: user authorization does not cover the required scope"
case errs.SubtypeTokenScopeInsufficient:
return "token has no permission for this operation; required scope is missing"
case errs.SubtypeUserUnauthorized:
return "access denied for this operation; possible causes: missing scope, missing user authorization, or restricted by tenant policy"
case errs.SubtypeAppUnavailable:
if appID != "" {
return fmt.Sprintf("unauthorized app: app %s is not properly installed in this tenant", appID)
}
return "unauthorized app: app is not properly installed in this tenant"
case errs.SubtypeAppDisabled:
if appID != "" {
return fmt.Sprintf("app %s is not in use in this tenant (currently disabled)", appID)
}
return "app is not in use in this tenant (currently disabled)"
case errs.SubtypePermissionDenied:
return "user lacks permission for the requested resource"
}
return fallback
}
// PermissionHint returns the canonical per-subtype recovery hint for a typed
// PermissionError. The hint distinguishes authorization subtypes routing
// to different recovery paths: developer console for app_scope_not_applied,
// user re-login for missing_scope / token_scope_insufficient / user_unauthorized,
// and tenant admin for app_unavailable / app_disabled. The subtype
// argument is the primary discriminator; identity is retained for the
// generic permission_denied fallback so callers that do not yet route on
// subtype still get a sensible hint.
//
// 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"
func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string {
switch subtype {
case errs.SubtypeAppScopeNotApplied:
if consoleURL != "" {
return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)
}
return "ensure the calling identity has been granted the required scopes"
return "the app developer must apply for the required scope(s) at the developer console"
case errs.SubtypeMissingScope:
if len(missing) > 0 {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
}
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
case errs.SubtypeTokenScopeInsufficient:
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
case errs.SubtypeUserUnauthorized:
return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy"
case errs.SubtypeAppUnavailable:
return "ask the tenant admin to check the app's install status in the Lark admin console"
case errs.SubtypeAppDisabled:
return "ask the tenant admin to re-enable the app in the Lark admin console"
case errs.SubtypePermissionDenied:
who := "this user"
if identity == "bot" {
who = "this bot"
}
return fmt.Sprintf("check the resource owner has granted access to %s", who)
}
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)
return "check the calling identity has the required scope"
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// TestBuildAPIError_CategoryConfirmationFillsRiskAction pins fail-closed
// behaviour: a code mapped to CategoryConfirmation MUST yield a
// ConfirmationRequiredError whose Risk + Action are non-empty even when the
// CodeMeta itself carries no Risk/Action hints. Risk falls back to
// RiskUnknown; Action falls back to ctx.LarkCmd.
func TestBuildAPIError_CategoryConfirmationFillsRiskAction(t *testing.T) {
const stubCode = 99999991
codeMeta[stubCode] = CodeMeta{
Category: errs.CategoryConfirmation,
Subtype: errs.SubtypeConfirmationRequired,
}
t.Cleanup(func() { delete(codeMeta, stubCode) })
resp := map[string]any{"code": stubCode, "msg": "confirmation required"}
ctx := ClassifyContext{
Brand: "feishu",
AppID: "cli_test",
Identity: "user",
LarkCmd: "drive +delete",
}
err := BuildAPIError(resp, ctx)
var confirmErr *errs.ConfirmationRequiredError
if !errors.As(err, &confirmErr) {
t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err)
}
if confirmErr.Risk == "" {
t.Error("Risk empty; arm must fail-closed with RiskUnknown")
}
if confirmErr.Risk != errs.RiskUnknown {
t.Errorf("Risk = %q, want %q (CodeMeta carried no Risk hint)",
confirmErr.Risk, errs.RiskUnknown)
}
if confirmErr.Action == "" {
t.Error("Action empty; arm must fail-closed with command name from ClassifyContext")
}
if confirmErr.Action != "drive +delete" {
t.Errorf("Action = %q, want %q (ctx.LarkCmd fallback)",
confirmErr.Action, "drive +delete")
}
}
// TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints pins that when
// CodeMeta carries explicit Risk + Action, the dispatcher uses them rather
// than falling back to RiskUnknown / ctx.LarkCmd.
func TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints(t *testing.T) {
const stubCode = 99999992
codeMeta[stubCode] = CodeMeta{
Category: errs.CategoryConfirmation,
Subtype: errs.SubtypeConfirmationRequired,
Risk: errs.RiskHighRiskWrite,
Action: "wiki:delete-space",
}
t.Cleanup(func() { delete(codeMeta, stubCode) })
resp := map[string]any{"code": stubCode, "msg": "confirmation required"}
ctx := ClassifyContext{LarkCmd: "drive +delete"}
err := BuildAPIError(resp, ctx)
var confirmErr *errs.ConfirmationRequiredError
if !errors.As(err, &confirmErr) {
t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err)
}
if confirmErr.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q (CodeMeta hint should win)",
confirmErr.Risk, errs.RiskHighRiskWrite)
}
if confirmErr.Action != "wiki:delete-space" {
t.Errorf("Action = %q, want %q (CodeMeta hint should win)",
confirmErr.Action, "wiki:delete-space")
}
}
// TestBuildAPIError_UnknownCategoryRoutesToInternalError pins fail-closed
// behaviour: an unrecognized Category routes to InternalError instead of
// emitting an empty Problem on the wire.
func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) {
const stubCode = 99999993
codeMeta[stubCode] = CodeMeta{
Category: errs.Category("totally_unknown_category"),
Subtype: errs.SubtypeUnknown,
}
t.Cleanup(func() { delete(codeMeta, stubCode) })
resp := map[string]any{"code": stubCode, "msg": "weird"}
err := BuildAPIError(resp, ClassifyContext{})
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("expected *InternalError, got %T: %v", err, err)
}
if ie.Category != errs.CategoryInternal {
t.Errorf("Category = %q, want %q", ie.Category, errs.CategoryInternal)
}
if ie.Subtype != errs.SubtypeSDKError {
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeSDKError)
}
if ie.Code != stubCode {
t.Errorf("Code = %d, want %d (raw Lark code should propagate)", ie.Code, stubCode)
}
}
// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a
// CategoryConfig response (Lark code 10014 — "app secret invalid") flows
// through BuildAPIError, the resulting *ConfigError MUST carry the canonical
// recovery hint pointing the user at `lark-cli config init`.
func TestBuildAPIError_ConfigInvalidClient_HasHint(t *testing.T) {
const code = 10014
resp := map[string]any{"code": code, "msg": "app secret invalid"}
ctx := ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "bot"}
err := BuildAPIError(resp, ctx)
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *ConfigError, got %T: %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Hint == "" {
t.Errorf("Hint is empty; canonical hint required for invalid_client")
}
if !strings.Contains(cfgErr.Hint, "lark-cli config init") {
t.Errorf("Hint should reference `lark-cli config init`; got %q", cfgErr.Hint)
}
}

View File

@@ -29,6 +29,22 @@ func missingScopeResp(scope string) map[string]any {
}
}
// appScopeNotAppliedResp builds the Lark response shape for code 99991672
// ("the app has not applied for the required scope(s)"). Used by tests that
// exercise the bot-perspective ConsoleURL attachment path, which the
// dispatcher restricts to SubtypeAppScopeNotApplied only.
func appScopeNotAppliedResp(scope string) map[string]any {
return map[string]any{
"code": 99991672,
"msg": "app scope not applied",
"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)
@@ -95,8 +111,8 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
{"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"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
{"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"},
@@ -129,29 +145,92 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
}
}
// 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) {
// TestBuildAPIError_TaskInvalidParamsRoutesToAPIError pins that code 1470400
// (Lark API-side parameter rejection) routes to *errs.APIError + CategoryAPI
// + SubtypeInvalidParameters. CategoryValidation is reserved for CLI-side
// (caller-side) flag/arg validation, never reachable from API responses;
// classify_test pins the API-side classification here so a regression that
// re-introduces the misclassification fails fast.
func TestBuildAPIError_TaskInvalidParamsRoutesToAPIError(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)
var ae *errs.APIError
if !errors.As(err, &ae) {
t.Fatalf("expected *errs.APIError, got %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)
if p.Category != errs.CategoryAPI {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeInvalidParameters {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
}
}
// TestBuildAPIError_TroubleshooterLiftedOnAPIArm pins that BuildAPIError lifts
// resp.error.troubleshooter into Problem.Troubleshooter when the response
// routes to the catch-all CategoryAPI arm. troubleshooter is the only
// resp.error field with genuinely non-redundant content vs typed envelope
// fields; the rest (permission_violations.subject, log_id, challenge_url) is
// already lifted by category-specific paths.
func TestBuildAPIError_TroubleshooterLiftedOnAPIArm(t *testing.T) {
resp := map[string]any{
"code": 1470400,
"msg": "bad params",
"error": map[string]any{
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/x",
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/x" {
t.Errorf("Troubleshooter = %q, want passthrough", p.Troubleshooter)
}
}
// TestBuildAPIError_TroubleshooterLiftedOnPermissionArm pins that
// troubleshooter surfaces on classified non-API arms too — BuildAPIError lifts
// it before the category switch so PermissionError / ConfigError / etc. inherit
// the same wire vocab.
func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
resp := map[string]any{
"code": 99991679,
"msg": "missing scope",
"error": map[string]any{
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/scope",
"permission_violations": []any{map[string]any{"subject": "docx:document"}},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if pe.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/scope" {
t.Errorf("Troubleshooter = %q, want lifted on PermissionError", pe.Troubleshooter)
}
}
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
// when the upstream response omits it — wire envelope must omit the field.
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
resp := map[string]any{"code": 1470400, "msg": "bad params"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Troubleshooter != "" {
t.Errorf("Troubleshooter = %q, want empty when resp omits it", p.Troubleshooter)
}
}
@@ -182,8 +261,6 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) {
`"code": 99991679`,
`"missing_scopes":`,
`"docx:document"`,
`"console_url":`,
`open.feishu.cn/app/cli_a123/auth`,
`"identity": "user"`,
`"log_id": "lg-1"`,
} {
@@ -196,6 +273,12 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) {
`"component"`,
`"doc_url"`,
`"retryable":`, // Retryable defaults false, omitempty → key absent
// console_url is gated to SubtypeAppScopeNotApplied (bot-perspective
// dev-action recovery). For user-perspective missing_scope the only
// actionable recovery is `lark-cli auth login --scope ...` (already
// in Hint), so the URL is dropped from the wire to avoid pointing an
// end user at a console they cannot modify.
`"console_url":`,
} {
if strings.Contains(out, mustNot) {
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
@@ -228,8 +311,8 @@ func TestRetryableEnvelope_TrueOnly(t *testing.T) {
}
func TestConsoleURL_FeishuBrand(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
@@ -240,8 +323,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
}
func TestConsoleURL_LarkBrand(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
@@ -252,14 +335,36 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
}
func TestConsoleURL_EmptyAppID(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"})
pe := err.(*errs.PermissionError)
if pe.ConsoleURL != "" {
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
}
}
// TestConsoleURL_AttachedOnlyForAppScopeNotApplied pins the gating rule:
// the developer-console deep-link only rides on the wire for
// SubtypeAppScopeNotApplied (where the recovery is "developer applies the
// scope"). User-perspective subtypes such as SubtypeMissingScope recover via
// `lark-cli auth login --scope ...`, so the URL is dead weight on those
// envelopes and is intentionally omitted to avoid pointing an end user at a
// console they cannot modify.
func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) {
cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}
bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError)
if bot.ConsoleURL == "" {
t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty")
}
user := errclass.BuildAPIError(missingScopeResp("docx:document"),
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError)
if user.ConsoleURL != "" {
t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.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).
@@ -335,9 +440,10 @@ func TestPermissionError_DefaultIdentity(t *testing.T) {
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"})
// ConsoleURL falls back to the no-scope form. Exercises the bot-perspective
// SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides.
resp := map[string]any{"code": 99991672, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe := err.(*errs.PermissionError)
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
@@ -367,20 +473,24 @@ func TestExtractMissingScopes_Dedup(t *testing.T) {
}
}
// 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).
// TestServiceShortcutEnvelopeConverge guards that the wire envelope produced
// by the dispatcher (BuildAPIError — the normal service / shortcut path)
// converges with the envelope produced by the direct-construction path used
// in cmd/service/service.go's checkServiceScopes pre-flight check.
//
// 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.
// Both paths now share the same canonical helpers in internal/errclass for
// Message (CanonicalPermissionMessage), Hint (PermissionHint), and
// ConsoleURL (ConsoleURL); MissingScopes and Identity are filled identically.
// A future drift on either side (e.g. a new extension field on
// PermissionError that only BuildAPIError populates, or service.go inlining
// its own message string again) fails this test loudly.
//
// One upstream-derived field is a documented exception: `code` (the Lark
// API numeric code). The pre-flight check runs against a locally cached
// scope list and has no upstream response to extract it from. The
// comparison below strips that key from both envelopes so the assertion
// isolates the contract fields that MUST converge: Subtype, Category,
// Message, Hint, Identity, MissingScopes, ConsoleURL.
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
const (
brand = "feishu"
@@ -392,27 +502,21 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
// 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 {
if _, ok := dispatcherErr.(*errs.PermissionError); !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),
}
// Path B: direct construction — exercises the same helpers that
// cmd/service/service.go's newPreflightMissingScopeError uses. Keep this
// in lock-step with that helper; if either drifts the byte-comparison
// fails. ConsoleURL is intentionally NOT set on either path for
// SubtypeMissingScope — see the gating rationale in buildPermissionError.
consoleURL := errclass.ConsoleURL(brand, appID, missing)
directErr := errs.NewPermissionError(errs.SubtypeMissingScope,
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
WithMissingScopes(missing...).
WithIdentity(identity)
var bufA, bufB bytes.Buffer
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
@@ -422,11 +526,34 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
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())
// Strip `code` from both envelopes — see test doc above.
stripA := stripUpstreamFields(t, bufA.Bytes())
stripB := stripUpstreamFields(t, bufB.Bytes())
if stripA != stripB {
t.Errorf("dispatcher vs direct-construction envelopes diverge (upstream fields stripped):\nDispatcher: %s\nDirect: %s", stripA, stripB)
}
}
// stripUpstreamFields parses an envelope JSON and re-marshals it with the
// upstream-derived "code" key removed from the inner "error" block. Used by
// the convergence test to isolate contract fields shared between the
// dispatcher and pre-flight paths.
func stripUpstreamFields(t *testing.T, raw []byte) string {
t.Helper()
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatalf("envelope not valid JSON: %v\nraw: %s", err, raw)
}
if errBlock, ok := obj["error"].(map[string]any); ok {
delete(errBlock, "code")
}
out, err := json.Marshal(obj)
if err != nil {
t.Fatalf("re-marshal failed: %v", err)
}
return string(out)
}
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
// Mirrors what the cmd/service direct-construction path produces.
pe := &errs.PermissionError{
@@ -492,44 +619,48 @@ func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
}
}
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_MissingScopeRoutesToAuthLogin(t *testing.T) {
// missing_scope means the user authorized the app but did not grant
// this scope — recoverable by re-running `auth login`. Both user and
// bot identities route the same way because the recovery action is
// user-initiated either way.
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, identity, errs.SubtypeMissingScope, "")
if !strings.Contains(got, "lark-cli auth login") {
t.Errorf("identity=%q: hint should suggest `lark-cli auth login`; got %q", identity, got)
}
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
t.Errorf("identity=%q: hint should include missing scopes; got %q", identity, 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)
// missing_scope with empty list — still suggests auth login even
// without the explicit --scope argument.
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope, ""); !strings.Contains(got, "lark-cli auth login") {
t.Errorf("missing_scope no-scope hint should still suggest auth login; 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)
// app_scope_not_applied without console URL — still points at the
// developer console (URL is optional context, not a routing axis).
if got := errclass.PermissionHint(nil, "user", errs.SubtypeAppScopeNotApplied, ""); !strings.Contains(got, "developer console") {
t.Errorf("app_scope_not_applied no-URL hint should still point at developer console; got %q", got)
}
}
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// 99991672 / app_scope_not_enabled means the scope has not been granted
// 99991672 / app_scope_not_applied 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.
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
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)
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
if !strings.Contains(got, "developer console") {
t.Errorf("identity=%q: hint should point to developer console; got %q", identity, got)
}
if !strings.Contains(got, consoleURL) {
t.Errorf("identity=%q: hint should embed the console URL; got %q", identity, got)
}
if strings.Contains(got, "auth login") {
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
@@ -537,6 +668,123 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
}
}
// TestBuildPermissionError_CanonicalMessage pins the per-subtype canonical
// wording so the wire envelope's Message preserves Lark's official phrasing
// ("access denied" / "unauthorized" / "token has no permission") and enhances
// it with CLI context (app ID, scope list). Regressions here are user-visible.
func TestBuildPermissionError_CanonicalMessage(t *testing.T) {
const appID = "cli_xyz"
cases := []struct {
name string
code int
wantSubtype errs.Subtype
// substrings the canonical message MUST contain
wantSubstrs []string
}{
{
name: "99991672 app_scope_not_applied",
code: 99991672,
wantSubtype: errs.SubtypeAppScopeNotApplied,
wantSubstrs: []string{"access denied", "app " + appID, "contact:contact"},
},
{
name: "99991679 missing_scope",
code: 99991679,
wantSubtype: errs.SubtypeMissingScope,
wantSubstrs: []string{"unauthorized", "user authorization", "contact:contact"},
},
{
name: "99991676 token_scope_insufficient",
code: 99991676,
wantSubtype: errs.SubtypeTokenScopeInsufficient,
wantSubstrs: []string{"token has no permission"},
},
{
name: "230027 user_unauthorized",
code: 230027,
wantSubtype: errs.SubtypeUserUnauthorized,
wantSubstrs: []string{"access denied for this operation"},
},
{
name: "99991673 app_unavailable",
code: 99991673,
wantSubtype: errs.SubtypeAppUnavailable,
wantSubstrs: []string{"unauthorized app", "app " + appID, "not properly installed"},
},
{
name: "99991662 app_disabled",
code: 99991662,
wantSubtype: errs.SubtypeAppDisabled,
wantSubstrs: []string{"app " + appID, "not in use", "currently disabled"},
},
{
name: "1470403 permission_denied",
code: 1470403,
wantSubtype: errs.SubtypePermissionDenied,
wantSubstrs: []string{"user lacks permission"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := map[string]any{
"code": tc.code,
"msg": "upstream raw text — must be replaced",
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *PermissionError, got %T", err)
}
if pe.Subtype != tc.wantSubtype {
t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype)
}
for _, sub := range tc.wantSubstrs {
if !strings.Contains(pe.Message, sub) {
t.Errorf("Message %q missing substring %q", pe.Message, sub)
}
}
if pe.Message == "upstream raw text — must be replaced" {
t.Errorf("Message must be rewritten to canonical text, got upstream verbatim: %q", pe.Message)
}
})
}
}
// TestCanonicalPermissionMessage_FallbackOnUnknownSubtype pins that an unknown
// subtype (not in the per-subtype switch) preserves the upstream fallback
// instead of producing an empty Message.
func TestCanonicalPermissionMessage_FallbackOnUnknownSubtype(t *testing.T) {
got := errclass.CanonicalPermissionMessage(errs.SubtypeUnknown, "cli_x", nil, "upstream verbatim")
if got != "upstream verbatim" {
t.Errorf("unknown subtype should preserve fallback; got %q", got)
}
}
// TestCanonicalPermissionMessage_EmptyAppIDStillReadable pins the no-app-id
// fallback wording so an early-init bootstrap path that produces a
// PermissionError without ClassifyContext.AppID still emits useful text.
func TestCanonicalPermissionMessage_EmptyAppIDStillReadable(t *testing.T) {
cases := []struct {
sub errs.Subtype
substr string
appIDIn string
}{
{errs.SubtypeAppScopeNotApplied, "app has not applied", ""},
{errs.SubtypeAppUnavailable, "app is not properly installed", ""},
{errs.SubtypeAppDisabled, "app is not in use", ""},
}
for _, tc := range cases {
got := errclass.CanonicalPermissionMessage(tc.sub, tc.appIDIn, nil, "")
if !strings.Contains(got, tc.substr) {
t.Errorf("subtype=%s no-app-id message missing %q: got %q", tc.sub, tc.substr, got)
}
if strings.Contains(got, " app ") || strings.Contains(got, "app : ") {
t.Errorf("subtype=%s no-app-id message has double space placeholder: %q", tc.sub, 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
@@ -554,8 +802,8 @@ func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testin
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, "developer console") {
t.Errorf("Hint should route to developer 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)

View File

@@ -12,10 +12,16 @@ import (
// 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).
//
// Risk + Action are populated only for codes that route to CategoryConfirmation;
// the dispatcher falls back to RiskUnknown + ctx.LarkCmd when either is empty
// so the envelope is never wire-invalid.
type CodeMeta struct {
Category errs.Category
Subtype errs.Subtype
Retryable bool
Risk string // CategoryConfirmation arm only; empty otherwise
Action string // CategoryConfirmation arm only; empty otherwise
}
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
@@ -27,42 +33,43 @@ type CodeMeta struct {
// 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
99991661: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}, // Authorization header missing
99991671: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // token format error (must start with t- / u-)
99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish)
99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid
99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: 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
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
99991676: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeTokenScopeInsufficient},
99991679: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}, // user authorized app but did not grant this scope
230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app
99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable
99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled 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},
99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true},
1061045: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},
131009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // wiki write-path lock contention; retryable with backoff
1064510: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossTenant},
1064511: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossBrand},
1310246: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable
1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch},
// CategoryConfig
99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
// CategoryPolicy
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
21001: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied},
}
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —

View File

@@ -5,20 +5,21 @@ 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.
// taskCodeMeta holds task-service Lark code → CodeMeta mappings.
// All Subtypes are framework-shared (errs.SubtypeXxx) — task does not declare
// service-specific Subtypes because none of these codes carry semantics beyond
// the cross-service taxonomy (NotFound / QuotaExceeded / etc.).
// 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},
1470400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid_params
1470403: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // permission_denied (resource-level)
1470404: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // not_found
1470422: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // conflict (retryable)
1470500: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // server_error (retryable)
1470610: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // assignee_limit
1470611: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // follower_limit
1470612: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tasklist_member_limit
1470613: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // reminder_exists
}
func init() { mergeCodeMeta(taskCodeMeta, "task") }

View File

@@ -4,12 +4,45 @@
package errclass
import (
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMeta_CredentialCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, false},
{99991671, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false},
{20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
{20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false},
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_MissingScope(t *testing.T) {
got, ok := LookupCodeMeta(99991679)
if !ok {
@@ -29,8 +62,8 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
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.Subtype != errs.SubtypePermissionDenied {
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypePermissionDenied)
}
if got.Retryable {
t.Errorf("Retryable = true, want false")
@@ -70,6 +103,27 @@ func TestLookupCodeMeta_Unknown(t *testing.T) {
}
}
// TestLookupCodeMeta_ConfigCode_99991543 pins the Lark "app_id or app_secret
// is incorrect" code to CategoryConfig / SubtypeInvalidClient. The CLI cannot
// retry around a wrong app credential — the operator has to edit the local
// config — so this MUST stay non-retryable and live in the config category
// (not the API category it was originally classed under).
func TestLookupCodeMeta_ConfigCode_99991543(t *testing.T) {
meta, ok := LookupCodeMeta(99991543)
if !ok {
t.Fatal("99991543 not registered in codeMeta")
}
if meta.Category != errs.CategoryConfig {
t.Errorf("category = %v, want %v", meta.Category, errs.CategoryConfig)
}
if meta.Subtype != errs.SubtypeInvalidClient {
t.Errorf("subtype = %v, want %v", meta.Subtype, errs.SubtypeInvalidClient)
}
if meta.Retryable {
t.Errorf("Retryable = true, want false (wrong app credential is operator-fix)")
}
}
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
got, ok := LookupCodeMeta(21000)
if !ok {
@@ -93,7 +147,7 @@ func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
if !ok {
t.Fatalf("panic value is not a string: %T (%v)", r, r)
}
for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} {
for _, needle := range []string{"1470403", "permission_denied", "intruder", "test"} {
if !strings.Contains(msg, needle) {
t.Errorf("panic message %q missing substring %q", msg, needle)
}

View File

@@ -1,32 +1,48 @@
// 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 provides boundary helpers that bridge legacy error types
// to the typed errs/ taxonomy. These helpers run at the dispatcher boundary
// (cmd/root.go.handleRootError) before the typed envelope writer, converting
// pre-typed-taxonomy errors (*core.ConfigError, *internalauth.NeedAuthorizationError)
// into typed *errs.* errors while preserving the original error in the Cause
// chain so existing `errors.As` callers continue to match.
package errcompat
import (
"strings"
"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.
// PromoteConfigError converts a legacy *core.ConfigError into the matching
// typed errs.*Error based on cfgErr.Type. Called from cmd/root.go.handleRootError
// before the typed envelope writer. The original *core.ConfigError is preserved
// in the Cause chain so external `errors.As(&core.ConfigError{})` callers
// (cmd/auth/list.go, cmd/doctor/doctor.go, etc.) still match.
func PromoteConfigError(cfgErr *core.ConfigError) error {
if cfgErr == nil {
return nil
}
return cfgErr
switch cfgErr.Type {
case "auth":
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", cfgErr.Message).
WithHint("%s", cfgErr.Hint).
WithCause(cfgErr)
case "config":
subtype := errs.SubtypeNotConfigured
lower := strings.ToLower(cfgErr.Message)
if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") {
subtype = errs.SubtypeInvalidConfig
}
return errs.NewConfigError(subtype, "%s", cfgErr.Message).
WithHint("%s", cfgErr.Hint).
WithCause(cfgErr)
default:
// dynamic Type (e.g. workspace name like "bind"/"hermes"/"openclaw") → NotConfigured
return errs.NewConfigError(errs.SubtypeNotConfigured, "%s", cfgErr.Message).
WithHint("%s", cfgErr.Hint).
WithCause(cfgErr)
}
}
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
var _ = errs.CategoryConfig

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errcompat
import (
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
)
// PromoteAuthError converts a legacy *internalauth.NeedAuthorizationError into
// *errs.AuthenticationError{Subtype: TokenMissing}. The Message field MUST
// contain "need_user_authorization" so the marker invariant guardrail in
// cmd/root_test.go and internal/auth/errors_test.go still holds.
//
// Hint mirrors newTokenMissingError in internal/client/client.go so both
// token-missing surfaces converge on the same recovery vocabulary. cmd's
// applyNeedAuthorizationHint appends per-command scopes onto this Hint with
// a "\n" join, so the action prompt is preserved even when scopes are added.
//
// Called from cmd/root.go.handleRootError when errors.As matches
// *NeedAuthorizationError, before WriteTypedErrorEnvelope.
func PromoteAuthError(err *internalauth.NeedAuthorizationError) error {
if err == nil {
return nil
}
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"need_user_authorization (user: %s)", err.UserOpenId).
WithUserOpenID(err.UserOpenId).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(err)
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errcompat
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
)
func TestPromoteAuthError_PromotesNeedAuthorizationError(t *testing.T) {
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
got := PromoteAuthError(needAuth)
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
// Cause chain must preserve original *NeedAuthorizationError so legacy
// consumers (auth.IsNeedUserAuthorizationError + errors.As pattern in
// internal/auth/errors.go:42) still match.
var preserved *internalauth.NeedAuthorizationError
if !errors.As(got, &preserved) {
t.Error("Unwrap chain lost *NeedAuthorizationError — breaks auth.IsNeedUserAuthorizationError consumer")
}
}
func TestPromoteAuthError_PreservesNeedUserAuthorizationMarker(t *testing.T) {
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
got := PromoteAuthError(needAuth)
if !strings.Contains(got.Error(), "need_user_authorization") {
t.Errorf("Message must contain need_user_authorization marker, got: %q", got.Error())
}
}
func TestPromoteAuthError_PreservesUserOpenID(t *testing.T) {
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_test_open_id"}
got := PromoteAuthError(needAuth)
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.UserOpenID != "u_test_open_id" {
t.Errorf("UserOpenID = %q, want preserved", authErr.UserOpenID)
}
}
// TestPromoteAuthError_CarriesAuthLoginHint pins that the recovery action
// prompt is attached at promotion time — without this Hint, downstream
// consumers see authentication/token_missing but no "run: lark-cli auth login"
// guidance, mirroring the pre-typed UX failure when NeedAuthorizationError
// surfaced as a bare network error. cmd's applyNeedAuthorizationHint relies
// on this Hint being non-empty so scope enrichment appends instead of
// overwrites the recovery prompt.
func TestPromoteAuthError_CarriesAuthLoginHint(t *testing.T) {
got := PromoteAuthError(&internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"})
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if !strings.Contains(authErr.Hint, "lark-cli auth login") {
t.Errorf("Hint must guide user to re-authorize, got: %q", authErr.Hint)
}
}
func TestPromoteAuthError_Nil_ReturnsNil(t *testing.T) {
if got := PromoteAuthError(nil); got != nil {
t.Errorf("nil input should return nil, got %v", got)
}
}

View File

@@ -5,33 +5,101 @@ package errcompat_test
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"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)
func TestPromoteConfigError_TypeAuth_PromotesToAuthenticationError(t *testing.T) {
cfg := &core.ConfigError{
Type: "auth",
Code: 3,
Message: "not logged in",
Hint: "run: lark-cli auth login",
}
got := errcompat.PromoteConfigError(cfg)
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
// Cause chain must preserve original *core.ConfigError for errors.As compat.
var cfgPreserved *core.ConfigError
if !errors.As(got, &cfgPreserved) {
t.Error("Unwrap chain lost *core.ConfigError — breaks cmd/auth/list.go consumer")
}
}
func TestPromoteConfigError_TypeConfig_PromotesToConfigError(t *testing.T) {
cases := []struct {
name string
msg string
wantSubtype errs.Subtype
}{
{"not_configured", "not configured", errs.SubtypeNotConfigured},
{"invalid_config_parse", "failed to parse config", errs.SubtypeInvalidConfig},
{"invalid_config_keyword", "invalid config file", errs.SubtypeInvalidConfig},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &core.ConfigError{Type: "config", Code: 3, Message: tc.msg}
got := errcompat.PromoteConfigError(cfg)
var ce *errs.ConfigError
if !errors.As(got, &ce) {
t.Fatalf("expected *errs.ConfigError, got %T", got)
}
if ce.Subtype != tc.wantSubtype {
t.Errorf("subtype = %v, want %v", ce.Subtype, tc.wantSubtype)
}
})
}
}
// 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)
func TestPromoteConfigError_TypeDynamic_PromotesToConfigError(t *testing.T) {
for _, wsName := range []string{"openclaw", "hermes", "bind"} {
t.Run(wsName, func(t *testing.T) {
cfg := &core.ConfigError{Type: wsName, Code: 3, Message: "not configured"}
got := errcompat.PromoteConfigError(cfg)
var ce *errs.ConfigError
if !errors.As(got, &ce) {
t.Fatalf("expected *errs.ConfigError, got %T", got)
}
if ce.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %v, want %v", ce.Subtype, errs.SubtypeNotConfigured)
}
})
}
}
func TestPromoteConfigError_Nil_ReturnsNil(t *testing.T) {
if got := errcompat.PromoteConfigError(nil); got != nil {
t.Errorf("nil input should return nil, got %v", got)
}
}
func TestPromoteConfigError_PreservesMessageHint(t *testing.T) {
cfg := &core.ConfigError{
Type: "auth",
Message: "session expired (user: u_xxx)",
Hint: "re-authenticate",
}
got := errcompat.PromoteConfigError(cfg)
if !strings.Contains(got.Error(), "session expired") {
t.Errorf("message lost in promotion: %v", got)
}
var authErr *errs.AuthenticationError
if !errors.As(got, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
}
if authErr.Hint != "re-authenticate" {
t.Errorf("hint = %q, want preserved", authErr.Hint)
}
}

View File

@@ -17,13 +17,8 @@ import (
// 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.
// Deprecated: legacy error type. Return a typed *errs.XxxError instead
// (see errs/types.go).
type ExitError struct {
Code int
Detail *ErrDetail
@@ -47,12 +42,12 @@ func (e *ExitError) Unwrap() error {
// 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
// preserves the upstream message verbatim. 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.
// wants the original Lark response wording rather than the enriched
// message/hint variant.
func MarkRaw(err error) error {
var exitErr *ExitError
if errors.As(err, &exitErr) {
@@ -63,13 +58,8 @@ func MarkRaw(err error) error {
// 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.
// Deprecated: legacy envelope writer. Typed errors are dispatched by
// cmd/root.go through WriteTypedErrorEnvelope.
func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
if err.Detail == nil {
return
@@ -95,12 +85,8 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
// 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.
// Deprecated: construct a typed *errs.XxxError directly
// (e.g. errs.NewValidationError, errs.NewInternalError).
func Errorf(code int, errType, format string, args ...any) *ExitError {
var err error
for _, arg := range args {
@@ -117,42 +103,26 @@ func Errorf(code int, errType, format string, args ...any) *ExitError {
}
// 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.
// "validation"). The legacy envelope emits only `type`+`message`; for
// `subtype` / `param` extension fields, construct a typed
// *errs.ValidationError directly.
func ErrValidation(format string, args ...any) *ExitError {
return Errorf(ExitValidation, "validation", format, args...)
}
// 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.
// New code should construct a typed *errs.AuthenticationError directly;
// the typed envelope emits the canonical `type: "authentication"`.
// Migrating an existing call site flips a user-visible wire field.
func ErrAuth(format string, args ...any) *ExitError {
return Errorf(ExitAuth, "auth", format, args...)
}
// 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.
// The legacy envelope emits only `type`+`message`; for `subtype`
// ("transport" / "timeout" / "tls" / "dns") and retryable hint extension
// fields, construct a typed *errs.NetworkError directly.
func ErrNetwork(format string, args ...any) *ExitError {
return Errorf(ExitNetwork, "network", format, args...)
}
@@ -160,14 +130,9 @@ func ErrNetwork(format string, args ...any) *ExitError {
// 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.
// Deprecated: route through errclass.BuildAPIError, which emits typed
// *errs.PermissionError / *errs.AuthenticationError / etc. with
// MissingScopes, ConsoleURL, and Identity at the source.
func ErrAPI(larkCode int, msg string, detail any) *ExitError {
exitCode, errType, hint := ClassifyLarkError(larkCode, msg)
if errType == "permission" {
@@ -187,12 +152,8 @@ 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.
// Deprecated: construct a typed *errs.XxxError directly and set its Hint
// field; the typed envelope promotes Problem.Hint to the wire.
func ErrWithHint(code int, errType, msg, hint string) *ExitError {
return &ExitError{
Code: code,
@@ -201,15 +162,10 @@ 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.
// The predicate-command silent-exit signal: stdout has already been
// written and the caller wants the matching exit code without a stderr
// envelope (e.g. `auth check` emitting its JSON result and then exiting
// non-zero on a no-token state). Outside the typed-envelope contract.
func ErrBare(code int) *ExitError {
return &ExitError{Code: code}
}
@@ -220,8 +176,21 @@ func ErrBare(code int) *ExitError {
// (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).
// Two-stage write:
//
// 1. Serialize the envelope into an in-memory buffer. If serialization
// fails, return false so the dispatcher falls back to the legacy
// envelope path; nothing is written to w.
// 2. Best-effort write of the serialized bytes to w. A partial write is
// accepted (return value still true): the typed exit code has already
// been determined upstream by handleRootError calling ExitCodeOf(err)
// before this writer runs, so a torn envelope on stderr must not
// downgrade the caller's typed exit (3/4/6/10) to plain 1. Consumers
// parse-or-skip on malformed JSON.
//
// Returns true when err was a typed error and serialization succeeded.
// Returns false only when err carries no Problem (caller should fall back
// to WriteErrorEnvelope) or when JSON encoding itself failed.
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
typed, ok := errs.UnwrapTypedError(err)
if !ok {
@@ -242,12 +211,11 @@ func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
// 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
}
// Best-effort write. Partial-write does not downgrade the success status:
// the dispatcher has already captured ExitCodeOf(err) before calling us,
// and a torn stderr is preferable to falling through to the plain
// "Error:" path with exit 1.
_, _ = w.Write(buf.Bytes())
return true
}

View File

@@ -7,9 +7,47 @@ import (
"bytes"
"encoding/json"
"errors"
"io"
"testing"
"github.com/larksuite/cli/errs"
)
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
// the write that would push past the limit. Used to simulate a stderr that
// dies mid-envelope.
type failingWriter struct {
limit int
n int
}
func (f *failingWriter) Write(p []byte) (int, error) {
if f.n+len(p) > f.limit {
canWrite := f.limit - f.n
if canWrite < 0 {
canWrite = 0
}
f.n += canWrite
return canWrite, io.ErrShortWrite
}
f.n += len(p)
return len(p), nil
}
// TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus pins that
// when serialization succeeds but the underlying write fails mid-envelope,
// WriteTypedErrorEnvelope returns true so the dispatcher does NOT fall
// through to the legacy "Error:" path and clobber the typed exit code with
// 1. Exit code is preserved separately by handleRootError computing
// ExitCodeOf(err) before the write.
func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T) {
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
w := &failingWriter{limit: 20} // dies mid-envelope
if ok := WriteTypedErrorEnvelope(w, err, "user"); !ok {
t.Error("partial write must return true; exit code is preserved separately")
}
}
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
// Set up PendingNotice
origNotice := PendingNotice
@@ -119,11 +157,11 @@ 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.
// TestErrValidation_LegacyExitErrorShape pins the 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. Typed
// *errs.ValidationError carries the extension fields when needed.
func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
err := ErrValidation("bad arg: %s", "x")
@@ -163,7 +201,7 @@ func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
}
}
// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for
// TestErrNetwork_LegacyExitErrorShape pins the 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) {

View File

@@ -31,10 +31,15 @@ const (
LarkErrUserNotAuthorized = 230027 // user not authorized
// App credential / status.
LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect
LarkErrAppNotInUse = 99991662 // app is disabled or not installed in this tenant
LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect (Open API)
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// TAT-endpoint variant of the "wrong app credentials" condition.
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
// ("app secret invalid") instead of 99991543 when the secret is wrong.
LarkErrTATInvalidSecret = 10014
// Rate limit.
LarkErrRateLimit = 99991400 // request frequency limit exceeded
@@ -94,14 +99,15 @@ var legacyHints = map[int]string{
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
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",
LarkErrAppScopeNotEnabled: "the app developer must apply for the required scope(s) at the developer console",
LarkErrTokenNoPermission: "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued",
LarkErrUserScopeInsufficient: "run `lark-cli auth login` to re-authorize the user with the updated scope set",
LarkErrUserNotAuthorized: "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy",
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",
LarkErrAppCredInvalid: "run `lark-cli config init` to set valid app_id and app_secret",
LarkErrTATInvalidSecret: "run `lark-cli config init` to set valid app_id and app_secret",
LarkErrAppNotInUse: "ask the tenant admin to re-enable the app in the Lark admin console",
LarkErrAppUnauthorized: "ask the tenant admin to check the app's install status in the Lark admin console",
LarkErrRateLimit: "please try again later",
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
@@ -117,32 +123,18 @@ var legacyHints = map[int]string{
// 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:
// Classification is sourced from errclass.LookupCodeMeta (the single source
// of truth). exitCode follows legacyExitCode below, which differs from
// ExitCodeForCategory in two preserved-legacy quirks: Authorization +
// permission subtypes return ExitAPI (legacy treated "permission" as
// exit 1), and Config returns ExitAuth (legacy bundled "check
// app_id/secret" under exit 3). errType maps to a legacy short string;
// unknown subtypes fall back to "api_error". Unknown codes classify as
// (ExitAPI, "api_error", "").
//
// - 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.
// Deprecated: route Lark API responses through errclass.BuildAPIError,
// which emits a typed *errs.XxxError with Category, Subtype, and
// identity-aware extension fields populated at the source.
func ClassifyLarkError(code int, msg string) (int, string, string) {
meta, ok := errclass.LookupCodeMeta(code)
if !ok {
@@ -180,7 +172,7 @@ func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
errs.SubtypeTokenScopeInsufficient:
return ExitAPI
case errs.SubtypeAppUnavailable,
errs.SubtypeAppNotInstalled:
errs.SubtypeAppDisabled:
return ExitAuth
}
return ExitAPI
@@ -206,7 +198,7 @@ func legacyErrType(cat errs.Category, sub errs.Subtype) string {
errs.SubtypeTokenScopeInsufficient:
return "permission"
case errs.SubtypeAppUnavailable,
errs.SubtypeAppNotInstalled:
errs.SubtypeAppDisabled:
return "app_status"
}
return "permission"

View File

@@ -38,6 +38,45 @@ func GetStrSliceFromMap(m map[string]interface{}, key string) []string {
return result
}
// DeclaredScopesForMethod returns the scopes declared by a method's
// from_meta entry for the given identity. Prefers the explicit
// `requiredScopes` field when present; otherwise returns the single
// recommended scope from `scopes` (or the first scope as a final fallback).
// Returns nil when the method has no scope information.
func DeclaredScopesForMethod(method map[string]interface{}, identity string) []string {
if method == nil {
return nil
}
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
out := make([]string, 0, len(requiredRaw))
for _, v := range requiredRaw {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
rawScopes, _ := method["scopes"].([]interface{})
if len(rawScopes) == 0 {
return nil
}
recommended := SelectRecommendedScope(rawScopes, identity)
if recommended == "" {
for _, raw := range rawScopes {
if s, ok := raw.(string); ok && s != "" {
recommended = s
break
}
}
}
if recommended == "" {
return nil
}
return []string{recommended}
}
// SelectRecommendedScope selects the known scope with the highest priority score
// (higher = more recommended / least privilege).
// Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes.