Files
larksuite--cli/internal/client/api_errors_test.go
T
wehub-resource-sync bf9395e022
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
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

312 lines
13 KiB
Go

// 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())
}
}