chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// (1) Pass-through any typed errs.* error.
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// (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)
|
||||
}
|
||||
|
||||
// (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 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 {
|
||||
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)
|
||||
}
|
||||
return e.WithHint("%s", rawAPIJSONHint).WithCause(err)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 strings.Contains(msg, "unexpected end of JSON input") ||
|
||||
strings.Contains(msg, "cannot unmarshal") {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json")
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 ie.Category != errs.CategoryInternal {
|
||||
t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ne.Subtype != errs.SubtypeNetworkTLS {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTLS {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, 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 pass-through, got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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_UntypedErrorRoutesToNetwork pins that a plain untyped
|
||||
// error (no embedded Problem, no JSON-decode chain) is NOT pass-through —
|
||||
// only typed *errs.* values are. It routes to the network branch with the
|
||||
// fallback transport subtype.
|
||||
func TestWrapDoAPIError_UntypedErrorRoutesToNetwork(t *testing.T) {
|
||||
got := WrapDoAPIError(errors.New("no access token available for user"))
|
||||
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError for an untyped error, 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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// RawApiRequest describes a raw API request.
|
||||
type RawApiRequest struct {
|
||||
Method string
|
||||
URL string
|
||||
Params map[string]interface{}
|
||||
Data interface{}
|
||||
As core.Identity
|
||||
ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers)
|
||||
}
|
||||
|
||||
// APIClient wraps lark.Client for all Lark Open API calls.
|
||||
type APIClient struct {
|
||||
Config *core.CliConfig
|
||||
SDK *lark.Client // All Lark API calls go through SDK
|
||||
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
|
||||
ErrOut io.Writer // debug/progress output
|
||||
Credential *credential.CredentialProvider
|
||||
}
|
||||
|
||||
func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (string, error) {
|
||||
result, err := c.Credential.ResolveToken(ctx, credential.NewTokenSpec(as, c.Config.AppID))
|
||||
if err != nil {
|
||||
var unavailableErr *credential.TokenUnavailableError
|
||||
if errors.As(err, &unavailableErr) {
|
||||
return "", newTokenMissingError(as, unavailableErr)
|
||||
}
|
||||
// The credential chain already emits a typed *errs.AuthenticationError
|
||||
// for the missing-UAT case (e.g. UAT refresh returned
|
||||
// need_user_authorization), so it flows through unchanged: the
|
||||
// outer-typed gate in cmd/root.go and the idempotent WrapDoAPIError
|
||||
// both preserve its authentication category and exit 3.
|
||||
return "", err
|
||||
}
|
||||
if result.Token == "" {
|
||||
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.
|
||||
func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []larkcore.RequestOptionFunc) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
for k, v := range request.Params {
|
||||
switch val := v.(type) {
|
||||
case []string:
|
||||
queryParams[k] = val
|
||||
case []interface{}:
|
||||
for _, item := range val {
|
||||
queryParams.Add(k, fmt.Sprintf("%v", item))
|
||||
}
|
||||
default:
|
||||
queryParams.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
apiReq := &larkcore.ApiReq{
|
||||
HttpMethod: strings.ToUpper(request.Method),
|
||||
ApiPath: request.URL,
|
||||
Body: request.Data,
|
||||
QueryParams: queryParams,
|
||||
}
|
||||
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
opts = append(opts, request.ExtraOpts...)
|
||||
return apiReq, opts
|
||||
}
|
||||
|
||||
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
|
||||
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
|
||||
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
||||
//
|
||||
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
||||
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
|
||||
// each one remembering to wrap. WrapDoAPIError classifies a raw transport
|
||||
// failure into a typed *errs.NetworkError / *errs.InternalError per the
|
||||
// contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified
|
||||
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
|
||||
// elsewhere) flow through unchanged.
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
// WrapDoAPIError is idempotent on already-classified errors:
|
||||
// the typed *errs.AuthenticationError that resolveAccessToken returns
|
||||
// for missing tokens passes through with its auth category and exit 3
|
||||
// intact, and any other typed *errs.* error from the credential chain
|
||||
// survives the same way. Only stray untyped errors (raw fmt.Errorf)
|
||||
// get the transport-or-internal fallback.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
if as.IsBot() {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
||||
opts = append(opts, larkcore.WithTenantAccessToken(token))
|
||||
} else {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
|
||||
opts = append(opts, larkcore.WithUserAccessToken(token))
|
||||
}
|
||||
|
||||
opts = append(opts, extraOpts...)
|
||||
resp, err := c.SDK.Do(ctx, req, opts...)
|
||||
if err != nil {
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
|
||||
// a live *http.Response whose Body is an io.Reader for streaming consumption.
|
||||
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
|
||||
// any extra headers from opts are applied automatically.
|
||||
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
|
||||
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
|
||||
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
|
||||
cfg := buildConfig(opts)
|
||||
|
||||
// Resolve auth
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
// See DoSDKRequest comment on the same wrap pattern; the typed
|
||||
// auth-error pass-through plus untyped fallback applies equally to
|
||||
// streaming requests.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
// Build URL
|
||||
requestURL, err := buildStreamURL(c.Config.Brand, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build body
|
||||
bodyReader, contentType, err := buildStreamBody(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Timeout — use context deadline only; httpClient.Timeout would cut off
|
||||
// healthy streaming responses because it includes body read time.
|
||||
httpClient := *c.HTTP
|
||||
httpClient.Timeout = 0
|
||||
cancel := func() {}
|
||||
requestCtx := ctx
|
||||
if cfg.timeout > 0 {
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
requestCtx, cancel = context.WithTimeout(ctx, cfg.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request
|
||||
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Apply headers from opts
|
||||
for k, vs := range cfg.headers {
|
||||
for _, v := range vs {
|
||||
httpReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
httpReq.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
// Handle HTTP errors internally
|
||||
if resp.StatusCode >= 400 {
|
||||
defer resp.Body.Close()
|
||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
msg := strings.TrimSpace(string(errBody))
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if resp.StatusCode >= 500 {
|
||||
subtype = errs.SubtypeNetworkServer
|
||||
}
|
||||
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 streamLogID(header http.Header) string {
|
||||
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
|
||||
if logID == "" {
|
||||
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
|
||||
}
|
||||
return logID
|
||||
}
|
||||
|
||||
type cancelOnCloseBody struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (r *cancelOnCloseBody) Close() error {
|
||||
err := r.ReadCloser.Close()
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) {
|
||||
requestURL := req.ApiPath
|
||||
if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") {
|
||||
var pathSegs []string
|
||||
for _, segment := range strings.Split(req.ApiPath, "/") {
|
||||
if !strings.HasPrefix(segment, ":") {
|
||||
pathSegs = append(pathSegs, segment)
|
||||
continue
|
||||
}
|
||||
pathKey := strings.TrimPrefix(segment, ":")
|
||||
pathValue, ok := req.PathParams[pathKey]
|
||||
if !ok {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
|
||||
}
|
||||
if pathValue == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "empty path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
|
||||
}
|
||||
pathSegs = append(pathSegs, url.PathEscape(pathValue))
|
||||
}
|
||||
endpoints := core.ResolveEndpoints(brand)
|
||||
requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/")
|
||||
}
|
||||
if query := req.QueryParams.Encode(); query != "" {
|
||||
requestURL += "?" + query
|
||||
}
|
||||
return requestURL, nil
|
||||
}
|
||||
|
||||
func buildStreamBody(body interface{}) (io.Reader, string, error) {
|
||||
switch typed := body.(type) {
|
||||
case nil:
|
||||
return nil, "", nil
|
||||
case io.Reader:
|
||||
return typed, "", nil
|
||||
case []byte:
|
||||
return bytes.NewReader(typed), "", nil
|
||||
case string:
|
||||
return strings.NewReader(typed), "text/plain; charset=utf-8", nil
|
||||
default:
|
||||
payload, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "failed to encode request body: %s", err).WithCause(err)
|
||||
}
|
||||
return bytes.NewReader(payload), "application/json", nil
|
||||
}
|
||||
}
|
||||
|
||||
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
|
||||
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
|
||||
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and
|
||||
// any endpoint whose Content-Type may not be JSON.
|
||||
func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore.ApiResp, error) {
|
||||
apiReq, extraOpts := c.buildApiReq(request)
|
||||
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
|
||||
}
|
||||
|
||||
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. Use DoAPI
|
||||
// directly when the response may not be JSON (e.g. file downloads).
|
||||
//
|
||||
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
|
||||
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
||||
// see a typed *errs.InternalError (invalid_response) 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 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 {
|
||||
return nil, err
|
||||
}
|
||||
result, parseErr := ParseJSONResponse(resp)
|
||||
if parseErr != nil {
|
||||
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
|
||||
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
|
||||
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{}) error) ([]interface{}, error) {
|
||||
var allResults []interface{}
|
||||
var pageToken string
|
||||
page := 0
|
||||
pageDelay := opts.PageDelay
|
||||
if pageDelay == 0 {
|
||||
pageDelay = 200
|
||||
}
|
||||
|
||||
for {
|
||||
page++
|
||||
params := make(map[string]interface{})
|
||||
for k, v := range request.Params {
|
||||
params[k] = v
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.ErrOut, "[page %d] fetching...\n", page)
|
||||
result, err := c.CallAPI(ctx, RawApiRequest{
|
||||
Method: request.Method,
|
||||
URL: request.URL,
|
||||
Params: params,
|
||||
Data: request.Data,
|
||||
As: request.As,
|
||||
ExtraOpts: request.ExtraOpts,
|
||||
})
|
||||
if err != nil {
|
||||
if page == 1 {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(c.ErrOut, "[page %d] error, stopping pagination\n", page)
|
||||
break
|
||||
}
|
||||
|
||||
if resultMap, ok := result.(map[string]interface{}); ok {
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
if code != 0 {
|
||||
allResults = append(allResults, result)
|
||||
if page == 1 {
|
||||
return allResults, nil
|
||||
}
|
||||
fmt.Fprintf(c.ErrOut, "[page %d] API error (code=%.0f), stopping pagination\n", page, code)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if onResult != nil {
|
||||
if err := onResult(result); err != nil {
|
||||
return allResults, err
|
||||
}
|
||||
}
|
||||
allResults = append(allResults, result)
|
||||
|
||||
pageToken = ""
|
||||
if resultMap, ok := result.(map[string]interface{}); ok {
|
||||
if data, ok := resultMap["data"].(map[string]interface{}); ok {
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
if hasMore {
|
||||
if pt, ok := data["page_token"].(string); ok && pt != "" {
|
||||
pageToken = pt
|
||||
} else if pt, ok := data["next_page_token"].(string); ok && pt != "" {
|
||||
pageToken = pt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pageToken == "" {
|
||||
break
|
||||
}
|
||||
|
||||
if opts.PageLimit > 0 && page >= opts.PageLimit {
|
||||
fmt.Fprintf(c.ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", opts.PageLimit)
|
||||
break
|
||||
}
|
||||
|
||||
if pageDelay > 0 {
|
||||
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
// PaginateAll fetches all pages and returns a single merged result.
|
||||
// Use this for formats that need the complete dataset (e.g. JSON).
|
||||
func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts PaginationOptions) (interface{}, error) {
|
||||
results, err := c.paginateLoop(ctx, request, opts, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
if len(results) == 1 {
|
||||
return results[0], nil
|
||||
}
|
||||
return mergePagedResults(c.ErrOut, results), nil
|
||||
}
|
||||
|
||||
// StreamPages fetches all pages and streams each page's list items via onItems.
|
||||
// Returns the last page result (for error checking), whether any list items were found,
|
||||
// and any network error. Use this for streaming formats (ndjson, table, csv).
|
||||
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}) error, opts PaginationOptions) (result interface{}, hasItems bool, err error) {
|
||||
totalItems := 0
|
||||
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) error {
|
||||
resultMap, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
data, ok := resultMap["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arrayField := output.FindArrayField(data)
|
||||
if arrayField == "" {
|
||||
return nil
|
||||
}
|
||||
items, ok := data[arrayField].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
totalItems += len(items)
|
||||
if err := onItems(items); err != nil {
|
||||
return err
|
||||
}
|
||||
hasItems = true
|
||||
return nil
|
||||
})
|
||||
if loopErr != nil {
|
||||
return nil, false, loopErr
|
||||
}
|
||||
|
||||
if hasItems {
|
||||
fmt.Fprintf(c.ErrOut, "[pagination] streamed %d pages, %d total items\n", len(results), totalItems)
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
return results[len(results)-1], hasItems, nil
|
||||
}
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
if code, _ := util.ToFloat64(resultMap["code"]); code == 0 {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// roundTripFunc is an adapter to use a function as http.RoundTripper.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
// jsonResponse creates an HTTP response with JSON body.
|
||||
func jsonResponse(body interface{}) *http.Response {
|
||||
b, _ := json.Marshal(body)
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewReader(b)),
|
||||
}
|
||||
}
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
errBuf := &bytes.Buffer{}
|
||||
httpClient := &http.Client{Transport: rt}
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpClient),
|
||||
)
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
return &APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: errBuf,
|
||||
Credential: testCred,
|
||||
Config: cfg,
|
||||
}, errBuf
|
||||
}
|
||||
|
||||
func TestIsJSONContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
ct string
|
||||
want bool
|
||||
}{
|
||||
{"application/json", true},
|
||||
{"application/json; charset=utf-8", true},
|
||||
{"text/json", true},
|
||||
{"application/octet-stream", false},
|
||||
{"image/png", false},
|
||||
{"text/html", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsJSONContentType(tt.ct); got != tt.want {
|
||||
t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimeToExt(t *testing.T) {
|
||||
tests := []struct {
|
||||
ct string
|
||||
want string
|
||||
}{
|
||||
{"image/png", ".png"},
|
||||
{"image/jpeg", ".jpg"},
|
||||
{"application/pdf", ".pdf"},
|
||||
{"text/plain", ".txt"},
|
||||
{"application/octet-stream", ".bin"},
|
||||
{"", ".bin"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := mimeToExt(tt.ct); got != tt.want {
|
||||
t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"user_id": "u123",
|
||||
"name": "Test User",
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
|
||||
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users/u123",
|
||||
As: "bot",
|
||||
}, func(items []interface{}) error {
|
||||
t.Error("onItems should not be called for non-batch API")
|
||||
return nil
|
||||
}, PaginationOptions{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hasItems {
|
||||
t.Error("expected hasItems=false for non-batch API")
|
||||
}
|
||||
if strings.Contains(errBuf.String(), "[pagination] streamed") {
|
||||
t.Error("expected no pagination summary log for non-batch API")
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected result to be a map")
|
||||
}
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if data["user_id"] != "u123" {
|
||||
t.Errorf("expected user_id=u123, got %v", data["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
|
||||
var streamedItems []interface{}
|
||||
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
As: "bot",
|
||||
}, func(items []interface{}) error {
|
||||
streamedItems = append(streamedItems, items...)
|
||||
return nil
|
||||
}, PaginationOptions{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hasItems {
|
||||
t.Error("expected hasItems=true for batch API")
|
||||
}
|
||||
if len(streamedItems) != 2 {
|
||||
t.Errorf("expected 2 streamed items, got %d", len(streamedItems))
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "[pagination] streamed") {
|
||||
t.Error("expected pagination summary log for batch API")
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamPages_OnItemsErrorStopsPagination(t *testing.T) {
|
||||
apiCalls := 0
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
apiCalls++
|
||||
if apiCalls == 1 {
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
sentinel := errors.New("stop streaming")
|
||||
var streamedItems []interface{}
|
||||
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
As: "bot",
|
||||
}, func(items []interface{}) error {
|
||||
streamedItems = append(streamedItems, items...)
|
||||
return sentinel
|
||||
}, PaginationOptions{PageDelay: 0})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("err = %v, want sentinel", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("result = %#v, want nil when callback stops pagination", result)
|
||||
}
|
||||
if hasItems {
|
||||
t.Fatal("hasItems = true, want false when callback stops before returning")
|
||||
}
|
||||
if apiCalls != 1 {
|
||||
t.Fatalf("apiCalls = %d, want early stop after first page", apiCalls)
|
||||
}
|
||||
if len(streamedItems) != 1 {
|
||||
t.Fatalf("streamedItems = %d, want first page only", len(streamedItems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
|
||||
apiCalls := 0
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
apiCalls++
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
|
||||
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, PaginationOptions{PageLimit: 2, PageDelay: 0})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if apiCalls != 2 {
|
||||
t.Errorf("expected 2 API calls with PageLimit=2, got %d", apiCalls)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "reached page limit (2), stopping. Use --page-all --page-limit 0 to fetch all pages.") {
|
||||
t.Errorf("expected page limit log, got: %s", errBuf.String())
|
||||
}
|
||||
|
||||
// Truncation must surface in the merged output: has_more stays true so
|
||||
// callers can detect loss. page_token is intentionally dropped from the
|
||||
// aggregate view — to fetch more, re-run with a larger --page-limit.
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Errorf("expected has_more=true when page limit truncates, got false")
|
||||
}
|
||||
if _, exists := data["page_token"]; exists {
|
||||
t.Errorf("expected page_token to be dropped from merged output, got %v", data["page_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAll_NaturalEndClearsPageToken(t *testing.T) {
|
||||
apiCalls := 0
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
apiCalls++
|
||||
hasMore := apiCalls < 2
|
||||
body := map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
|
||||
"has_more": hasMore,
|
||||
},
|
||||
}
|
||||
if hasMore {
|
||||
body["data"].(map[string]interface{})["page_token"] = "next"
|
||||
}
|
||||
return jsonResponse(body), nil
|
||||
})
|
||||
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, PaginationOptions{PageLimit: 10, PageDelay: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if hasMore, _ := data["has_more"].(bool); hasMore {
|
||||
t.Errorf("expected has_more=false at natural end, got true")
|
||||
}
|
||||
if _, exists := data["page_token"]; exists {
|
||||
t.Errorf("expected page_token absent at natural end, got %v", data["page_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApiReq_QueryParams(t *testing.T) {
|
||||
ac := &APIClient{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params map[string]interface{}
|
||||
want larkcore.QueryParams
|
||||
}{
|
||||
{
|
||||
name: "scalar values",
|
||||
params: map[string]interface{}{"page_size": 20, "user_id_type": "open_id"},
|
||||
want: larkcore.QueryParams{
|
||||
"page_size": []string{"20"},
|
||||
"user_id_type": []string{"open_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "[]interface{} array",
|
||||
params: map[string]interface{}{"department_ids": []interface{}{"d1", "d2", "d3"}},
|
||||
want: larkcore.QueryParams{
|
||||
"department_ids": []string{"d1", "d2", "d3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "[]string array",
|
||||
params: map[string]interface{}{"statuses": []string{"active", "inactive"}},
|
||||
want: larkcore.QueryParams{
|
||||
"statuses": []string{"active", "inactive"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed scalar and array",
|
||||
params: map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"ids": []interface{}{"id1", "id2"},
|
||||
},
|
||||
want: larkcore.QueryParams{
|
||||
"user_id_type": []string{"open_id"},
|
||||
"ids": []string{"id1", "id2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
params: map[string]interface{}{"tags": []interface{}{}},
|
||||
want: larkcore.QueryParams{},
|
||||
},
|
||||
{
|
||||
name: "nil params",
|
||||
params: nil,
|
||||
want: larkcore.QueryParams{},
|
||||
},
|
||||
{
|
||||
name: "bool value",
|
||||
params: map[string]interface{}{"with_bot": true},
|
||||
want: larkcore.QueryParams{"with_bot": []string{"true"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
apiReq, _ := ac.buildApiReq(RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
Params: tt.params,
|
||||
})
|
||||
got := apiReq.QueryParams
|
||||
// Check all expected keys exist with correct values
|
||||
for k, wantVals := range tt.want {
|
||||
gotVals, ok := got[k]
|
||||
if !ok {
|
||||
t.Errorf("missing key %q", k)
|
||||
continue
|
||||
}
|
||||
if len(gotVals) != len(wantVals) {
|
||||
t.Errorf("key %q: got %d values %v, want %d values %v", k, len(gotVals), gotVals, len(wantVals), wantVals)
|
||||
continue
|
||||
}
|
||||
for i := range wantVals {
|
||||
if gotVals[i] != wantVals[i] {
|
||||
t.Errorf("key %q[%d]: got %q, want %q", k, i, gotVals[i], wantVals[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check no unexpected keys
|
||||
for k := range got {
|
||||
if _, ok := tt.want[k]; !ok {
|
||||
t.Errorf("unexpected key %q with values %v", k, got[k])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, errBuf := newTestAPIClient(t, rt)
|
||||
|
||||
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
As: "bot",
|
||||
}, PaginationOptions{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(errBuf.String(), "[pagination] streamed") {
|
||||
t.Error("expected no streaming summary log from PaginateAll")
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: srv.URL,
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
t.Fatalf("DoStream() error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Fatalf("response body = %q, want %q", string(body), "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
|
||||
}
|
||||
|
||||
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
|
||||
// the missing-token path of resolveAccessToken returns the typed
|
||||
// *errs.AuthenticationError{Subtype: TokenMissing}.
|
||||
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 mirrors the production credential chain: the
|
||||
// missing-UAT case is constructed typed at the source (internal/auth) and
|
||||
// carries the legacy *NeedAuthorizationError sentinel in its Cause chain. It
|
||||
// must surface as a typed AuthenticationError and flow through resolveAccessToken
|
||||
// and WrapDoAPIError unchanged (never mis-classified as NetworkError).
|
||||
type needAuthTokenResolver struct {
|
||||
userOpenID string
|
||||
}
|
||||
|
||||
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, internalauth.NewNeedUserAuthorizationError(f.userOpenID)
|
||||
}
|
||||
|
||||
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
|
||||
// pins that the typed missing-UAT error from the credential chain reaches the
|
||||
// caller as a typed AuthenticationError with the marker and sentinel intact.
|
||||
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_AuthFailureSurfacesTypedAuthenticationError(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.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsUser)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error, got nil")
|
||||
}
|
||||
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 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 typed network classification via WrapDoAPIError.
|
||||
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
|
||||
// *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
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsBot)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error from broken transport, got nil")
|
||||
}
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
|
||||
}
|
||||
if netErr.Category != errs.CategoryNetwork {
|
||||
t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork)
|
||||
}
|
||||
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 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".
|
||||
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{ malformed`)),
|
||||
}, nil
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.CallAPI(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users/me",
|
||||
As: "bot",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected JSON parse error, got nil")
|
||||
}
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err)
|
||||
}
|
||||
if intErr.Category != errs.CategoryInternal {
|
||||
t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal)
|
||||
}
|
||||
if intErr.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, config)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/drive/v1/medias/file_token/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("forbidden"),
|
||||
Headers: http.Header{
|
||||
larkcore.HttpHeaderKeyLogId: []string{"202605270003"},
|
||||
},
|
||||
})
|
||||
|
||||
client, err := factory.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = client.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
|
||||
}, core.AsBot)
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T %v", err, err)
|
||||
}
|
||||
if netErr.LogID != "202605270003" {
|
||||
t.Fatalf("LogID = %q, want %q", netErr.LogID, "202605270003")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Option configures API request behavior for DoStream (and future DoSDKRequest).
|
||||
type Option func(*requestConfig)
|
||||
|
||||
type requestConfig struct {
|
||||
timeout time.Duration
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
// WithTimeout sets a request-level timeout that overrides the client default.
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(c *requestConfig) {
|
||||
c.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders adds extra HTTP headers to the request.
|
||||
func WithHeaders(h http.Header) Option {
|
||||
return func(c *requestConfig) {
|
||||
if c.headers == nil {
|
||||
c.headers = make(http.Header)
|
||||
}
|
||||
for k, vs := range h {
|
||||
for _, v := range vs {
|
||||
c.headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfig(opts []Option) requestConfig {
|
||||
var cfg requestConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// PaginationOptions contains pagination control options.
|
||||
type PaginationOptions struct {
|
||||
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
|
||||
PageDelay int // ms, default 200
|
||||
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
|
||||
}
|
||||
|
||||
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
firstMap, ok := results[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return map[string]interface{}{"pages": results}
|
||||
}
|
||||
|
||||
data, ok := firstMap["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return map[string]interface{}{"pages": results}
|
||||
}
|
||||
|
||||
arrayField := output.FindArrayField(data)
|
||||
if arrayField == "" {
|
||||
return map[string]interface{}{"pages": results}
|
||||
}
|
||||
|
||||
var merged []interface{}
|
||||
for _, r := range results {
|
||||
if rm, ok := r.(map[string]interface{}); ok {
|
||||
if d, ok := rm["data"].(map[string]interface{}); ok {
|
||||
if items, ok := d[arrayField].([]interface{}); ok {
|
||||
merged = append(merged, items...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "[pagination] merged %d pages, %d total items\n", len(results), len(merged))
|
||||
|
||||
mergedData := make(map[string]interface{})
|
||||
for k, v := range data {
|
||||
mergedData[k] = v
|
||||
}
|
||||
mergedData[arrayField] = merged
|
||||
|
||||
// Surface the last page's real has_more so callers can detect truncation
|
||||
// when --page-limit stops the loop before the API is exhausted. Page tokens
|
||||
// are intentionally dropped: the merged view is an aggregate, not a resume
|
||||
// cursor — to fetch more, re-run with a larger --page-limit.
|
||||
lastHasMore := false
|
||||
if lastMap, ok := results[len(results)-1].(map[string]interface{}); ok {
|
||||
if lastData, ok := lastMap["data"].(map[string]interface{}); ok {
|
||||
lastHasMore, _ = lastData["has_more"].(bool)
|
||||
}
|
||||
}
|
||||
mergedData["has_more"] = lastHasMore
|
||||
delete(mergedData, "page_token")
|
||||
delete(mergedData, "next_page_token")
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range firstMap {
|
||||
result[k] = v
|
||||
}
|
||||
result["data"] = mergedData
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
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"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// ── Response routing ──
|
||||
|
||||
// ResponseOptions configures how HandleResponse routes a raw API response.
|
||||
type ResponseOptions struct {
|
||||
OutputPath string // --output flag; "" = auto-detect
|
||||
Format output.Format // output format for JSON responses
|
||||
JqExpr string // if set, apply jq filter instead of Format
|
||||
Out io.Writer // stdout
|
||||
ErrOut io.Writer // stderr
|
||||
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
|
||||
CommandPath string // raw cobra CommandPath() for content safety scanning
|
||||
// Identity is forwarded to CheckError (default or caller-supplied) so the
|
||||
// classifier can populate identity-aware fields (e.g. PermissionError.Identity).
|
||||
// Defaults to core.AsUser when empty.
|
||||
Identity core.Identity
|
||||
// CheckError is called on parsed JSON results. Nil defaults to (*APIClient).CheckResponse
|
||||
// with the Identity field (or AsUser when unset).
|
||||
CheckError func(result interface{}, identity core.Identity) error
|
||||
}
|
||||
|
||||
// httpStatusError classifies an HTTP error response by status when the body
|
||||
// carries no usable business error: 5xx → NetworkError (server tier), 404 →
|
||||
// APIError/not_found, any other 4xx → APIError/unknown. Used wherever a
|
||||
// status >= 400 must not be swallowed — a non-JSON body, an unparseable body,
|
||||
// or a JSON body whose business code is 0.
|
||||
func httpStatusError(status int, rawBody []byte) error {
|
||||
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(rawBody)), 500)
|
||||
if status >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"HTTP %d: %s", status, body).
|
||||
WithCode(status)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == 404 {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "HTTP %d: %s", status, body).
|
||||
WithCode(status)
|
||||
}
|
||||
|
||||
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
|
||||
// 1. If Content-Type is JSON, check for business errors first (even with --output).
|
||||
// 2. If --output is set and response is not a JSON error, save to file.
|
||||
// 3. If Content-Type is non-JSON and no --output, auto-save binary to file.
|
||||
func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
identity := opts.Identity
|
||||
if identity == "" {
|
||||
identity = core.AsUser
|
||||
}
|
||||
check := opts.CheckError
|
||||
if check == nil {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error
|
||||
// directly instead of falling through to the binary-save path.
|
||||
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
|
||||
return httpStatusError(resp.StatusCode, resp.RawBody)
|
||||
}
|
||||
|
||||
// JSON responses: always check for business errors before saving.
|
||||
if IsJSONContentType(ct) || ct == "" {
|
||||
result, err := ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
// An unparseable / empty body on an HTTP error (common with a
|
||||
// missing Content-Type) must be classified by status, not reported
|
||||
// as an internal decode failure, matching the non-JSON branch above.
|
||||
if resp.StatusCode >= 400 {
|
||||
return httpStatusError(resp.StatusCode, resp.RawBody)
|
||||
}
|
||||
return WrapJSONResponseParseError(err, resp.RawBody)
|
||||
}
|
||||
if apiErr := check(result, identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
// CheckResponse treats business code 0 as success, so a 4xx/5xx whose
|
||||
// JSON body omits a non-zero code would otherwise be served as a
|
||||
// successful result. Classify by HTTP status so it is never swallowed.
|
||||
if resp.StatusCode >= 400 {
|
||||
return httpStatusError(resp.StatusCode, resp.RawBody)
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
// File downloads keep the existing raw-response scan path because the
|
||||
// saved payload is the API response body, not the success envelope.
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
|
||||
if opts.JqExpr != "" || opts.Format == output.FormatJSON {
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(identity),
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
})
|
||||
}
|
||||
|
||||
// Content safety scanning for non-JSON presentation formats.
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(opts.Out, result, opts.Format)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
if opts.JqExpr != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
// No --output: auto-save with derived filename.
|
||||
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
|
||||
if err != nil {
|
||||
return classifySaveErr(err)
|
||||
}
|
||||
fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct)
|
||||
output.PrintJson(opts.Out, meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
|
||||
meta, err := SaveResponse(fio, resp, path)
|
||||
if err != nil {
|
||||
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.
|
||||
func IsJSONContentType(ct string) bool {
|
||||
return strings.Contains(ct, "application/json") || strings.Contains(ct, "text/json")
|
||||
}
|
||||
|
||||
// ParseJSONResponse decodes a raw SDK response body as JSON.
|
||||
// CallAPI and HandleResponse both delegate to this function.
|
||||
func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
|
||||
var result interface{}
|
||||
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ── File saving ──
|
||||
|
||||
// SaveResponse writes an API response body to the given outputPath and returns metadata.
|
||||
// It delegates to FileIO.Save for path validation and atomic write; fio must not be nil.
|
||||
func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
|
||||
result, err := fio.Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(resp.RawBody)),
|
||||
}, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
var me *fileio.MkdirError
|
||||
var we *fileio.WriteError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return nil, fmt.Errorf("unsafe output path: %w", err)
|
||||
case errors.As(err, &me):
|
||||
return nil, fmt.Errorf("create directory: %w", err)
|
||||
case errors.As(err, &we):
|
||||
return nil, fmt.Errorf("cannot write file: %w", err)
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot write file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
resolvedPath, err := fio.ResolvePath(outputPath)
|
||||
if err != nil || resolvedPath == "" {
|
||||
resolvedPath = outputPath
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"saved_path": resolvedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveFilename picks a filename from the response headers.
|
||||
// Priority: Content-Disposition filename > Content-Type extension > "download.bin".
|
||||
func ResolveFilename(resp *larkcore.ApiResp) string {
|
||||
if name := larkcore.FileNameByHeader(resp.Header); name != "" {
|
||||
return name
|
||||
}
|
||||
return "download" + mimeToExt(resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// mimeToExt maps a Content-Type to a file extension (with leading dot).
|
||||
func mimeToExt(ct string) string {
|
||||
if ct == "" {
|
||||
return ".bin"
|
||||
}
|
||||
mediaType, _, _ := mime.ParseMediaType(ct)
|
||||
switch mediaType {
|
||||
case "application/pdf":
|
||||
return ".pdf"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "text/plain":
|
||||
return ".txt"
|
||||
case "text/csv":
|
||||
return ".csv"
|
||||
case "text/html":
|
||||
return ".html"
|
||||
case "application/zip":
|
||||
return ".zip"
|
||||
case "application/xml", "text/xml":
|
||||
return ".xml"
|
||||
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
||||
return ".xlsx"
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
return ".docx"
|
||||
case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
return ".pptx"
|
||||
default:
|
||||
return ".bin"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
|
||||
return newApiRespWithStatus(200, body, headers)
|
||||
}
|
||||
|
||||
func newApiRespWithStatus(status int, body []byte, headers map[string]string) *larkcore.ApiResp {
|
||||
h := http.Header{}
|
||||
for k, v := range headers {
|
||||
h.Set(k, v)
|
||||
}
|
||||
return &larkcore.ApiResp{
|
||||
StatusCode: status,
|
||||
Header: h,
|
||||
RawBody: body,
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsJSONContentType_Extended(t *testing.T) {
|
||||
tests := []struct {
|
||||
ct string
|
||||
want bool
|
||||
}{
|
||||
{"application/json", true},
|
||||
{"application/json; charset=utf-8", true},
|
||||
{"text/json", true},
|
||||
{"application/octet-stream", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsJSONContentType(tt.ct); got != tt.want {
|
||||
t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONResponse(t *testing.T) {
|
||||
body := []byte(`{"code":0,"msg":"ok","data":{"id":"123"}}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
result, err := ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseJSONResponse failed: %v", err)
|
||||
}
|
||||
m, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected map result")
|
||||
}
|
||||
if m["msg"] != "ok" {
|
||||
t.Errorf("expected msg=ok, got %v", m["msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONResponse_Invalid(t *testing.T) {
|
||||
resp := newApiResp([]byte(`not json`), map[string]string{"Content-Type": "application/json"})
|
||||
_, err := ParseJSONResponse(resp)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
_, err := ParseJSONResponse(resp)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty body")
|
||||
}
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("expected wrapped io.EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"from content-type pdf",
|
||||
map[string]string{"Content-Type": "application/pdf"},
|
||||
"download.pdf",
|
||||
},
|
||||
{
|
||||
"from content-type png",
|
||||
map[string]string{"Content-Type": "image/png"},
|
||||
"download.png",
|
||||
},
|
||||
{
|
||||
"unknown type",
|
||||
map[string]string{"Content-Type": "application/octet-stream"},
|
||||
"download.bin",
|
||||
},
|
||||
{
|
||||
"empty content-type",
|
||||
map[string]string{},
|
||||
"download.bin",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp := newApiResp([]byte("data"), tt.headers)
|
||||
got := ResolveFilename(resp)
|
||||
if got != tt.want {
|
||||
t.Errorf("ResolveFilename() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimeToExt_Extended(t *testing.T) {
|
||||
tests := []struct {
|
||||
ct string
|
||||
want string
|
||||
}{
|
||||
{"application/pdf", ".pdf"},
|
||||
{"image/png", ".png"},
|
||||
{"image/jpeg", ".jpg"},
|
||||
{"image/gif", ".gif"},
|
||||
{"text/plain", ".txt"},
|
||||
{"text/csv", ".csv"},
|
||||
{"text/html", ".html"},
|
||||
{"application/zip", ".zip"},
|
||||
{"application/xml", ".xml"},
|
||||
{"text/xml", ".xml"},
|
||||
{"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"},
|
||||
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"},
|
||||
{"application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx"},
|
||||
{"application/octet-stream", ".bin"},
|
||||
{"", ".bin"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := mimeToExt(tt.ct); got != tt.want {
|
||||
t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
body := []byte("hello binary data")
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"})
|
||||
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse failed: %v", err)
|
||||
}
|
||||
if meta["size_bytes"] != int64(len(body)) {
|
||||
t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"])
|
||||
}
|
||||
|
||||
savedPath, _ := meta["saved_path"].(string)
|
||||
data, err := os.ReadFile(savedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved file: %v", err)
|
||||
}
|
||||
if !bytes.Equal(data, body) {
|
||||
t.Errorf("saved content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_CreatesDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin"))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse with nested dir failed: %v", err)
|
||||
}
|
||||
savedPath, _ := meta["saved_path"].(string)
|
||||
if _, err := os.Stat(savedPath); err != nil {
|
||||
t.Errorf("expected file to exist at %s", savedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_JSON(t *testing.T) {
|
||||
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Identity: core.AsBot,
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse failed: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["ok"] != true {
|
||||
t.Fatalf("ok = %v, want true; output: %s", got["ok"], out.String())
|
||||
}
|
||||
if got["identity"] != "bot" {
|
||||
t.Fatalf("identity = %v, want bot; output: %s", got["identity"], out.String())
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code field: %s", out.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %T, want object; output: %s", got["data"], out.String())
|
||||
}
|
||||
if data["id"] != "1" {
|
||||
t.Fatalf("data.id = %v, want 1; output: %s", data["id"], out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
|
||||
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Identity: core.AsBot,
|
||||
JqExpr: ".data.id",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "1" {
|
||||
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_JSONWithError(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for non-zero code")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if strings.Contains(out.String(), `"ok": true`) || strings.Contains(out.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse binary failed: %v", err)
|
||||
}
|
||||
if !bytes.Contains(errOut.Bytes(), []byte("binary response detected")) {
|
||||
t.Errorf("expected binary detection message, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryWithOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
OutputPath: "out.png",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse with output path failed: %v", err)
|
||||
}
|
||||
data, _ := os.ReadFile("out.png")
|
||||
if string(data) != "PNG DATA" {
|
||||
t.Errorf("expected saved PNG DATA, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_NonJSONError_404(t *testing.T) {
|
||||
resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 text/plain")
|
||||
}
|
||||
got := err.Error()
|
||||
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 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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_NonJSONError_502(t *testing.T) {
|
||||
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), map[string]string{"Content-Type": "text/html"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 502 text/html")
|
||||
}
|
||||
got := err.Error()
|
||||
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 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))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed pins that an HTTP
|
||||
// status error whose JSON body omits a non-zero business code (e.g. 400 +
|
||||
// {"code":0,...}) still surfaces a typed error. CheckResponse treats code 0 as
|
||||
// success, so without the HTTP-status fallback a 4xx would be served as a
|
||||
// successful result and exit 0.
|
||||
func TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed(t *testing.T) {
|
||||
resp := newApiRespWithStatus(400, []byte(`{"code":0,"msg":"bad request"}`),
|
||||
map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got out=%q err=nil", out.String())
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("expected *errs.APIError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 400") {
|
||||
t.Errorf("expected 'HTTP 400' in error, got: %s", err.Error())
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleResponse_NoContentTypeError_404 pins that a 404 with an empty body
|
||||
// and no Content-Type header — which falls into the JSON branch and fails to
|
||||
// parse — is classified by HTTP status (api/not_found), not reported as an
|
||||
// internal decode failure.
|
||||
func TestHandleResponse_NoContentTypeError_404(t *testing.T) {
|
||||
resp := newApiRespWithStatus(404, []byte(""), nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 with empty body and no Content-Type")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("expected *errs.APIError, got %T", err)
|
||||
}
|
||||
if apiErr != nil && apiErr.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("subtype = %q, want not_found", apiErr.Subtype)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleResponse_NoContentTypeError_502 pins that a 5xx with a non-JSON
|
||||
// body and no Content-Type is classified as a NetworkError by status, not an
|
||||
// internal decode failure.
|
||||
func TestHandleResponse_NoContentTypeError_502(t *testing.T) {
|
||||
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 502 with non-JSON body and no Content-Type")
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for 200 text/plain, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "binary response detected") {
|
||||
t.Errorf("expected binary detection message, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
|
||||
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
JqExpr: ".data",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --jq is used with non-JSON response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--jq requires a JSON response") {
|
||||
t.Errorf("expected '--jq requires a JSON response' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path traversal")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsafe output path") {
|
||||
t.Errorf("expected 'unsafe output path' wrapper, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"})
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse failed: %v", err)
|
||||
}
|
||||
savedPath, _ := meta["saved_path"].(string)
|
||||
if !filepath.IsAbs(savedPath) {
|
||||
t.Errorf("saved_path should be absolute, got %q", savedPath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user