mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
136
internal/errclass/classify_internal_test.go
Normal file
136
internal/errclass/classify_internal_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
32
internal/errcompat/promote_auth.go
Normal file
32
internal/errcompat/promote_auth.go
Normal 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)
|
||||
}
|
||||
79
internal/errcompat/promote_auth_test.go
Normal file
79
internal/errcompat/promote_auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user