Skip to content
75 changes: 69 additions & 6 deletions cmd/api/api_paginate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,15 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal/unknown", problem, ok)
}
var paginationErr *errs.PaginationError
if !errors.As(err, &paginationErr) {
t.Fatalf("apiPaginate() error = %T (%v), want PaginationError", err, err)
}
if paginationErr.CompletedPages != 1 || paginationErr.NextPageToken != "next-1" {
t.Fatalf("progress = %d/%q, want 1/next-1", paginationErr.CompletedPages, paginationErr.NextPageToken)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
Expand Down Expand Up @@ -290,7 +297,7 @@ func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
}
}

func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
func TestAPIPaginate_BusinessErrorsReturnProgressWithoutStdout(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
Expand Down Expand Up @@ -322,9 +329,19 @@ func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
var paginationErr *errs.PaginationError
if !errors.As(err, &paginationErr) {
t.Fatalf("error = %T (%v), want PaginationError", err, err)
}
if paginationErr.CompletedPages != 0 || paginationErr.NextPageToken != "" {
t.Fatalf("progress = %d/%q, want 0/empty", paginationErr.CompletedPages, paginationErr.NextPageToken)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeUnknown || problem.Code != 123456 {
t.Fatalf("ProblemOf = %#v, %v; want api/unknown code 123456", problem, ok)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
Expand Down Expand Up @@ -357,6 +374,17 @@ func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
var paginationErr *errs.PaginationError
if !errors.As(err, &paginationErr) {
t.Fatalf("error = %T (%v), want PaginationError", err, err)
}
if paginationErr.CompletedPages != 0 || paginationErr.NextPageToken != "" {
t.Fatalf("progress = %d/%q, want 0/empty", paginationErr.CompletedPages, paginationErr.NextPageToken)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork {
t.Fatalf("ProblemOf = %#v, %v; want original network classification", problem, ok)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
Expand All @@ -367,6 +395,37 @@ func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
}
}

func TestAPIPaginate_DecodeErrorReturnsProgressWithoutStdout(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
RawBody: []byte(`{"code":`),
})

err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want decode error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
var paginationErr *errs.PaginationError
if !errors.As(err, &paginationErr) {
t.Fatalf("error = %T (%v), want PaginationError", err, err)
}
if paginationErr.CompletedPages != 0 || paginationErr.NextPageToken != "" {
t.Fatalf("progress = %d/%q, want 0/empty", paginationErr.CompletedPages, paginationErr.NextPageToken)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("ProblemOf = %#v, %v; want internal/invalid_response", problem, ok)
}
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("decode error wrote stdout/stderr: %q / %q", out.String(), errOut.String())
}
}

func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
Expand All @@ -387,6 +446,10 @@ func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
var paginationErr *errs.PaginationError
if !errors.As(err, &paginationErr) || paginationErr.CompletedPages != 0 || paginationErr.NextPageToken != "" {
t.Fatalf("pagination progress = %#v, want 0 completed pages and no token", paginationErr)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
Expand Down
18 changes: 9 additions & 9 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
}
}

func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
func TestApiCmd_PageAll_NonBatchAPI_ErrorReturnsPaginationProgress(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
})
Expand All @@ -468,17 +468,17 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
if err == nil {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230027") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
if stdout.Len() != 0 {
t.Fatalf("pagination error wrote stdout: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "user not authorized") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var paginationErr *errs.PaginationError
if !errors.As(err, &paginationErr) {
t.Fatalf("expected PaginationError, got %T: %v", err, err)
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
if paginationErr.CompletedPages != 0 || paginationErr.NextPageToken != "" {
t.Fatalf("progress = %d/%q, want 0/empty", paginationErr.CompletedPages, paginationErr.NextPageToken)
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
Expand Down
57 changes: 29 additions & 28 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"

lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"

"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
Expand Down Expand Up @@ -80,15 +81,22 @@
return "", "", err
}

_, err = client.ClassifyAPIResponse(apiResp, func(result interface{}) error {
raw, _ := result.(map[string]interface{})
return errclass.BuildAPIError(raw, errclass.ClassifyContext{Identity: string(core.AsUser)})
})
if err != nil {
return "", "", err
}

var resp userInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return "", "", fmt.Errorf("failed to parse user info: %w", err)
}
if resp.Code != 0 {
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"failed to parse user info: %v", err).WithCause(err)

Check warning on line 95 in cmd/auth/auth.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/auth.go#L94-L95

Added lines #L94 - L95 were not covered by tests
}
if resp.Data.OpenID == "" {
return "", "", fmt.Errorf("failed to get user info: missing open_id in response")
return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"failed to get user info: missing open_id in response")

Check warning on line 99 in cmd/auth/auth.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/auth.go#L98-L99

Added lines #L98 - L99 were not covered by tests
}

name = resp.Data.Name
Expand Down Expand Up @@ -146,12 +154,23 @@
return nil, err
}

cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
if cfg, _ := f.Config(); cfg != nil {
cc.Brand = string(cfg.Brand)
cc.AppID = appId
}
_, err = client.ClassifyAPIResponse(apiResp, func(result interface{}) error {
raw, _ := result.(map[string]interface{})
return errclass.BuildAPIError(raw, cc)
})
if err != nil {
return nil, err
}

var resp appInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if resp.Code != 0 {
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"failed to parse response: %v", err).WithCause(err)

Check warning on line 173 in cmd/auth/auth.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/auth.go#L172-L173

Added lines #L172 - L173 were not covered by tests
}

app := resp.Data.App
Expand All @@ -170,21 +189,3 @@

return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
}

// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the
// upstream `error` block — the typed appInfoResponse shape drops it.
func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error {
var raw map[string]any
_ = json.Unmarshal(rawBody, &raw)
if raw == nil {
raw = map[string]any{}
}
raw["code"] = code
raw["msg"] = msg
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
if cfg, _ := f.Config(); cfg != nil {
cc.Brand = string(cfg.Brand)
cc.AppID = appId
}
return errclass.BuildAPIError(raw, cc)
}
Loading
Loading